自带异步渲染的前端框架: Crank

发布于:2020 年 6 月 28 日 14:51

自带异步渲染的前端框架: Crank

本文要点:

  • 主要的前端框架,如 React,在不断增加特性的同时也变得越来越复杂。与这些框架一起使用的其他工具、语法和生态系统的复杂性也在增加。
  • 复杂性增加的一部分原因是,大型框架由于用户众多,需要保持高度的向后兼容性和稳定性。因此,它们就有理由不去重新考虑关键的设计选择。
  • Crank 重新研究了类似 React 这样的框架的关键架构部分,该部分规定渲染函数必须为纯函数。相反,Crank 利用异步生成器执行异步渲染,没有任何成本。异步生成器是 JavaScript 的一种标准语言特性,不用承担实现该功能的库的成本。
  • 借助语言自带的生成器和 async/await 语法,开发人员可以像处理同步任务一样自然地处理异步任务(获取远程数据、暂停和恢复渲染)。实现前端应用程序时需要掌握的、与这门语言无关的概念减少了。

Brian Kim 是开源库 Repeater.js 的作者,他最近发布了一个新的用于创建 Web 应用程序的 JavaScript 库 Crank 。Crank 的创新之处在于,它使用协程声明式地描述了应用程序的行为,这是用 JavaScript 及异步生成器实现的。

虽然 Crank 尚在测试阶段,还需要进一步的研究,但它支持的异步渲染可能可以处理类似 React 提供的 Suspense 功能这样的用例。

当生成器运行时,它可能会接收数据(初始的 prop),返回一个迭代器,用于访问生成器闭包中保存的私有状态。迭代器在迭代方(Crank 库函数)请求时计算并生成视图,而迭代方在其迭代请求中传递更新后的 prop。Crank 异步迭代器返回一个 promise,使计算视图得以渲染,从而提供异步渲染能力。这样,Crank 组件就自然地提供了局部 state 和 effect 支持,而不需要专门的语法。另外,Crank 组件的生命周期就是生成器的生命周期:生成器在装载匹配的 DOM 元素时启动,在卸载匹配的 DOM 元素时停止。错误可以使用标准的 JavaScript 结构try... catch捕获。

还有其他框架用 JavaScript 生成器来创建 Web 应用程序。 Concur UI 从 Haskell 移植到 JavaScript, PureScript 和 Python 使用异步生成器组合组件。 Turbine 将自己描述为一个毫不妥协的纯函数 Web 框架,它利用生成器实现了 FRP 范式。

InfoQ 采访了 Brian Kim,内容涉及这个新的 JavaScript 框架的基本原理,以及他认为利用 JavaScript 生成器可以获得哪些好处。

InfoQ:您可以向我们的读者介绍下自己吗?

Brian Kim:我是一名独立的前端工程师。我整个编程生涯几乎都在使用 React——你甚至可以在 2013 年的一篇 React 博文中看到我的名字。

我也是开源异步迭代器库 Repeater.js 的创建者和维护者。该库旨在成为创建安全的异步迭代器所缺少的构造函数。[…] 我创建了 repeaters,这是一个看起来很像 Promise 构造函数的实用工具类,它让你可以更轻松地将基于回调的 API 转换为异步迭代器。

InfoQ:您能快速地为我们介绍下 repeaters 的设计目标吗?

Kim:Repeaters 采用了我多年来学到的许多好的异步迭代器设计实践,比如延迟执行、使用有界队列、处理反压以及以可预测的方式传播错误。本质上,它是一个精心设计的 API,让开发人员可以顺利地使用异步迭代器,确保他们的事件处理程序总是得到清理,而瓶颈和死锁可以被迅速发现。

InfoQ:您最近发布了 Crank.js。您将其描述为一个新的创建 Web 应用程序的 Web 框架。为什么要创建一个新的 JavaScript 框架?

Kim:我知道,感觉像是每周都有一个新的 JavaScript 框架发布,我在介绍 Crank 的博文中甚至为又创建了一个框架而道歉。我创建 Crank 是因为我对最新的 React API(如 Hooks 和 Suspense)感到失望,但我仍然希望使用靠着 React 流行起来的 JSX 和元素比较算法。我已经愉快地使用 React 超过五年了,所以我是用了很长时间后才说受够了,并编写了自己的框架。

InfoQ:是什么让您无法忍受了?

