[关闭]
@levinzhang 2023-03-08T13:29:31.000000Z 字数 4037 阅读 236

JavaScript中Signals的演进

摘要

在目前的前端领域,Signals是一个非常热门的领域,Vue、Angular和React等主流前端框架都表示出了极大的兴趣。Angular甚至决定在Angular 16中基于Signals重构其变更探测,本文从JavaScript语言和前端框架的历史总结了反应式和Signals的演进历程。


本文最初发表于DEV社区网站,由InfoQ中文站翻译分享。

最近,在前端领域,围绕着“Signals”这个词,有一些热烈的讨论。不管是Preact还是Angular,似乎都在讨论该话题。

但它们并不是什么新东西。如果我们将其追溯到上个世纪60年代的研究,那么这就更算不上新鲜的事物了。它的基础采用了与第一个电子表格和硬件描述语言(如Verilog和VHDL)相同的模型。

即便是在JavaScript中,从声明式JavaScript框架诞生开始,我们就拥有这种理念了。随着时间的推移,它们有了不同的名字,并且在这些年里不断流行了起来。现在,它又重新出现了,这是一个很好的时机,我们可以对它是什么以及为何需要它进行更多的介绍。

免责声明:我是SolidJS的作者。本文从我的角度介绍了演进的过程。尽管文中没有提及,但是Elm SignalsEmber的计算属性Meteor都是很值得称道的。
如果你还不清楚Signals是什么以及它是如何运行的,请参阅我的这篇对细粒度反应性(Fine-Grained Reactivity)的介绍。

起初的蛮荒时代

有时候,我们会惊讶地发现,很多参与者在完全相同的时间形成了类似的方案。在声明式JavaScript框架的起步阶段,有三个方案在三个月内陆续发布,它们分别是Knockout.js(2010年7月)、Backbone.js(2010年10月)和Angular.js(2010年10月)。

Angular的脏值检查、Backbone的模型驱动重渲染以及Knockout的细粒度更新,虽然它们彼此间有些差异,但是最终都成为了我们今天管理状态和更新DOM的基础。

Knockout.js对本文的主题特别重要,因为它们的细粒度更新是建立在所谓的“Signals”的基础之上的。他们最初引入了两个概念,分别为observable(状态)和computed(副作用),但是在接下来的几年中,他们在前端语言中引入了第三个概念pureComputed(衍生状态)。

  1. const count = ko.observable(0);
  2. const doubleCount = ko.pureComputed(() => count() * 2);
  3. // 每当doubleCount更新时,打印日志记录
  4. ko.computed(() => console.log(doubleCount()))

狂野时代

在这个时代,服务器端开发的MVC和过去几年从jQuery中学到的模式进行了融合,形成了新的模式。其中,最常见的一个模式叫做数据绑定,Angular.js和Knockout.js都具有该模式,不过实现方式略有不同。

数据绑定的概念是,状态应该被关联(attached)到视图树的一个特定部分上。借助这种方式,能够实现的一种强大功能叫做双向绑定。所以,我们可以让状态更新DOM,反过来,DOM事件会自动更新状态,所有的这一切均是以一种简单的声明方式实现的。

但是,滥用这种力量最终会作茧自缚。我们构建应用的时候,对其缺乏足够深入的了解。在Angular中,如果不知道什么内容发生变化,就会对整个树进行脏值检查,而向上传播会导致它多次发生。在Knockout中,很难跟踪变化的路径,因为你会在DOM上走来走去,出现循环也是司空见惯的。

React出现的时候,我们已经准备好逃离这一切了,对我个人来说,是Jing Chen的演讲,让我稳住了阵脚。

自由时刻

接下来,就是对React的采用。有些人依然喜欢反应式模型,因为React对状态管理没有自己的偏好,所以完全可以将两者结合起来。

Mobservable(2015)就是这样的方案。但是,相对于与React的集成,它还带来了一些新的内容。它强调一致性和顺畅(glitch-free)的传播。也就是说,对于任何给定的变更,系统的每个部分仅运行一次,而且以适当的顺序同步运行。

为了实现这一点,它使用了一种推-拉(push-pull)混合的系统来替换先前方案中基于推送的反应性。变更的通知会被推送出去,但是衍生状态的执行会推迟到读取它的地方。

为了更好地理解Mobservable的原始方式,请参阅Michel Westrate的“Becoming Fully Reactive: An in Depth Explanation of Mobservable”一文。

虽然在很大程度上,这个细节会被React重新渲染读取变更的组件所掩盖,但是,这是使系统实现可调试和一致性的关键步骤。在接下来的几年里,随着算法的不断完善,我们会看到一种趋势,那就是更多基于拉取的语义

征服泄露的观察者

