第一阶段(解释)
TC39 提案冠军:Daniel Ehrenberg、Yehuda Katz、Jatin Ramanathan、Shay Lewis、Kristen Hewell Garrett、Dominic Gannaway、Preston Sego、Milo M、Rob Eisenberg
原作者:罗布·艾森伯格和丹尼尔·埃伦伯格
本文档描述了 JavaScript 中信号的早期共同方向,类似于 ES2015 中 TC39 标准化 Promises 之前的 Promises/A+ 工作。使用 Polyfill 亲自尝试一下。
与 Promises/A+ 类似,这项工作的重点是协调 JavaScript 生态系统。如果这种协调成功,那么就可以根据该经验制定标准。几个框架作者正在合作开发一个可以支持他们的反应性核心的通用模型。当前草案基于 Angular、Bubble、Ember、FAST、MobX、Preact、Qwik、RxJS、Solid、Starbeam、Svelte、Vue、Wiz 等作者/维护者的设计输入...
与 Promises/A+ 不同,我们并不是试图解决面向开发人员的通用表面 API,而是解决底层信号图的精确核心语义。该提案确实包含一个完全具体的 API,但该 API 并不针对大多数应用程序开发人员。相反,这里的信号 API 更适合构建在其之上的框架,通过通用信号图和自动跟踪机制提供互操作性。
该提案的计划是在进入第一阶段之前进行重要的早期原型设计,包括集成到多个框架中。我们只对标准化信号感兴趣,如果它们适合在多个框架中实际使用,并提供比框架真正的好处 -提供的信号。我们希望重要的早期原型设计能够为我们提供这些信息。更多详情请参阅下面的“现状和发展计划”。
为了开发复杂的用户界面 (UI),JavaScript 应用程序开发人员需要以有效的方式存储、计算、无效、同步状态并将状态推送到应用程序的视图层。 UI 通常不仅仅涉及管理简单的值,还经常涉及渲染计算状态,该状态依赖于其他值或状态的复杂树,而该树本身也是计算的。 Signals 的目标是提供用于管理此类应用程序状态的基础设施,以便开发人员可以专注于业务逻辑而不是这些重复的细节。
独立发现类似信号的构造在非 UI 上下文中也很有用,特别是在构建系统中以避免不必要的重建。
信号用于反应式编程,以消除管理应用程序更新的需要。
用于根据状态更改进行更新的声明式编程模型。
来自什么是反应性? 。
给定一个变量counter
,无论计数器是偶数还是奇数,您都希望将其渲染到 DOM 中。每当counter
发生变化时,您都希望使用最新的奇偶校验来更新 DOM。在 Vanilla JS 中,你可能会遇到这样的情况:
让计数器 = 0;const setCounter = (值) => { 计数器=值; render();};const isEven = () => (counter & 1) == 0;const parity = () => isEven() ? "even" : "odd";const render = () => element.innerText = parity();// 模拟外部更新计数器...setInterval(() => setCounter(counter + 1), 1000);
这有很多问题...
counter
设置噪音大且样板代码繁重。
counter
状态与渲染系统紧密耦合。
如果counter
发生变化但parity
没有变化(例如计数器从 2 变为 4),则我们会进行不必要的奇偶校验计算和不必要的渲染。
如果我们 UI 的另一部分只想在counter
更新时渲染怎么办?
如果我们 UI 的另一部分仅依赖于isEven
或parity
怎么办?
即使在这个相对简单的场景中,也会很快出现许多问题。我们可以尝试通过为counter
引入 pub/sub 来解决这些问题。这将允许counter
的其他消费者可以订阅以添加他们自己对状态变化的反应。
然而,我们仍然面临以下问题:
仅依赖于parity
的渲染函数必须“知道”它实际上需要订阅counter
。
如果不直接与counter
交互,则无法单独基于isEven
或parity
来更新 UI。
我们增加了样板文件。每当您使用某些东西时,这不仅仅是调用函数或读取变量的问题,而是订阅并在那里进行更新的问题。管理取消订阅也特别复杂。
现在,我们可以通过添加 pub/sub 来解决几个问题,不仅可以用来counter
,还可以添加isEven
和parity
。然后,我们必须订阅isEven
到counter
, parity
到isEven
,并render
到parity
。不幸的是,不仅我们的样板代码爆炸了,而且我们还陷入了大量的订阅簿记工作,如果我们不以正确的方式正确清理所有内容,则可能会导致内存泄漏灾难。因此,我们解决了一些问题,但创建了全新的问题类别和大量代码。更糟糕的是,我们必须为系统中的每个状态经历整个过程。
尽管 JS 或 Web 平台中没有内置任何此类机制,但模型和视图 UI 中的数据绑定抽象长期以来一直是跨多种编程语言的 UI 框架的核心。在 JS 框架和库中,已经进行了大量的不同方式的实验来表示这种绑定,并且经验已经证明了单向数据流与表示状态或计算单元的第一类数据类型相结合的强大功能来自其他数据,现在通常称为“信号”。这种一流的反应价值方法似乎在 2010 年的 Knockout 中首次流行于开源 JavaScript Web 框架中。此后的几年里,已经创建了许多变体和实现。在过去的 3-4 年里,Signal 原语和相关方法获得了进一步的关注,几乎每个现代 JavaScript 库或框架都以这样或那样的名称提供类似的东西。
为了理解信号,让我们看一下上面的例子,它是用下面进一步阐述的信号 API 重新想象的。
const counter = new Signal.State(0);const isEven = new Signal.Compulated(() => (counter.get() & 1) == 0);const parity = new Signal.Compulated(() => isEven .get() ? "even" : "odd");// 库或框架基于其他信号原语定义效果效果(cb: () => void): (() => void);effect(() => element.innerText = parity.get());// 模拟外部更新计数器...setInterval(() => counter.set(counter.get() + 1), 1000 );
我们可以立即看到一些事情:
我们已经消除了前面示例中counter
变量周围的嘈杂样板。
有一个统一的 API 来处理值、计算和副作用。
counter
和render
之间不存在循环引用问题或颠倒依赖关系。
无需手动订阅,也无需记账。
有一种方法可以控制副作用计时/调度。
信号给我们带来的远不止 API 表面所看到的:
自动依赖性跟踪- 计算信号会自动发现它所依赖的任何其他信号,无论这些信号是简单值还是其他计算。
惰性求值- 计算在声明时不会立即求值,也不会在依赖项发生变化时立即求值。仅当明确请求其值时才会评估它们。
记忆化- 计算信号缓存其最后的值,以便其依赖项没有变化的计算不需要重新计算,无论它们被访问多少次。
每个信号实现都有自己的自动跟踪机制,以跟踪评估计算信号时遇到的源。这使得在不同框架之间共享模型、组件和库变得困难——它们往往与视图引擎存在错误耦合(考虑到信号通常作为 JS 框架的一部分实现)。
该提案的目标是将反应式模型与渲染视图完全解耦,使开发人员能够迁移到新的渲染技术,而无需重写其非 UI 代码,或者在 JS 中开发共享反应式模型以部署在不同的上下文中。不幸的是,由于版本控制和重复,通过 JS 级库达到强大的共享水平是不切实际的——内置函数提供了更强的共享保证。
由于内置了常用的库,因此减少代码的潜在性能提升总是一个小的潜在性能提升,但信号的实现通常非常小,因此我们预计这种影响不会很大。
我们怀疑信号相关数据结构和算法的原生 C++ 实现可能比 JS 中实现的效率稍微高出一个常数因子。然而,与 Polyfill 中存在的算法相比,预计不会发生算法变化;引擎在这里并不需要魔法,并且反应性算法本身将是明确定义的且明确的。
冠军小组希望开发信号的各种实现,并使用它们来研究这些性能的可能性。
使用现有的 JS 语言信号库,可能很难跟踪以下内容:
跨计算信号链的调用堆栈,显示错误的因果链
当一个信号依赖另一个信号时,信号之间的参考图——在调试内存使用情况时很重要
内置信号使 JS 运行时和开发工具能够改进对检查信号的支持,特别是调试或性能分析,无论这是内置在浏览器中还是通过共享扩展。可以更新元素检查器、性能快照和内存分析器等现有工具,以在信息呈现中专门突出显示信号。
一般来说,JavaScript 拥有相当小的标准库,但 TC39 的趋势是使 JS 更像是一种“自带电池”的语言,具有高质量的内置功能集。例如,Temporal 正在取代 moment.js,许多小功能(例如Array.prototype.flat
和Object.groupBy
正在取代许多 lodash 用例。好处包括更小的包大小、更高的稳定性和质量、加入新项目时需要学习的内容更少,以及 JS 开发人员普遍使用的通用词汇。
W3C 和浏览器实现者当前的工作正在寻求将本机模板引入 HTML(DOM 部件和模板实例化)。此外,W3C Web 组件 CG 正在探索扩展 Web 组件以提供完全声明性 HTML API 的可能性。为了实现这两个目标,HTML 最终将需要一个响应式原语。此外,通过集成信号对 DOM 进行许多人体工程学改进是可以想象的,并且社区已经要求这样做。
请注意,这种集成将是稍后的单独工作,而不是该提案本身的一部分。
有时,即使浏览器不发生变化,标准化工作仅在“社区”级别就会有所帮助。 Signals 的工作将许多不同的框架作者聚集在一起,对反应性、算法和互操作性的本质进行深入讨论。这已经很有用了,但并不能证明包含在 JS 引擎和浏览器中是合理的;仅当除了启用生态系统信息交换之外还有其他显着优势时,才应将信号添加到 JavaScript 标准中。
事实证明,现有的信号库在其核心方面彼此并没有太大不同。该提案旨在通过实现其中许多图书馆的重要品质来巩固他们的成功。
表示状态的信号类型,即可写信号。这是其他人可以读取的值。
计算/备忘录/派生信号类型,依赖于其他信号类型,并且延迟计算和缓存。
计算是惰性的,这意味着当其依赖项之一发生更改时,默认情况下不会再次计算计算信号,而是仅在有人实际读取它们时才运行。
计算是“无故障”的,这意味着不会执行不必要的计算。这意味着,当应用程序读取计算信号时,会对要运行的图形的潜在脏部分进行拓扑排序,以消除任何重复项。
计算被缓存,这意味着如果在上次依赖项更改后,没有依赖项发生更改,则在访问时不会重新计算计算出的信号。
可以对计算信号和状态信号进行自定义比较,以注意何时应更新依赖于它们的进一步计算信号。
对计算信号具有其依赖项之一(或嵌套依赖项)的条件的反应会变得“脏”并发生变化,这意味着信号的值可能已过时。
此反应旨在安排稍后执行更重要的工作。
效果是根据这些反应以及框架级调度来实现的。
计算信号需要能够对它们是否被注册为这些反应之一的(嵌套)依赖项做出反应。
使 JS 框架能够进行自己的调度。无 Promise 风格的内置强制调度。
需要同步反应才能根据框架逻辑安排后续工作。
写入是同步的并且立即生效(批量写入的框架可以在上面做到这一点)。
可以将检查效果是否可能“脏”与实际运行效果分开(启用两阶段效果调度程序)。
能够读取信号而不触发要记录的依赖关系( untrack
)
启用使用信号/反应性的不同代码库的组合,例如,
就跟踪/反应性本身而言,一起使用多个框架(模数省略,见下文)
独立于框架的反应式数据结构(例如,递归反应式存储代理、反应式 Map 和 Set 和 Array 等)
阻止/禁止天真地滥用同步反应。
健全性风险:如果使用不当,可能会暴露“故障”:如果在设置信号后立即完成渲染,则可能会向最终用户暴露不完整的应用程序状态。因此,此功能只能用于在应用程序逻辑完成后智能地安排稍后的工作。
解决方案:禁止从同步反应回调中读取和写入任何信号
阻止untrack
并标记其不健全的本质
健全性风险:允许创建计算信号,其值取决于其他信号,但当这些信号发生变化时不会更新。当未跟踪的访问不会改变计算结果时应该使用它。
解决方案:该API在名称中被标记为“不安全”。
注意:该提案确实允许从计算信号和效果信号中读取和写入信号,而不限制读取后的写入,尽管存在健全性风险。做出这一决定是为了保持与框架集成的灵活性和兼容性。
必须是多个框架实现其信号/反应机制的坚实基础。
应该是递归存储代理、基于装饰器的类字段反应性以及.value
和[state, setState]
样式 API 的良好基础。
语义能够表达不同框架所支持的有效模式。例如,这些信号应该可以成为立即反映的写入或稍后批处理和应用的写入的基础。
如果 JavaScript 开发人员可以直接使用此 API,那就太好了。
想法:提供所有的钩子,但如果可能的话,包括误用时的错误。
想法:将微妙的 API 放在一个subtle
命名空间中,类似于crypto.subtle
,以标记 API 之间的界限,这些 API 是更高级的用法(例如实现框架或构建开发工具)与更日常的应用程序开发用法(例如实例化信号以与框架。
然而,重要的是不要从字面上掩盖完全相同的名字!
如果某个功能与生态系统概念相匹配,那么使用通用词汇会很好。
“JS 开发人员的可用性”和“提供框架的所有挂钩”之间的紧张关系
可实现、可用并具有良好的性能——表面 API 不会造成太多开销
启用子类化,以便框架可以添加自己的方法和字段,包括私有字段。这对于避免在框架层面进行额外分配非常重要。请参阅下面的“内存管理”。
如果可能:如果没有任何活动引用它以进行未来可能的读取,则计算的信号应该是垃圾收集的,即使它链接到保持活动状态的更广泛的图(例如,通过读取保持活动状态)。
请注意,当今的大多数框架都需要显式处理计算信号,如果它们有任何对或来自另一个仍然存在的信号图的引用。
当它们的生命周期与 UI 组件的生命周期相关联时,结果并没有那么糟糕,并且无论如何都需要处理效果。
如果使用这些语义执行成本太高,那么我们应该将计算信号的显式处理(或“取消链接”)添加到下面的 API,而该 API 目前还缺少它。
一个单独的相关目标:最小化分配数量,例如,
制作一个可写信号(避免两个单独的闭包+数组)
实现效果(避免每个反应都关闭)
在用于观察 Signal 变化的 API 中,避免创建额外的临时数据结构
解决方案:基于类的 API 可以重用子类中定义的方法和字段
Signal API 的初步想法如下。请注意,这只是一个早期草案,我们预计会随着时间的推移而发生变化。让我们从完整的.d.ts
开始,了解整体形状,然后我们将讨论其含义的细节。
interface Signal<T> {// 获取信号的值 get(): T;}namespace Signal {// 一个读写信号类 State<T> 实现 Signal<T> {// 创建一个以值开头的状态 Signal tconstructor(t: T, options?: SignalOptions<T>);// 获取信号的值 get(): T;// 设置状态 Signal 值为 tset(t: T): void;}// 一个 Signal这是一个公式基于其他 Signalsclass Computed<T =known> 实现 Signal<T> {// 创建一个计算结果为回调返回的值的信号。// 使用此信号作为 this value 来调用回调。constructor(cb: (this : Computed<T>) => T, options?: SignalOptions<T>);// 获取信号的值get(): T;}// 该命名空间包含“高级”功能,最好保留给 //框架作者而不是// 类似于 `crypto.subtle` 命名空间微妙 {// 运行一个回调,禁用所有跟踪功能 untrack<T>(cb: () => T): T;// 获取当前计算的信号跟踪任何信号读取(如果有的话) null;// 返回此信号在上次评估期间所引用的所有信号的有序列表。// 对于观察者,列出其正在观察的信号集。function introspectSources(s: Computed | Watcher): (State | Computed)[];// 返回包含此信号的观察者,以及上次评估时读取此信号的任何计算信号,// 如果该计算信号被(递归地)监视。 function introspectSinks(s: State | Computed): (Compulated | Watcher)[];// 如果此信号是“实时”的,则为 True,因为它由 Watcher 监视,// 或者由计算信号读取,该信号是 (递归地) live.function hasSinks(s: State | Computed): boolean;// 如果此元素是“反应性”的,则为 True,因为它依赖于// 某些其他信号。 A Computed where hasSources is false // 将始终返回相同的常量。function hasSources(s: Computed | Watcher): boolean;class Watcher {// 当写入 Watcher 的(递归)源时,调用此回调,//如果自上次`watch`调用以来尚未调用它。 // 在notify.constructor(notify: (this: Watcher) => void)期间不能读取或写入信号。 // 将这些信号添加到观察者的集合,并设置观察者在下次集合中的任何信号(或其依赖项之一)更改时运行其 // 通知回调。 // 可以不带任何参数调用,只是为了重置“已通知”状态,以便// 将再次调用通知回调。watch(...s: Signal[]): void;// 从监视集中删除这些信号(例如,对于已处理的效果)unwatch(...s: Signal[]): void;// 返回源集在观察者的集合中仍然是脏的,或者是一个计算信号//其源是脏的或挂起的并且尚未重新评估 getPending(): Signal[];}// 用于观察是否被监视的钩子long Watchvar Watched: Symbol;var Unwatched: Symbol;}interface SignalOptions<T> {// 新旧值之间的自定义比较函数。 Default: Object.is.// 信号作为 this 值传入 context.equals?: (this: Signal<T>, t: T, t2: T) => boolean;// isWatched 变为时调用的回调true,如果之前为 false[Signal.subtle.watched]?: (this: Signal<T>) => void;// 每当 isWatched 变为 false(如果之前为 false)时调用回调true[Signal.subtle.unwatched]?: (this: Signal<T>) => void;}}
信号代表可能随时间变化的数据单元。信号可以是“状态”(只是手动设置的值)或“计算的”(基于其他信号的公式)。
计算信号的工作原理是自动跟踪评估期间读取的其他信号。当读取计算值时,它会检查其先前记录的任何依赖项是否已更改,如果已更改,则重新评估自身。当多个计算信号嵌套时,跟踪的所有属性都会转到最里面的一个。
计算信号是惰性的,即基于拉的:它们仅在被访问时才重新评估,即使它们的依赖项之一较早发生了更改。
传递到计算信号中的回调通常应该是“纯粹的”,因为它是它访问的其他信号的确定性、无副作用的函数。同时,调用回调的时间是确定的,允许谨慎使用副作用。
信号具有突出的缓存/记忆功能:状态信号和计算信号都会记住它们的当前值,并且只有在它们实际发生变化时才触发引用它们的计算信号的重新计算。甚至不需要重复比较旧值与新值——当源信号重置/重新评估时进行一次比较,信号机制会跟踪引用该信号的哪些内容尚未根据新值进行更新值还没有。在内部,这通常通过“图形着色”来表示,如(Milo 的博客文章)中所述。
计算信号动态跟踪它们的依赖关系——每次运行它们时,它们最终可能依赖于不同的事物,并且精确的依赖关系集在信号图中保持新鲜。这意味着,如果您仅需要一个分支的依赖关系,并且先前的计算采用了另一个分支,那么对临时未使用的值的更改将不会导致计算出的 Signal 被重新计算,即使在拉取时也是如此。
与 JavaScript Promises 不同,Signals 中的所有内容都是同步运行的:
将信号设置为新值是同步的,并且在随后读取依赖于该信号的任何计算信号时,这一点会立即反映出来。此突变没有内置批处理。
读取计算信号是同步的——它们的值始终可用。
如下所述,Watchers 中的notify
回调在触发它的.set()
调用期间同步运行(但在图形着色完成之后)。
与 Promises 一样,信号可以表示错误状态:如果计算信号的回调抛出异常,则该错误将像另一个值一样被缓存,并在每次读取信号时重新抛出。
Signal
实例表示读取动态变化值的能力,该值的更新会随着时间的推移而被跟踪。它还隐式地包括通过来自另一个计算信号的跟踪访问隐式地订阅信号的功能。
这里的 API 旨在匹配大部分 Signal 库在使用“信号”、“计算”和“状态”等名称时达成的非常粗略的生态系统共识。但是,对计算信号和状态信号的访问是通过.get()
方法进行的,这与所有流行的信号 API 不同,这些 API 要么使用.value
样式的访问器,要么使用signal()
调用语法。
该API旨在减少分配数量,使信号适合嵌入JavaScript框架中,同时达到与现有框架定制信号相同或更好的性能。这意味着:
状态信号是单个可写对象,可以从同一引用访问和设置它。 (请参阅下面“能力分离”部分中的含义。)
状态和计算信号都被设计为可子类化,以促进框架通过公共和私有类字段(以及使用该状态的方法)添加附加属性的能力。
使用相关信号作为上下文的this
值来调用各种回调(例如, equals
,计算的回调),因此不需要每个信号一个新的闭包。相反,上下文可以保存在信号本身的额外属性中。
此 API 强制执行的一些错误条件:
递归读取计算结果是错误的。
Watcher 的notify
回调无法读取或写入任何信号
如果计算信号的回调抛出,则对该信号的后续访问将重新抛出缓存的错误,直到依赖项之一发生更改并重新计算。
一些不强制执行的条件:
计算信号可以在其回调中同步写入其他信号
由观察者的notify
回调排队的工作可以读取或写入信号,从而可以根据信号复制经典的 React 反模式!
上面定义的Watcher
接口为实现典型 JS API 的效果提供了基础:当其他信号发生变化时,回调会重新运行,纯粹是为了它们的副作用。上面在初始示例中使用的effect
函数可以定义如下:
// 这个函数通常存在于库/框架中,而不是应用程序代码中 // 注意:这个调度逻辑太基础了,没有什么用处。不要复制/粘贴。letending = false;let w = new Signal.subtle.Watcher(() => {if (!pending) {pending = true;queueMicrotask(() => {pending = false;for (let s of w.getPending()) s.get();w.watch();});}});// 一个效果效果信号,其计算结果为 cb,它在微任务队列上调度 // 读取自身每当其依赖项之一可能更改时导出函数effect(cb) {let destructor;let c = new Signal.Compulated(() => { destructor?.(); destructor = cb(); });w.watch(c) ;c.get();return () => { 析构函数?.(); w.unwatch(c) };}
Signal API 不包含任何内置函数,例如effect
。这是因为效果调度很微妙,并且通常与框架渲染周期和 JS 无法访问的其他高级框架特定状态或策略相关。
浏览此处使用的不同操作:传递给Watcher
构造函数的notify
回调是当 Signal 从“干净”状态(我们知道缓存已初始化且有效)进入“已检查”或“脏”状态时调用的函数。 “状态(其中缓存可能有效也可能无效,因为该递归依赖的至少一个状态已更改)。
对notify
的调用最终是通过在某些状态信号上调用.set()
来触发的。此调用是同步的:它发生在.set
返回之前。但无需担心此回调会观察处于半处理状态的信号图,因为在notify
回调期间,即使在untrack
调用中,也无法读取或写入信号。因为在.set()
期间调用了notify
,所以它正在中断另一个可能不完整的逻辑线程。要从notify
读取或写入信号,请安排工作稍后运行,例如,通过将信号写入列表中以供稍后访问,或使用上面的queueMicrotask
。
请注意,完全可以通过调度计算信号的轮询来在没有Symbol.subtle.Watcher
的情况下有效地使用信号,就像 Glimmer 所做的那样。然而,许多框架发现同步运行此调度逻辑通常很有用,因此 Signals API 包含了它。
计算信号和状态信号都像任何 JS 值一样被垃圾收集。但观察者有一种特殊的方式来保持事物的活动状态:只要任何底层状态可达,观察者观察到的任何信号都将保持活动状态,因为这些信号可能会触发未来的notify
调用(然后是未来的.get()
)。因此,请记得调用Watcher.prototype.unwatch
来清理效果。
Signal.subtle.untrack
是一个逃生舱口,允许读取信号而不跟踪这些读取。此功能是不安全的,因为它允许创建计算信号,其值取决于其他信号,但当这些信号更改时不会更新。当未跟踪的访问不会改变计算结果时应该使用它。
这些功能可能会在以后添加,但不包含在当前草案中。它们的省略是由于框架之间的设计空间缺乏既定的共识,以及通过本文档中描述的信号概念之上的机制来解决它们的缺失的能力。然而,不幸的是,这一遗漏限制了框架之间互操作性的潜力。随着本文档中描述的信号原型的产生,我们将努力重新检查这些省略是否是适当的决定。
异步:在此模型中,信号始终同步可用于评估。然而,拥有某些导致信号被设置的异步过程以及了解信号何时仍在“加载”通常很有用。对加载状态进行建模的一种简单方法是使用异常,并且计算信号的异常缓存行为在某种程度上合理地与该技术结合在一起。改进的技术将在第 30 期中讨论。
事务:对于视图之间的转换,维护“from”和“to”状态的实时状态通常很有用。 “to”状态在后台呈现,直到准备好交换(提交事务),而“from”状态保持交互。同时维护这两种状态需要“分叉”信号图的状态,甚至一次支持多个挂起的转换可能很有用。第 73 期的讨论。
一些可能的便捷方法也被省略。
该提案已列入 2024 年 4 月 TC39 第 1 阶段议程。目前可以将其视为“第 0 阶段”。
该提案的 polyfill 已经可用,并进行了一些基本测试。一些框架作者已经开始尝试替代这种信号实现,但这种使用还处于早期阶段。
Signal 提案的合作者希望在推动该提案的方式上特别保守,这样我们就不会陷入交付某些东西而最终后悔并没有实际使用的陷阱。我们的计划是执行 TC39 流程不需要的以下额外任务,以确保该提案步入正轨:
在提出第二阶段建议之前,我们计划:
开发多个生产级的polyfill实现,这些实现是可靠的、经过充分测试的(例如,通过各种框架的测试以及test262类型的测试),并且在性能方面具有竞争力(通过全面的信号/框架基准集进行验证)。
将提出的Signal API集成到大量我们认为有些代表性的JS框架中,并且一些大型应用程序在此基础上工作。测试它在这些环境中是否有效且正确地工作。
对 API 的可能扩展空间有深入的了解,并得出应将哪些扩展(如果有)添加到此提案中的结论。
本节根据它们实现的算法描述了暴露给 JavaScript 的每个 API。这可以被认为是一个原始规范,并且在早期就被包含进来,以确定一组可能的语义,同时对更改非常开放。
该算法的一些方面:
计算中信号的读取顺序很重要,并且可以按照某些回调(调用哪个Watcher
、 equals
、 new Signal.Computed
的第一个参数以及watched
/ unwatched
回调)的执行顺序来观察。这意味着计算信号的源必须按顺序存储。
这四个回调可能都会抛出异常,并且这些异常会以可预测的方式传播到调用的 JS 代码。异常不会停止该算法的执行或使图处于半处理状态。对于 Watcher 的notify
回调中抛出的错误,该异常将被发送到触发它的.set()
调用,如果抛出多个异常,则使用 AggregateError 。其他的(包括watched
/ unwatched
?)存储在信号的值中,在读取时重新抛出,并且这样的重新抛出信号可以像任何其他具有正常值的信号一样被标记为~clean~
。
在计算信号未被“观察”(被任何观察者观察)的情况下要小心避免循环,以便它们可以独立于信号图的其他部分进行垃圾收集。在内部,这可以通过始终收集的代号系统来实现;请注意,优化的实现还可能包括本地每个节点的生成编号,或者避免跟踪监视信号上的某些编号。
信号算法需要参考某些全局状态。此状态对于整个线程或“代理”来说是全局的。
computing
:当前由于.get
或.run
调用而重新评估的最里面的计算或效果信号,或null
。最初为null
。
frozen
:布尔值,表示当前是否有回调正在执行,该回调要求图表不被修改。最初是false
。
generation
:从 0 开始递增的整数,用于跟踪值的最新情况,同时避免循环。
Signal
命名空间Signal
是一个普通对象,用作与 Signal 相关的类和函数的命名空间。
Signal.subtle
是一个类似的内部命名空间对象。
Signal.State
类Signal.State
内部插槽value
: 状态信号的当前值
equals
:更改值时使用的比较函数
watched
:当信号被效果观察到时调用的回调
unwatched
:当信号不再被效果观察到时调用的回调
sinks
:一组依赖于此的监视信号
Signal.State(initialValue, options)
将此信号的value
设置为initialValue
。
将此信号equals
为 options?.equals
将此信号的watched
设置为选项?.[Signal.subtle.watched]
将此信号的unwatched
设置为选项?。[Signal.subtle.unwatched]
将此信号的sinks
设置为空集
Signal.State.prototype.get()
如果frozen
为 true,则抛出异常。
如果computing
不是undefined
,则将此信号添加到computing
的sources
集中。
注意:在被观察者观察之前,我们不会向该信号的sinks
集添加computing
。
返回此信号的value
。
Signal.State.prototype.set(newValue)
如果当前执行上下文被frozen
,则抛出异常。
使用此信号和该值的第一个参数运行“设置信号值”算法。
如果该算法返回~clean~
,则返回 undefined。
将此信号的所有sinks
的state
设置为(如果它是计算信号) ~dirty~
(如果它们之前是干净的),或者(如果它是观察者) ~pending~
(如果它之前是~watching~
。
将所有接收器的计算信号依赖项的state
(递归地)设置为~checked~
如果它们之前是~clean~
)(即,将脏标记保留在适当的位置),或者对于观察者, ~pending~
如果以前~watching~
。
对于在递归搜索中遇到的每个先前~watching~
观察者,然后按深度优先顺序,
将frozen
设置为 true。
调用它们的notify
回调(保留抛出的任何异常,但忽略notify
的返回值)。
恢复frozen
为假。
将 Watcher 的state
设置为~waiting~
。
如果notify
回调抛出任何异常,请在所有notify
回调运行后将其传播给调用者。如果有多个异常,则将它们一起打包成 AggregateError 并抛出。
返回未定义。
Signal.Computed
类Signal.Computed
状态机计算信号的state
可以是以下之一:
~clean~
:信号的值存在并且已知不会陈旧。
~checked~
:该信号的(间接)源已更改;该信号具有值,但可能已过时。只有在评估了所有直接来源后才能知道它是否过时。
~computing~
:此信号的回调当前正在作为.get()
调用的副作用执行。
~dirty~
:该信号要么具有已知已过时的值,要么从未被评估过。
转换图如下:
状态图-v2
[*] --> 脏
脏 --> 计算:[4]
计算 --> 干净:[5]
干净 --> 脏: [2]
干净 --> 检查:[3]
检查-->干净:[6]
检查-->脏:[1]
加载中过渡是:
数字 | 从 | 到 | 健康)状况 | 算法 |
---|---|---|---|---|
1 | ~checked~ | ~dirty~ | 该信号的直接源(即计算信号)已被评估,并且其值已更改。 | 算法:重新计算脏计算信号 |
2 | ~clean~ | ~dirty~ | 该信号的直接源(即状态)已设置,其值不等于其先前值。 | 方法: Signal.State.prototype.set(newValue) |
3 | ~clean~ | ~checked~ | 该信号的递归但非直接源(即状态)已设置,其值不等于其先前值。 | 方法: Signal.State.prototype.set(newValue) |
4 | ~dirty~ | ~computing~ | 我们即将执行callback 。 | 算法:重新计算脏计算信号 |
5 | ~computing~ | ~clean~ | callback 已完成评估并返回一个值或引发异常。 | 算法:重新计算脏计算信号 |
6 | ~checked~ | ~clean~ | 该信号的所有直接来源都已被评估,并且发现所有来源都没有变化,因此我们现在知道它还没有过时。 | 算法:重新计算脏计算信号 |
Signal.Computed
内部槽value
:信号的先前缓存值,或从未读取的计算信号的~uninitialized~
。该值可能是一个异常,在读取该值时会重新抛出该异常。效果信号始终undefined
。
state
:可能是~clean~
、 ~checked~
、 ~computing~
或~dirty~
。
sources
:该信号所依赖的一组有序信号。
sinks
:依赖于该信号的一组有序信号。
equals
:选项中提供的 equals 方法。
callback
:被调用以获取计算信号值的回调。设置为传递给构造函数的第一个参数。
Signal.Computed
构造函数构造函数集
callback
到它的第一个参数
equals
基于选项,如果不存在则默认为Object.is
state
为~dirty~
value
~uninitialized~
使用 AsyncContext,传递给new Signal.Computed
的回调会关闭调用构造函数时的快照,并在执行期间恢复此快照。
Signal.Computed.prototype.get
如果当前执行上下文被frozen
或者此 Signal 的状态为~computing~
,或者如果此信号是 Effect 并computing
计算的 Signal,则抛出异常。
如果computing
不为null
,则将此信号添加到computing
的sources
集中。
注意:我们不会向该信号的sinks
集添加computing
,直到/除非它被观察者监视。
如果此信号的状态为~dirty~
或~checked~
:重复以下步骤,直到此 Signal 为~clean~
:
通过sources
向上递归以找到最深、最左边(即最早观察到的)递归源,这是一个标记为~dirty~
计算信号(当遇到~clean~
计算信号时切断搜索,并将该计算信号作为最后一件事)来搜索)。
对该信号执行“重新计算脏计算信号”算法。
此时,该信号的状态将是~clean~
,并且没有递归源将是~dirty~
或~checked~
。返回信号的value
。如果该值是异常,则重新抛出该异常。
Signal.subtle.Watcher
类Signal.subtle.Watcher
状态机Watcher 的state
可能是以下之一:
~waiting~
: notify
回调已运行,或者观察者是新的,但没有主动观察任何信号。
~watching~
:观察者正在积极观察信号,但尚未发生需要notify
回调的更改。
~pending~
:Watcher 的依赖项已更改,但notify
回调尚未运行。
转换图如下:
状态图-v2
[*] --> 等待
等待 --> 观看:[1]
观看 --> 等待:[2]
正在观看 --> 待处理:[3]
待处理 --> 等待: [4]
加载中过渡是:
数字 | 从 | 到 | 健康)状况 | 算法 |
---|---|---|---|---|
1 | ~waiting~ | ~watching~ | Watcher 的watch 方法已被调用。 | 方法: Signal.subtle.Watcher.prototype.watch(...signals) |
2 | ~watching~ | ~waiting~ | Watcher 的unwatch 方法已被调用,并且最后一个监视的信号已被删除。 | 方法: Signal.subtle.Watcher.prototype.unwatch(...signals) |
3 | ~watching~ | ~pending~ | 观察到的信号可能已经改变了值。 | 方法: Signal.State.prototype.set(newValue) |
4 | ~pending~ | ~waiting~ | notify 回调已运行。 | 方法: Signal.State.prototype.set(newValue) |
Signal.subtle.Watcher
内部插槽state
:可能是~watching~
、 ~pending~
或~waiting~
signals
:该观察者正在观察的一组有序信号
notifyCallback
:当事情发生变化时调用的回调。设置为传递给构造函数的第一个参数。
new Signal.subtle.Watcher(callback)
state
设置为~waiting~
。
将signals
初始化为空集。
notifyCallback
设置为回调参数。
使用 AsyncContext,传递给new Signal.subtle.Watcher
回调不会关闭调用构造函数时的快照,因此写入周围的上下文信息是可见的。
Signal.subtle.Watcher.prototype.watch(...signals)
如果frozen
为 true,则抛出异常。
如果任何参数不是信号,则抛出异常。
将所有参数附加到该对象的signals
末尾。
对于每个新观看的信号,按从左到右的顺序,
将此观察者添加为该信号的sink
。
如果这是第一个接收器,则递归到源以将该信号添加为接收器。
将frozen
设置为 true。
调用watched
回调(如果存在)。
恢复frozen
为假。
如果 Signal 的state
为~waiting~
,则将其设置为~watching~
。
Signal.subtle.Watcher.prototype.unwatch(...signals)
如果frozen
为 true,则抛出异常。
如果任何参数不是信号,或者没有被该观察者观察,则抛出异常。
对于参数中的每个信号,按从左到右的顺序,
从该观察者的signals
集中删除该信号。
从该信号的sink
集中删除该观察者。
如果该信号的sink
集已变空,则将该信号作为接收器从其每个源中删除。
将frozen
设置为 true。
调用unwatched
回调(如果存在)。
恢复frozen
为假。
如果观察者现在没有signals
,并且其state
为~watching~
,则将其设置为~waiting~
。
Signal.subtle.Watcher.prototype.getPending()
返回一个包含signals
子集的数组,这些信号是处于状态~dirty~
或~pending~
的计算信号。
Signal.subtle.untrack(cb)
令c
为执行上下文的当前computing
状态。
将computing
设置为空。
打电话给cb
。
将computing
恢复到c
(即使cb
抛出异常)。
返回cb
的返回值(重新抛出任何异常)。
注意:untrack 并不能让你摆脱frozen
状态,冻结状态是严格维护的。
Signal.subtle.currentComputed()
返回当前computing
值。
清除此信号的sources
集,并将其从这些源的sinks
集中删除。
保存之前的computing
值并将computing
设置到该Signal中。
将此信号的状态设置为~computing~
。
运行此计算信号的回调,使用此信号作为 this 值。保存返回值,如果回调抛出异常,则存储该异常以供重新抛出。
恢复之前的computing
值。
将“设置信号值”算法应用于回调的返回值。
将此信号的状态设置为~clean~
。
如果该算法返回~dirty~
:将此信号的所有接收器标记为~dirty~
(以前,接收器可能是checked和dirty的混合)。 (或者,如果这是无人关注的,则采用新一代编号来表示肮脏,或类似的东西。)
否则,该算法返回~clean~
:在这种情况下,对于此信号的每个~checked~
接收器,如果该信号的所有源现在都是干净的,则将该信号也标记为~clean~
。将此清理步骤递归地应用到进一步的接收器,以及已检查接收器的任何新清理的信号。 (或者,如果没有人监视,请以某种方式指示相同的内容,以便清理可以延迟进行。)
如果此算法传递了一个值(与重新抛出异常相反,来自重新计算脏计算信号算法):
调用此 Signal 的equals
函数,将当前value
、新值和此 Signal 作为参数传递。如果抛出异常,则将该异常(以便在读取时重新抛出)保存为 Signal 的值并继续,就像回调返回 false 一样。
如果该函数返回 true,则返回~clean~
。
将此信号的value
设置为参数。
回来~dirty~
问:信号相关的东西在 2022 年刚刚开始成为热门新事物,现在对其进行标准化是不是有点太早了?难道我们不应该给他们更多的时间来进化和稳定吗?
答:Web 框架中 Signals 的现状是 10 多年持续发展的结果。随着近年来投资的增加,几乎所有的 Web 框架都在接近非常相似的 Signals 核心模型。该提案是 Web 框架领域当前众多领导者之间共同设计的结果,如果没有该领域专家组在各种情况下的验证,它不会被推进标准化。
问:考虑到内置信号与渲染和所有权的紧密集成,框架是否可以使用内置信号?
答:更特定于框架的部分往往是在效果、调度和所有权/处置方面,本提案不试图解决这些问题。我们对标准轨道信号进行原型设计的首要任务是验证它们是否可以兼容地位于现有框架“之下”并具有良好的性能。
问:Signal API 是供应用程序开发人员直接使用,还是由框架封装?
答:虽然这个 API 可以由应用程序开发人员直接使用(至少是不在Signal.subtle
命名空间内的部分),但它的设计并不是特别符合人体工程学。相反,库/框架作者的需求是优先考虑的。大多数框架甚至希望用表达其人体工程学倾向的东西来包装基本的Signal.State
和Signal.Computed
API。在实践中,通常最好通过框架使用信号,该框架管理更棘手的功能(例如,Watcher、 untrack
),以及管理所有权和处置(例如,确定何时应向观察者添加信号或从观察者中删除信号),以及调度 DOM 渲染——这个提案并不试图解决这些问题。
问:当某个小部件被销毁时,我是否必须拆除与该小部件相关的信号?其 API 是什么?
A :这里相关的拆解操作是Signal.subtle.Watcher.prototype.unwatch
。只有观看的信号需要清理(通过取消观看它们),而未观看的信号可以自动进行垃圾收集。
问:信号可以与 VDOM 一起使用,还是直接与底层 HTML DOM 一起使用?
答: 是的!信号独立于渲染技术。使用类似信号构造的现有 JavaScript 框架与 VDOM(例如 Preact)、本机 DOM(例如 Solid)和组合(例如 Vue)集成。内置信号也可以实现同样的效果。
问:在 Angular 和 Lit 等基于类的框架中使用 Signals 是否符合人体工程学?像 Svelte 这样基于编译器的框架怎么样?
答:可以使用简单的访问器装饰器将类字段设为基于信号,如 Signal polyfill 自述文件中所示。信号与 Svelte 5 的 Runes 非常接近——编译器可以很简单地将 runes 转换为此处定义的 Signal API,事实上这就是 Svelte 5 内部所做的事情(但使用它自己的 Signals 库)。
问:Signals 可以与 SSR 一起使用吗?保湿?可恢复性?
答:是的。 Qwik 使用信号在这两个属性上取得了良好的效果,而其他框架也有其他成熟的方法来与具有不同权衡的信号进行水合作用。我们认为可以使用连接在一起的状态和计算信号来对 Qwik 的可恢复信号进行建模,并计划在代码中证明这一点。
问:Signal 是否可以像 React 一样处理单向数据流?
答:是的,信号是一种单向数据流的机制。基于信号的 UI 框架可让您将视图表达为模型的函数(其中模型包含信号)。状态图和计算信号在构造上是非循环的。也可以在 Signals 中重新创建 React 反模式(!),例如, useEffect
中setState
的 Signal 等效项是使用 Watcher 来安排对 State 信号的写入。
问:信号与 Redux 等状态管理系统有何关系?信号会鼓励非结构化状态吗?
答:信号可以为类似存储的状态管理抽象形成有效的基础。在多个框架中发现的常见模式是基于代理的对象,该对象在内部使用信号表示属性,例如 Vue reactive()
或 Solid 存储。这些系统可以为特定应用程序在正确的抽象级别上灵活地进行状态分组。
问:哪些信号提供了Proxy
目前无法处理的功能?
答:代理和信号是互补的并且可以很好地结合在一起。代理可让您拦截浅层对象操作并协调(单元格的)依赖关系图。用信号支持代理是创建具有良好人体工程学的嵌套反应结构的好方法。
在这个例子中,我们可以使用代理来使信号具有 getter 和 setter 属性,而不是使用get
和set
方法:
const a = new Signal.State(0);const b = new Proxy(a, { get(目标, 属性, 接收者) {if (属性 === '值') { return target.get():} } set(目标, 属性, 值, 接收者) {if (属性 === '值') { target.set(值)!} }});// 在假设的反应式上下文中的用法:<template> {b.值} <按钮 onclick={() => {b.value++; }}>更改</按钮></模板>
当使用针对细粒度反应性进行优化的渲染器时,单击该按钮将导致b.value
单元格更新。
看:
使用信号和代理创建的嵌套反应结构的示例:signal-utils
显示反应性数据 atd 代理之间关系的先前实现示例:tracked-built-ins
讨论。
问:信号是基于推的还是基于拉的?
答:计算信号的评估是基于拉动的:计算信号仅在调用.get()
时评估,即使基础状态更改得更早。同时,改变一个State信号可能会立即触发一个Watcher的回调,“推送”通知。因此信号可以被认为是一种“推拉”结构。
问:信号是否会在 JavaScript 执行中引入不确定性?
答:不会。首先,所有 Signal 操作都具有明确定义的语义和顺序,并且在一致的实现之间不会有所不同。在更高的层次上,信号遵循一组特定的不变量,相对于这些不变量它们是“健全的”。计算信号始终以一致的状态观察信号图,并且其执行不会被其他信号变异代码中断(除了它调用自身的代码)。请参阅上面的描述。
问:当我写入状态信号时,何时计划更新计算信号?
答: 没有预定!计算出的信号将在下次有人读取它时重新计算自身。同步地,可以调用观察者的notify
回调,使框架能够在他们认为合适的时间安排读取。
问:写入状态信号什么时候生效?立即,还是分批进行?
答:对状态信号的写入会立即反映出来——下次读取依赖于状态信号的计算信号时,如果需要,它将重新计算自身,即使是在紧随其后的代码行中。然而,这种机制固有的惰性(计算信号仅在读取时才计算)意味着,在实践中,计算可能以批量方式发生。
问:信号启用“无故障”执行意味着什么?
答:早期基于推送的反应性模型面临冗余计算的问题:如果对状态 Signal 的更新导致计算出的 Signal 急切地运行,最终可能会将更新推送到 UI。但是,如果在下一帧之前原始状态 Signal 会发生另一次更改,则对 UI 的写入可能为时过早。有时,由于此类故障,甚至会向最终用户显示不准确的中间值。信号通过基于拉取而不是基于推来避免这种动态:当框架安排 UI 的渲染时,它将拉取适当的更新,从而避免计算以及写入 DOM 中的浪费工作。
问:信号“有损”是什么意思?
答:这是无故障执行的另一面:信号代表一个数据单元——只是当前的当前值(可能会改变),而不是一段时间内的数据流。因此,如果您连续两次写入状态信号,而不执行任何其他操作,则第一次写入将“丢失”,并且任何计算的信号或效果都不会看到。这被理解为一个特性而不是一个错误——其他结构(例如,异步迭代、可观察)更适合流。
问:原生 Signals 会比现有的 JS Signal 实现更快吗?
答:我们希望如此(通过一个小的常数因子),但这仍有待在代码中证明。 JS 引擎并不神奇,最终需要实现与信号的 JS 实现相同类型的算法。请参阅上面有关性能的部分。
问:当信号的任何实际使用都需要效果时,为什么该提案不包含effect()
函数?
答:效果本质上与调度和处置相关,这些由框架管理,超出了本提案的范围。相反,该提案包括通过更底层的Signal.subtle.Watcher
API 实现效果的基础。
问:为什么订阅是自动的而不是提供手动界面?
答:经验表明,用于反应性的手动订阅界面不符合人体工程学且容易出错。自动跟踪更具可组合性,是 Signals 的核心功能。
问:为什么Watcher
的回调是同步运行的,而不是在微任务中调度?
A :由于回调不能读写Signal,所以同步调用不会带来不健全的情况。典型的回调会将信号添加到数组中以供稍后读取,或在某处标记一点。为所有这些类型的操作创建单独的微任务是不必要的,而且成本高昂,不切实际。
问:这个 API 缺少我最喜欢的框架提供的一些好东西,这使得使用信号进行编程变得更容易。这也可以添加到标准中吗?
答:也许吧。各种扩展仍在考虑中。请提出问题,以就您认为重要的任何缺失功能进行讨论。
问:这个 API 可以减小大小或复杂性吗?
答:保持 API 最小化绝对是我们的目标,我们已经尝试通过上面介绍的内容来实现这一目标。如果您有更多可以删除的内容的想法,请提出问题进行讨论。
问:我们是否应该从更原始的概念(例如可观察量)开始这一领域的标准化工作?
答:Observables 对于某些事情来说可能是个好主意,但它们并不能解决 Signals 旨在解决的问题。如上所述,可观察量或其他发布/订阅机制并不是许多类型的 UI 编程的完整解决方案,因为开发人员需要进行太多容易出错的配置工作,并且由于缺乏懒惰而浪费工作等问题。
问:既然大多数应用程序都是基于 Web 的,为什么在 TC39 中提出 Signals 而不是 DOM?
答:该提案的一些合著者对非 Web UI 环境作为目标很感兴趣,但如今,任何一个场所都可能适合该目标,因为 Web API 在 Web 之外更频繁地实现。最终,信号不需要依赖于任何 DOM API,因此任何一种方式都可以。如果有人有充分理由要求切换此群组,请在问题中告诉我们。目前,所有贡献者均已签署 TC39 知识产权协议,计划将其提交给 TC39。
问:我需要多长时间才能使用标准信号?
答:polyfill 已经可用,但最好不要依赖其稳定性,因为该 API 在审核过程中不断发展。在几个月或一年内,高质量、高性能的稳定聚酯填充材料应该可以使用,但这仍需经过委员会修订,尚未成为标准。按照 TC39 提案的典型轨迹,预计 Signals 至少需要至少 2-3 年的时间才能在所有浏览器上原生可用,回溯到几个版本,这样就不需要腻子填充了。
问:我们如何防止过早标准化错误类型的信号,就像您不喜欢的 {{JS/web 功能}}?
答:该提案的作者计划在 TC39 请求阶段推进之前,在原型设计和验证方面加倍努力。参见上文“现状和发展计划”。如果您发现此计划存在差距或需要改进的机会,请提出问题并进行解释。