Kim:我想我的挫败感是从钩子开始的。我之前很兴奋,React 团队致力于避免组件拥有状态,使其函数语法更有用,但是我担心“钩子的规则”,它们似乎很容易规避,这对其他框架是不公平的,因为它有权调用任何名字以use开头的函数。然后,当我开始在实践中学习更多关于钩子的知识,并看到了letconst发明以前在 JavaScript 中从未见过的新的 stale closure 缺陷时,我开始怀疑钩子是不是最好的方法。

但对我来说真正的转折点是 Suspense 项目。[…]

InfoQ:您能详细地说明下吗?

Kim:这时,我开始试用 Suspense,因为我认为它将允许人们使用我编写的异步迭代器钩子,就好像它们是同步的一样。但是,我很快就发现,我实际上不可能使用 Suspense,因为它对缓存有严格的要求,而且我也不清楚如何缓存以及重用我的钩子所依赖的异步迭代器。

Suspense 以及 React 中异步数据获取的实现需要缓存,这让我有些震惊,因为到目前为止,我只是假设可以在 React 组件中获得类似于 async/await 这样的特性。[…] 我非常担心,我必须使用 key 并失效每一个为了使用 promises 而进行的异步调用。

[我开始意识到]React 在组件中使用componentDidWhat或钩子所做的每件事都可以封装成一个单一的异步生成器函数:

复制代码
async function *MyComponent(props)
let state = componentWillMount(props);
let ref = yield <MyElement />;
state = componentDidMount(props, state, ref);
try {
for await (const nextProps of updates()) {
if (shouldComponentUpdate(props, nextProps, state)) {
state = componentWillUpdate(props, nextProps, state);
ref = yield <MyElement />;
state = componentDidUpdate(props, nextProps, state, ref);
}
props = nextProps;
}
} catch (err) {
return componentDidCatch(err);
} finally {
componentWillUnmount(ref);
}
}

[…] 通过生成 JSX 元素而不是返回它们,你可以在渲染之前和之后编写代码,类似于componentWillUpdatecomponentDidUpdate。State 变成局部变量,而新的 props 可以通过框架提供的异步迭代器传入,甚至可以使用 JavaScript 控制流如 try/catch/finally 从子组件捕获错误并编写清理逻辑,所有这些都在相同的作用域内。

InfoQ:所以您决定使用异步生成器作为这个新框架的基础?

Kim:[…] React 团队安排了大量的工程人才来构建一个“UI 运行时”,我 [意识到我] 可以把最困难的部分如堆栈暂挂或调度委托给 JavaScript 运行时,它提供生成器、异步函数和一个微任务队列来完成这些工作。我觉得 React 团队所做的一切让人印象深刻,作为一名程序员,那是我所力不能及的,但是,他们把这些特性以原生 JavaScript 的形式提供了出来,我只需要弄清楚如何把这些拼图拼在一起就可以了。

组件不是只能用同步函数编写,还可以用异步函数,以及同步和异步生成器函数。我走了弯路,在此之前,我一直埋头于我的一个创业点子。在对这个想法进行了长达数月的调查研究之后,Crank 诞生了。坦白地说,我希望回到编写应用程序而不是框架的工作中来,但是,JavaScript 社区突如其来的兴趣让 Crank 成了一个愉快的意外。

InfoQ:您提到,Crank.js 利用了基于 JSX 的组件和异步生成器。JSX 组件在使用渲染函数的框架(某种程度上类似于 React 这样的框架或 Vue)中非常常见。而生成器很少使用,异步生成器就更少用了。这些构造与开发 Web 应用程序有什么关系?

Kim:我绝对不是第一个试验生成器和异步生成器的人;我一直在 GitHub 上寻找新的想法,我看到在前端领域有很多人在用生成器做试验。

然而,也许是由于 JavaScript 中的生成器早期与 async/await 和 promises 存在关联,作为指定组件异步依赖项的一种方式,这些库中似乎有许多都使用生成器来生成 promises 而只返回 JSX 元素。我意识到,我们还可以简单地生成 JSX 元素,并基于虚拟 DOM 比较算法为异步组件找到一个单独的语义。

最后,我认为,JSX 元素和生成器实际上是完美的搭配:你生成元素,框架渲染它们,渲染后的节点通过调用以某种响应模式传递回生成器。我认为,总的来说,很多人,特别是那些有函数编程背景的人,往往不急于采用迭代器和生成器,因为它们是有状态的数据结构,这些人认为,生成器的有状态性增加了推理难度。但实际上,我认为,这是生成器的一个很好的特性,至少在 JavaScript 中,有状态过程建模的最好方法是使用有状态抽象。

