最近,在前端领域,围绕着“Signals”这个词,有一些热烈的讨论。不管是Preact还是Angular,似乎都在讨论该话题。
但它们并不是什么新东西。如果我们将其追溯到上个世纪 60 年代的研究,那么这就更算不上新鲜的事物了。它的基础采用了与第一个电子表格和硬件描述语言(如 Verilog 和 VHDL)相同的模型。
即便是在 JavaScript 中,从声明式 JavaScript 框架诞生开始,我们就拥有这种理念了。随着时间的推移,它们有了不同的名字,并且在这些年里不断流行了起来。现在,它又重新出现了,这是一个很好的时机,我们可以对它是什么以及为何需要它进行更多的介绍。
免责声明:我是 SolidJS 的作者。本文从我的角度介绍了演进的过程。尽管文中没有提及,但是Elm Signals、Ember的计算属性和Meteor都是很值得称道的。
如果你还不清楚 Signals 是什么以及它是如何运行的,请参阅我的这篇对细粒度反应性(Fine-Grained Reactivity)的介绍。
起初的蛮荒时代
有时候,我们会惊讶地发现,很多参与者在完全相同的时间形成了类似的方案。在声明式 JavaScript 框架的起步阶段,有三个方案在三个月内陆续发布,它们分别是Knockout.js(2010 年 7 月)、Backbone.js(2010 年 10 月)和Angular.js(2010 年 10 月)。
Angular 的脏值检查、Backbone 的模型驱动重渲染以及 Knockout 的细粒度更新,虽然它们彼此间有些差异,但是最终都成为了我们今天管理 state 和更新 DOM 的基础。
Knockout.js 对本文的主题特别重要,因为它们的细粒度更新是建立在所谓的“Signals”的基础之上的。他们最初引入了两个概念,分别为 observable(状态)和 computed(副作用),但是在接下来的几年中,他们在前端语言中引入了第三个概念 pureComputed(衍生状态)。
狂野时代
在这个时代,服务器端开发的 MVC 和过去几年从 jQuery 中学到的模式进行了融合,形成了新的模式。其中,最常见的一个模式叫做数据绑定,Angular.js 和 Knockout.js 都具有该模式,不过实现方式略有不同。
数据绑定的概念是,state(状态)应该被关联(attached)到 view tree(视图树)的一个特定部分上。借助这种方式,能够实现的一种强大功能叫做双向绑定。所以,我们可以让状态更新 DOM,反过来,DOM 事件会自动更新状态,所有的这一切均是以一种简单的声明方式实现的。
但是,滥用这种力量最终会作茧自缚。我们构建应用的时候,对其缺乏足够深入的了解。在 Angular 中,如果不知道什么内容发生变化,就会对整个树进行脏值检查,而向上传播会导致它多次发生。在 Knockout 中,很难跟踪变化的路径,因为你会在 DOM 上走来走去,出现循环也是司空见惯的。
当React出现的时候,我们已经准备好逃离这一切了,对我个人来说,是Jing Chen的演讲,让我稳住了阵脚。
- 3.0x
- 2.5x
- 2.0x
- 1.5x
- 1.25x
- 1.0x
- 0.75x
- 0.5x
自由时刻
接下来,就是对 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-alive 和 Suspense。甚至像并发渲染这样的功能也可以用这种方式来实现,从而充分体现了如何同时利用基于推送和拉取的方式能够达成的最佳效果。
编译
在 2019 年,Svelte 3向我们展示了利用编译器能够完成多少事情。实际上,他们将反应性完全编译掉了。在这过程中,也会有一些权衡,Svelte 向我们展示了编译器如何抹平人类工程学方面的欠缺。这将会成为一种趋势。
反应式语言(如状态、衍生状态、作用)不仅向我们描述了用户界面等同步系统所需的所有内容,而且它是可分析的。我们可以精确地知道都发生了哪些变更以及它们发生在什么地方。可追溯性的潜力是很深远的。
来自Preact团队的 Marvin Hagemeister在Twitter这样说到,“我认为这是基于 Signals 的方式优于钩子(hook)的主要原因之一。它能够使我们添加更多的调试洞察力,这是钩子所无法实现的,比如准确地显示一个状态发生变更的原因。”
如果能够在编译时知道这一切,我们就可以交付更少的 JavaScript 代码。对于代码的加载,我们会有更高的自由度。这就是Qwik和Marko的可恢复性的基础。
面向未来的 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,我迫不及待地想知道我们的下一步会是什么。
原文链接:
https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob
评论