细粒度反应性是四人组(Gang of Four)观察者模式的变种。虽然观察者模式是一个强大的同步模式,但是它也有一个典型的问题。一个Signal会保持对所有订阅者的强引用,所以长期存活的Signal会保留所有的订阅,除非进行手动处置。

这种记录方式在大量使用时会变得很复杂,尤其是在涉及嵌套的时候。在处理分支逻辑和树的时候嵌套很常见的,就像在构建UI视图时的那样。

有一个鲜为人知的库,叫做S.js(2013),它将会提供答案。S是独立于其他大多数解决方案而开发的,它更直接地以数字电路作为模型,所有的状态变化都在时钟周期内进行。S将其状态基元称为“Signals”。尽管它不是第一个使用该名字的,但它是我们今天使用该术语的起源。

更为重要的是,它引入了反应式所有权的概念。所有者会收集所有的子反应式作用域,并在所有者处置(disposal)自身或重新执行时,管理子反应式作用域的处置。反应式图会从一个根所有者开始,然后每个节点均作为它所拥有的后代。这个所有者模式不仅对处置过程很有用处,而且在反应式图中,建立了一种提供者/消费者(Provider/Consumer)上下文的机制。

调度

Vue(2014)也为我们今天的发展做出了巨大的贡献。除了在优化一致一致性方面与MobX的节奏保持一致之外,Vue从一开始就将细粒度反应性作为其核心。

虽然Vue和React都使用了虚拟DOM,但是Vue的反应性得到了最好的支持,这意味着它是与框架一起研发的,首先是作为内部机制,为其Options API提供支持,在过去的几年中,它成为了Composition API(2020)的前沿和核心。

Vue将推送/拉取向前推进了一步,能够调度任务何时会完成。默认情况下,Vue会收集所有的变更,但是下一个微任务在处理作用(effect)队列之前不会处理它们。

然而,这种调度也可以用来做其他的事情,比如keep-aliveSuspense。甚至像并发渲染这样的功能也可以用这种方式来实现,从而充分体现了如何同时利用基于推送和拉取的方式能够达成的最佳效果。

编译

在2019年,Svelte 3向我们展示了利用编译器能够完成多少事情。实际上,他们将反应性完全编译掉了。在这过程中,也会有一些权衡,Svelte向我们展示了编译器如何抹平人类工程学方面的欠缺。这将会成为一种趋势。

反应式语言(如状态、衍生状态、作用)不仅向我们描述了用户界面等同步系统所需的所有内容,而且它是可分析的。我们可以精确地知道都发生了哪些变更以及它们发生在什么地方。可追溯性的潜力是很深远的。

来自Preact团队的Marvin Hagemeister在Twitter这样说到,“我认为这是基于Signals的方式优于钩子(hook)的主要原因之一。它能够使我们添加更多的调试洞察力,这是钩子所无法实现的,比如准确地显示一个状态发生变更的原因。”

如果能够在编译时知道这一切,我们就可以交付更少的JavaScript代码。对于代码的加载,我们会有更高的自由度。这就是QwikMarko的可恢复性的基础。

面向未来的Signals

Angular团队的成员Pawel Kozlowski则认为,“Signals是新的VDOM。人们对它的兴趣正在爆发:很多人正在尝试一些新东西。这将使我们能够探索该领域,尝试不同的策略,对其增进了解和优化。虽然现在不知道最终结果是什么,但是这种集体探索是很好的!”

鉴于这项技术已经非常古老,说还有很多东西需要探索,这可能会令人感到惊讶。但是,这里的原因在于,它是一种对解决方案进行建模的方式,而不是一种具体的方案。它所提供的是一种描述状态同步的语言,与要让它执行的副作用完全无关。

因此,它能够被Vue、Solid、Preact、Qwik和Angular采用似乎并不足为奇。我们已经看到它进入了Rust的Leptos和Sycamore,表明DOM上的WASM不一定会慢。React甚至考虑在底层使用它。

来自React核心团队的Andrew Clark表示,“我们可能会在React中添加一个类似Signals的基元,但我并不认为这是一个编写UI代码的好方法。它对性能来说是很好的。但我更喜欢React的模式,在这种模式下,你每次都会假装重新创建所有的内容。我们的计划是使用一个编译器来实现与之相当的性能”。

也许这是一种合适的方式,因为React的虚拟DOM始终只是一个实现细节。

Signals和反应性语言似乎是一个交汇点。但是,这在JavaScript诞生之初却并不那么明显。也许这是因为JavaScript并不是最好的语言。我甚至可以说,长期以来,我们在前端框架设计中感受到的很多痛苦都是语言本身的问题。

无论这一切的结局如何,到目前为止,都是一次相当不错的旅程。有这么多人关注Signals,我迫不及待地想知道我们的下一步会是什么。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注