将组件生命周期建模为生成器,我们不仅能够在一个函数中捕获 DOM 的状态并建模,我们还能以非常透明的方式完成这项工作,因为每个组件实例只有一个生成器执行,其闭包会在两次渲染之间保留。在 Crank 中,同步生成器组件的恢复次数等于父组件更新它的次数加上组件本身更新的次数。这种可以推理组件执行准确次数的能力,人们基本不再期待 React 会提供,在实践中,这意味着我们可以借助 Crank 把副作用直接放到“渲染方法”中,因为这个框架不会在你不期望的时候不断地重新渲染你的组件。

InfoQ:您从开发人员那里收到了什么反馈吗?

Kim:我收到过这样的反馈:“我希望我们在 Rust 中也能有这样的东西。”我很高兴看到人们参照 Crank 的思想,然后用其他语言实现它们,这些语言可能会有像 Rust futures 这样更强大的抽象。

InfoQ:有哪些事情用 Crank 更简单,用其他框架更难?您能举个例子吗?

Kim:因为所有状态都是局部变量,所以我们可以在生成器组件中自由地组合来自 React 的概念,比如 props、state 和 refs,这是其他框架无法做到的。例如,这个组件示例比较了新旧 props,并根据它们是否匹配来渲染一些不同的东西,在 Crank 开发早期,我对此感到很震惊:

复制代码
function *Greeting({name}) {
yield <div>Hello {name}</div>;
for (const {name: newName} of this) {
if (name !== newName) {
yield (
<div>Goodbye {name} and hello {newName}</div>
);
} else {
yield <div>Hello again {newName}</div>;
}
name = newName;
}
}
renderer.render(<Greeting name="Alice" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello Alice</div>"
renderer.render(<Greeting name="Alice" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello again Alice</div>"
renderer.render(<Greeting name="Bob" />, document.body);
console.log(document.body.innerHTML); // "<div>Goodbye Alice and hello Bob</div>"
renderer.render(<Greeting name="Bob" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello again Bob</div>"

我们不需要一个单独的生命周期或钩子来比较新旧 props,我们只需要在同一个闭包中引用它们。简而言之,比较新旧 props 就像比较数组中的相邻元素一样简单。

此外,因为 Crank 将局部状态的概念与重新渲染解耦,我认为它解锁了许多在其他框架中不可能实现的高级渲染模式。例如,你可以想象这样一种架构:子组件具有局部状态,但不会重新渲染,而是由在 requestAnimationFrame 循环中渲染的单个父组件一次性渲染。有状态的组件不必在每次更新时都重新渲染,在 Crank 中这很容易实现,因为我们已经从渲染中解耦了状态。

举个例子,你可以看看我制作的这个快速演示,我在其中实现了一个 3D 立方体 / 球体演示,去年,React 和 Svelte 的用户在 Twitter 上讨论过这个演示。我对 Crank 的性能上限感到兴奋,因为更新组件是通过生成器逐步完成的,当状态只是局部变量,而有状态性本身并没有与反应式系统紧密耦合(迫使每个有状态组件重新渲染,即使有一个祖先组件会重新渲染它)时,你还可以在用户空间做很多有趣的优化。虽然在 Crank 最初的版本中,我更关注的是正确性和 API 设计,而不是性能,但我目前正努力提升 Crank 的速度,而且结果看起来很有希望,尽管我还没有计划对 Crank 的性能提什么具体的要求。

InfoQ:反过来说,有什么事情使用其他框架更简单,而使用 Crank 更难?

Kim:我曾批评过 Concurrent Mode 和 React 的未来发展方向,但如果 React 团队能够完成,那么看到组件可以根据主线程拥挤程度而自动调度渲染,还是让人感觉很神奇的。关于如何在 Crank 中实现这类调度,我有一些想法,但我没有任何具体的解决方案。我的希望是,既然你可以直接在组件中 await,那么我们就可以在用户空间中以一种透明、可选的方式直接实现调度。

此外,尽管我不喜欢 React 的钩子,但对于库作者如何将他们的整个 API 封装在一个或两个钩子中,我还是有一些话要说。有一件我应该料到但实际没料到的事情是,早期的采用者大力呼吁使用类似钩子这样的特性来将他们的库与 Crank 集成。我还不确定那会是什么样子,但我也有一些想法。

受访者介绍:

Brian Kim是一名独立的前端工程师。他是开源异步迭代器库 Repeater.js 的创建者和维护者。该库旨在成为创建安全的异步迭代器所缺少的构造函数。

原文链接:

Crank, a New Front-End Framework with Baked-In Asynchronous Rendering - Q&A with Brian Kim

阅读数:2 发布于:2020 年 6 月 28 日 14:51

评论

发布
暂无评论