JavaScript 中信号的演变
最近,前端世界围绕“信号”一词引起了一些热议。从Preact到Angular,它们似乎很快就无处不在。
但它们并非新鲜事物。如果追溯到20世纪60年代末的研究,你会发现它们远非新鲜事物。其基础与第一批电子表格和硬件描述语言(如Verilog和VHDL)的建模相同。
即使在 JavaScript 中,自声明式 JavaScript 框架诞生以来,我们就一直拥有它们。随着时间的推移,它们拥有不同的名称,并且多年来时而流行时而过时。但现在我们又回到了这里,是时候进一步解释一下它们是如何以及为什么出现的了。
免责声明:我是SolidJS的作者。本文从我的影响角度反映了 SolidJS 的演变。Elm Signals、Ember 的计算属性和Meteor都值得一提,尽管本文未涉及。
不确定信号是什么或它如何工作?看看这篇关于细粒度反应性的介绍:
起初……
有时,你会惊讶地发现,多个团队几乎同时提出了类似的解决方案。声明式 JavaScript 框架的诞生,在三个月内相继发布了三个版本:Knockout.js(2010 年 7 月)、Backbone.js(2010 年 10 月)、Angular.js(2010 年 10 月)。
Angular 的脏检查、Backbone 的模型驱动重新渲染,以及 Knockout 的细粒度更新。虽然每个功能略有不同,但最终都成为了我们如今管理状态和更新 DOM 的基础。
Knockout.js 对本文主题尤为重要,因为它们的细粒度更新建立在我们称之为“信号”的概念之上。它们最初引入了两个概念observable
(状态)和computed
(副作用),但在接下来的几年里,又将第三个概念pureComputed
(派生状态)引入了前端语言。
const count = ko.observable(0);
const doubleCount = ko.pureComputed(() => count() * 2);
// logs whenever doubleCount updates
ko.computed(() => console.log(doubleCount()))
狂野西部
这些模式融合了我在服务端开发 MVC 时学到的模式以及过去几年 jQuery 的经验。其中一种特别常见的模式叫做数据绑定,Angular.js 和 Knockout.js 都使用了它,尽管方式略有不同。
数据绑定的理念是将状态绑定到视图树的特定部分。其中一个强大的功能是实现双向绑定。这样,我们就可以以简单的声明方式,让状态更新 DOM,反过来,DOM 事件也会自动更新状态。
然而,滥用这种权力最终却成了落井下石。我们当时缺乏经验,所以就这样构建了应用程序。在 Angular 中,如果不知道具体发生了哪些变更,它就会对整棵树进行脏检查,而向上传播可能会导致脏检查多次。在 Knockout 中,由于需要沿着树上下移动,追踪变更路径变得非常困难,而且循环往复的情况很常见。
当React提出解决方案时,对我个人而言,正是 Jing Chen 的演讲巩固了这一解决方案,我们已经做好了放弃的准备。
无故障
随之而来的是 React 的大规模采用。有些人仍然偏爱响应式模型,而且由于 React 对状态管理没有太多坚持,因此将两者混合使用是完全可行的。
Mobservable(2015 年推出,后简称为 MobX)就是那个解决方案。但它不仅与 React 协同工作,还带来了一些新的东西。它强调一致性和无故障传播。也就是说,对于任何给定的更改,系统的每个部分都只会同步运行一次,并且以正确的顺序运行。
它通过用推拉混合系统取代前代产品中典型的推送式响应来实现这一点。变更通知会被推送出去,但派生状态的执行会被推迟到读取状态的地方。
为了更好地理解 Mobservable 的原始方法,请查看: Michel Westrate 撰写的《成为完全反应式:对 Mobservable 的深入解释》。
虽然这个细节很大程度上被 React 会重新渲染读取更改的组件这一事实所掩盖,但这在使这些系统可调试和一致性方面迈出了巨大的一步。在接下来的几年里,随着算法变得更加精细,我们将看到一种基于拉取语义的趋势。
克服泄漏的观察者
细粒度响应式是“四人帮”观察者模式的一种变体。虽然它是一种强大的同步模式,但也存在一个经典问题。信号会对其订阅者保持强引用,因此长生命周期的信号会保留所有订阅,除非手动释放。
这种记账方式在大量使用时会变得极其复杂,尤其是在涉及嵌套的情况下。在处理分支逻辑和树状结构时,嵌套很常见,就像在构建 UI 视图时遇到的那样。
一个鲜为人知的库S.js (2013) 给出了答案。S 的开发独立于大多数其他解决方案,其模型更直接地模仿了所有状态变化都基于时钟周期的数字电路。它将其状态原语称为“信号”。虽然它并非第一个使用这个名称的库,但它正是我们今天使用的术语的来源。
更重要的是,它引入了响应式所有权的概念。所有者将收集所有子响应式作用域,并在其自身处置或需要重新执行时管理它们的处置。响应式图最初将包装在根所有者中,然后每个节点将作为其后代的所有者。这种所有者模式不仅可用于处置,还可以作为一种机制,在响应式图中构建提供者/消费者上下文。
调度
Vue(2014)也为我们今天的发展做出了巨大的贡献。除了在一致性优化方面与 MobX 保持同步之外,Vue 从一开始就将细粒度的响应式设计作为其核心。
虽然 Vue 与 React 共享虚拟 DOM,但一流的反应性意味着它首先作为支持其 Options API 的内部机制与框架一起发展,并在过去几年中成为 Composition API(2020 年)的核心。
Vue 通过调度工作完成时间,将推送/拉取机制向前推进了一步。默认情况下,Vue 会收集所有更改,但直到效果队列在下一个微任务上运行后才会处理。
然而,这种调度也可以用于执行诸如keep-alive
(无需计算成本即可保留屏幕外的图形)之类的操作,以及。这种方法甚至可以实现并发渲染之类Suspense
的操作,真正展示了如何充分利用基于拉和基于推的方法。
汇编
2019 年,Svelte 3向所有人展示了编译器的强大功能。事实上,它们完全消除了编译时的响应性。这并非毫无代价,但更有趣的是,Svelte 向我们展示了编译器如何消除人机工程学方面的缺陷。这将继续成为一种趋势。
反应性语言:状态、派生状态和效果;不仅为我们提供了描述用户界面等同步系统所需的一切,而且易于分析。我们可以准确地知道哪些变化以及在哪里发生了变化。可追溯性的潜力是巨大的:
如果我们知道在编译时可以减少 JavaScript 代码的加载,我们就可以更加自由地加载代码。这就是Qwik和Marko可恢复性的基础。
未来的信号
考虑到这项技术的历史悠久,说它还有更多值得探索的地方或许会令人惊讶。但这是因为,它是一种建模解决方案的方法,而非一种特定的解决方案。它提供的是一种描述状态同步的语言,与您希望它执行的任何副作用无关。
因此,它被 Vue、Solid、Preact、Qwik 和 Angular 采用或许并不令人意外。我们已经看到它进入 Rust,Leptos 和 Sycamore 证明了 DOM 上的 WASM并不一定很慢。React 甚至正在考虑在底层使用它:
也许这很合适,因为 React 的虚拟 DOM 始终只是一个实现细节。
信号和响应式语言似乎正在融合。但从信号首次引入 JavaScript 开始,这一点并不明显。或许是因为 JavaScript 并非最适合信号处理语言。我甚至可以说,如今我们在前端框架设计中感受到的很多痛苦都源于语言问题。
无论最终结果如何,到目前为止,这都是一段相当不错的旅程。现在有这么多人关注 Signals,我迫不及待地想看看我们接下来会如何发展。
文章来源:https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob