Signals S1
中文标题:信号(Signals)
- 阶段: Stage 1
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案标准化了 JavaScript 中信号的公共核心,通过提供共享的响应式原语来侧重于框架间的互操作性。它定义了具有自动依赖跟踪、惰性求值和缓存功能的 State 和 Computed 信号类,以及一个用于效果和调度的低级 Watcher。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
🚦 JavaScript 信号标准提案 🚦
阶段 1(说明)
TC39 提案负责人:Daniel Ehrenberg, Yehuda Katz, Jatin Ramanathan, Shay Lewis, Kristen Hewell Garrett, Dominic Gannaway, Preston Sego, Milo M, Rob Eisenberg
原始作者:Rob Eisenberg 和 Daniel Ehrenberg
本文档描述了 JavaScript 中信号的一个早期共同方向,类似于在 ES2015 中由 TC39 标准化的 Promise 之前的 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 更适合框架在其上构建,通过公共信号图和自动跟踪机制提供互操作性。
此提案的计划是进行大量的早期原型设计,包括集成到多个框架中,然后再推进到阶段 1 之后。我们只对标准化信号感兴趣,如果它们适用于多个框架的实际使用,并且相对于框架提供的信号提供了实际的好处。我们希望大量的早期原型设计能给我们提供这些信息。详见下面“状态和发展计划”。
背景:为什么需要信号?
为了开发复杂的用户界面(UI),JavaScript 应用程序开发者需要以高效的方式存储、计算、失效、同步和推送状态到应用程序的视图层。UI 通常不仅仅涉及管理简单的值,还常常涉及渲染依赖于其他值的复杂树的计算状态,或者本身也是计算出来的状态。信号的目标是提供用于管理此类应用程序状态的基础设施,以便开发者可以专注于业务逻辑,而不是这些重复的细节。
类似信号的结构在非 UI 环境中也被独立发现是有用的,特别是在构建系统中,以避免不必要的重建。
信号在响应式编程中用于消除在应用程序中管理更新的需要。
一种基于状态变化进行更新的声明式编程模型。
来自 什么是响应式?。
示例 - 一个原生 JavaScript 计数器
给定一个变量 counter,您希望根据计数器是偶数还是奇数来渲染到 DOM。每当 counter 改变时,您希望使用最新的奇偶性更新 DOM。在原生 JavaScript 中,您可能会有这样的代码:
这里使用全局变量仅用于演示目的。正确的状态管理有许多解决方案,本提案中的示例旨在尽可能简洁。本提案不鼓励全局变量。
这有许多问题...
counter的设置是嘈杂且样板化的。counter状态与渲染系统紧密耦合。- 如果
counter改变但parity未改变(例如 counter 从 2 变为 4),则我们做了不必要的 parity 计算和不必要的渲染。 - 如果 UI 的另一部分只想在
counter更新时渲染怎么办? - 如果 UI 的另一部分仅依赖
isEven或parity怎么办?
即使在这样相对简单的场景中,也会很快出现一些问题。我们可以尝试通过为 counter 引入发布/订阅来解决这些问题。这将允许 counter 的其他消费者订阅以添加它们自己对状态变化的反应。
但是,我们仍然面临以下问题:
- 渲染函数只依赖
parity,但必须“知道”它实际上需要订阅counter。 - 不可能仅基于
isEven或parity更新 UI,而不直接与counter交互。 - 我们增加了样板代码。任何时候使用某些东西,它不仅仅是调用函数或读取变量,而是在那里订阅和进行更新。管理退订也特别复杂。
现在,我们可以通过不仅对 counter 而且对 isEven 和 parity 添加发布/订阅来解决几个问题。然后我们必须将 isEven 订阅到 counter,parity 到 isEven,以及 render 到 parity。不幸的是,不仅我们的样板代码爆炸了,而且我们被困在大量的订阅簿记中,如果我们没有以正确的方式正确清理所有内容,可能会导致潜在的内存泄漏灾难。所以,我们解决了一些问题,但创造了一个全新的问题类别和大量代码。更糟的是,我们必须为系统中的每一条状态经历这一整个流程。
引入信号
UI 中模型和视图的数据绑定抽象长期以来一直是跨多种编程语言的 UI 框架的核心,尽管在 JS 或 web 平台中没有内置任何此类机制。在 JS 框架和库中,已经进行了大量关于表示此绑定不同方式的实验,经验表明单向数据流结合表示状态单元或从其他数据派生的计算的一类数据类型的强大功能,现在通常称为“信号”。 这种一类响应式值方法似乎在开源 JavaScript web 框架中首次出现是在 2010 年与 Knockout 在 2010 年。在随后的几年里,已经创建了许多变体和实现。在过去的 3-4 年中,信号原语和相关方法获得了进一步的吸引力,几乎每个现代 JavaScript 库或框架都有类似的,以某种名称。
为了理解信号,让我们看看上面的例子,用下面进一步阐述的信号 API 重新构想。
示例 - 信号计数器
我们可以立即看到几件事:
- 我们消除了前面示例中围绕
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 开发者之间的通用词汇。
HTML/DOM 集成(未来的可能性)
当前在 W3C 和浏览器实现者中的工作正在寻求将原生模板带到 HTML(DOM Parts 和 Template Instantiation)。此外,W3C Web Components CG 正在探索扩展 Web Components 以提供完全声明式 HTML API 的可能性。为了实现这两个目标,最终 HTML 将需要一个响应式原语。此外,通过信号集成对 DOM 进行许多人体工程学改进是可以想象的,并且社区也已要求这些改进。
注意,这种集成将是稍后的单独工作,不是本提案本身的一部分。
生态系统信息交流(不 是发布的原因)
标准化工作有时仅在“社区”层面有帮助,即使没有浏览器中的更改。信号工作汇聚了许多不同的框架作者,就响应性、算法和互操作性的本质进行深入讨论。这已经是有用的,并且不证明在 JS 引擎和浏览器中包含是合理的;信号只应在有显著好处 超出 启用的生态系统信息交流时添加到 JavaScript 标准中。
信号的设计目标
事实证明,现有的信号库在核心上并不都那么不同。此提案旨在通过实现这些库中许多重要质量来构建它们的成功。
核心特性
- 表示状态的信号类型,即可写信号。这是一个其他人可以读取的值。
- 计算/记忆/派生信号类型,它依赖于其他信号,并且惰性计算并缓存。
- 计算是惰性的,意味着默认情况下,当其中一个依赖项更改时,计算信号不会重新计算,而只有实际读取它们时才运行。
- 计算是“无毛刺”,意味着不会执行不必要的计算。这意味着,当应用程序读取计算信号时,存在对可能脏的图部分进行拓扑排序以运行,以消除任何重复。
- 计算被缓存,意味着如果在上次依赖项更改后没有任何依赖项更改,则计算信号在访问时 不 重新计算。
- 自定义比较是可能的,用于计算信号以及状态信号,以指出何时应更新依赖于它们的进一步计算信号。
- 对计算信号的一个依赖项(或嵌套依赖项)变为“脏”并更改的情况的反应,意味着信号的值可能过时。
- 此反应旨在安排更重要的工作稍后执行。
- 效果是基于这些反应,加上框架级调度来实现的。
- 计算信号需要能够反应它们是否被注册为这些反应之一的(嵌套)依赖项。
- 使 JS 框架能够进行自己的调度。没有 Promise 风格的内置强制调度。
- 需要同步反应以启用基于框架逻辑安排稍后工作。
- 写入是同步的并立即生效(批处理写入的框架可以在其之上进行)。
- 可以将检查效果是否可能“脏”与实际运行效果分开(启用两阶段效果调度器)。
- 能够读取信号 不 触发依赖项记录(
untrack) - 启用使用信号/响应性的不同代码库的组合,例如,
- 在跟踪/响应性本身方面一起使用多个框架(关于遗漏,见下文)
- 框架无关的响应式数据结构(例如,递归响应式存储代理、响应式 Map 和 Set 和 Array 等)
健全性
- 阻止/禁止对同步反应的幼稚误用。
- 健全性风险:如果使用不当,它可能暴露“毛刺”:如果在设置信号时立即进行渲染,它可能向最终用户暴露不完整的应用程序状态。因此,此功能只应用于智能地安排工作稍后执行,一旦应用程序逻辑完成。
- 解决方案:禁止在同步反应回调中读写任何信号。
- 阻止
untrack并标记其不健全的性质- 健全性风险:允许创建值依赖其他信号但那些信号更改时不更新的计算信号。当未跟踪的访问不会改变计算结果时,应使用它。
- 解决方案:API 在名称中标记为“不安全”。
- 注意:此提案确实允许从计算和效果信号中读写信号,而不限制读取后的写入,尽管存在健全性风险。采取此决定是为了保持在与框架集成时的灵活性和兼容性。
表面 API
- 必须是多个框架实现其信号/响应性机制的坚实基础。
- 应该是一个好的基础,用于递归存储代理、基于装饰器的类字段响应性,以及
.value和[state, setState]风格的 API。 - 语义能够表达不同框架启用的有效模式。例如,应该有可能这些信号作为立即反映的写入或批处理并稍后应用的写入的基础。
- 应该是一个好的基础,用于递归存储代理、基于装饰器的类字段响应性,以及
- 如果此 API 能被 JavaScript 开发者直接使用,那将是好的。
- 如果一个特性与生态系统概念匹配,使用通用词汇是好的。
- 然而,重要的是不要字面上遮蔽完全相同的名称!
- “JS 开发者的可用性”与“为框架提供所有钩子”之间的张力
- 想法:提供所有钩子,但尽可能在误用时包括错误。
- 想法:将微妙的 API 放在
subtle命名空间中,类似于crypto.subtle,以标记实现框架或构建开发工具等更高级使用与更日常的应用程序开发使用(如实例化信号以与框架一起使用)之间的界限。
- 如果一个特性与生态系统概念匹配,使用通用词汇是好的。
- 可实现且性能良好 - 表面 API 不会导致太多开销
- 启用子类化,以便框架可以添加自己的方法和字段,包括私有字段。这对于避免在框架级别需要额外分配很重要。见下面“内存管理”。
内存管理
- 如果可能:一个计算信号如果没有任何活着的引用用于未来可能的读取,它应该可以被垃圾收集,即使它链接到一个保持存活的更广泛的图中(例如,通过读取一个保持存活的 state)。
- 注意,当今大多数框架需要显式处理计算信号,如果它们有任何引用到或来自保持存活的另一个信号图。
- 当它们的生命周期与 UI 组件的生命周期相关联时,这最终不会太糟糕,并且无论如何效果都需要处理。
- 如果以这些语义执行太昂贵,那么我们应该在下面的 API 中添加显式处理(或“解除链接”)计算信号,目前缺少该功能。
- 一个单独的相关目标:最小化分配次数,例如,
- 制作一个可写信号(避免两个单独的闭包 + 数组)
- 实现效果(避免为每个反应创建闭包)
- 在观察信号变化的 API 中,避免创建额外的临时数据结构
- 解决方案:基于类的 API 支持重用子类中定义的方法和字段。
API 草图
以下是信号 API 的初步想法。注意,这只是一个早期草案,我们预计会随着时间的推移发生变化。让我们从完整的 .d.ts 开始,以了解整体形状,然后我们将讨论其含义的细节。
信号如何工作
信号表示一个随时间变化的数据单元。信号可以是“状态”(只是一个手动设置的值)或“计算”(基于其他信号的公式)。
计算信号通过自动跟踪在其求值期间读取的其他信号来工作。当读取一个计算值时,它检查其先前记录的依赖项是否发生了变化,如果有,则重新计算自身。当多个计算信号嵌套时,所有跟踪的归属都归于最内层的那个。
计算信号是惰性的,即基于拉取的:它们只在被访问时重新求值,即使它们的依赖项之一更早更改。
传递给计算信号的回调通常应该是“纯粹的”,即它是它访问的其他信号的确定性、无副作用的函数。同时,回调被调用的时机是确定性的,允许小心使用副作用。
信号具有突出的缓存/记忆化:状态和计算信号都记住它们的当前值,并且仅当它们实际更改时才触发引用它们的计算信号重新计算。甚至不需要重复比较旧值和新值——比较在源信号被重置/重新评估时进行一次,信号机制跟踪哪些引用该信号的项尚未基于新值更新。在内部,这通常通过“图着色”表示,如(Milo 的博客文章)所述。
计算信号动态跟踪它们的依赖项——每次运行时,它们可能最终依赖不同的东西,并且该精确的依赖集在信号图中保持新鲜。这意味着如果您有一个仅在一条分支上需要的依赖项,而之前的计算采取了另一条分支,则对该临时未使用值的更改不会导致计算信号重新计算,即使在拉取时。
与 JavaScript Promise 不同,信号中的所有内容都是同步运行的:
- 将信号设置为新值是同步的,并且这在之后读取任何依赖它的计算信号时立即反映出来。没有内置的此变异的批处理。
- 读取计算信号是同步的——它们的值始终可用。
- Watcher 中的
notify回调,如下所述,在触发它的.set()调用期间同步运行(但在图着色完成之后)。
与 Promise 一样,信号可以表示错误状态:如果计算信号的回调抛出,则该错误像其他值一样被缓存,并在每次读取信号时重新抛出。
理解 Signal 类
一个 Signal 实例表示读取随时间变化且更新被跟踪的动态值的能力。它还隐式包括订阅信号的能力,通过来自另一个计算信号的跟踪访问。
这里的 API 旨在匹配大部分信号库在“signal”、“computed”和“state”等名称使用上的大致生态共识。然而,对 Computed 和 State 信号的访问是通过 .get() 方法进行的,这与所有流行的信号 API 不一致,它们要么使用 .value 风格的访问器,要么使用 signal() 调用语法。
该 API 旨在减少分配次数,使信号适合嵌入 JavaScript 框架,同时达到与现有框架定制的信号相同或更好的性能。这意味着:
- 状态信号是单个可写对象,可以从同一引用访问和设置。(见下面“能力分离”部分的含义。)
- State 和 Computed 信号都被设计为可子类化,以促进框架能够通过公共和私有类字段(以及使用该状态的方法)添加额外属性。
- 各种回调(例如
equals、计算回调)以相关信号作为this值调用以提供上下文,这样每个信号不需要新的闭包。相反,上下文可以保存在信号本身的额外属性中。
此 API 强制执行的一些错误条件:
- 递归读取计算是错误的。
- Watcher 的
notify回调不能读取或写入任何信号。 - 如果计算信号的回调抛出,则后续访问信号会重新抛出该缓存的错误,直到依赖项之一更改并重新计算。
一些 不 强制执行的条件:
- 计算信号可以在其回调中同步写入其他信号。
- 由 Watcher 的
notify回调排队的工作可以读写信号,从而可以在信号方面复制 经典的 React 反模式!
实现效果
上面定义的 Watcher 接口为实现典型的 JS 效果 API 提供了基础:当其他信号更改时重新运行的回调,纯粹为了它们的副作用。上面初始示例中使用的 effect 函数可以定义如下:
Signal API 不包含任何像 effect 这样的内置函数。这是因为效果调度是微妙的,并且通常与框架渲染周期和其他高级框架特定状态或策略相关联,JS 无法访问这些。
逐步介绍这里使用的不同操作:传递给 Watcher 构造函数的 notify 回调是在信号从“干净”状态(我们知道缓存已初始化且有效)进入“已检查”或“脏”状态(缓存可能有效也可能无效,因为其递归依赖的至少一个状态已更改)时调用的函数。
对 notify 的调用最终由某个状态信号上的 .set() 调用触发。此调用是同步的:它发生在 .set 返回之前。但无需担心此回调观察到半处理状态的信号图,因为在 notify 回调期间,不能读取或写入任何信号,即使在 untrack 调用中也是如此。因为 notify 在 .set() 期间被调用,它中断了另一个可能未完成的逻辑线程。要从 notify 读写信号,安排稍后运行的工作,例如,将信号写下来到列表中以便稍后访问,或像上面那样使用 queueMicrotask。
注意,完全可以在没有 Signal.subtle.Watcher 的情况下有效使用信号,通过安排轮询计算信号,如 Glimmer 所做的那样。然而,许多框架发现让此调度逻辑同步运行通常非常有用,因此 Signals API 包含它。
计算信号和状态信号都可以像任何 JS 值一样被垃圾回收。但 Watcher 有一种特殊的方式来保持事物存活:任何由 Watcher 观察的信号将保持存活,只要任何底层状态是可访问的,因为这些可能触发未来的 notify 调用(以及未来的 .get())。因此,请记住调用 Watcher.prototype.unwatch 来清理效果。
一个不健全的逃生舱口
Signal.subtle.untrack 是一个逃生舱口,允许读取信号 不 跟踪这些读取。此能力是不安全的,因为它允许创建值依赖其他信号但那些信号更改时不更新的计算信号。当未跟踪的访问不会改变计算结果时,应使用它。
暂时省略
这些功能可能会在以后添加,但当前草案中未包含。省略是由于在设计空间中框架之间缺乏已建立的共识,以及已经证明可以使用本文档中描述的信号概念之上的机制来弥补其缺失的能力。然而,不幸的是,省略限制了框架之间的互操作性潜力。随着本文档中描述的信号原型的产生,将努力重新审视这些省略是否是正确的决定。
- 异步:在此模型中,信号始终同步可供求值。然而,通常有用的是让某些异步过程导致信号被设置,并了解信号何时仍在“加载”。建模加载状态的一种简单方法是使用异常,而计算信号的异常缓存行为与该技术在一定程度上合理地组合。更好的技术在 Issue #30 中讨论。
- 事务:对于视图之间的转换,通常有用的是维护“从”和“到”状态的实时状态。“到”状态在后台渲染,直到准备好交换(提交事务),而“从”状态保持交互。同时维护两个状态需要“分叉”信号图的状态,甚至可能支持同时多个待处理转换。讨论在 Issue #73 中。
一些可能的便捷方法也被省略了。
状态和发展计划
此提案在 2024 年 4 月的 TC39 议程上为阶段 1。目前可以被认为是“阶段 0”。
此提案的 polyfill 可用,并带有一些基本测试。一些框架作者已开始尝试替换此信号实现,但此使用处于早期阶段。
Signal 提案的合作者希望在推进此提案时特别保守,以便我们不会落入陷阱,得到我们最终后悔且实际上不使用的发布内容。我们的计划是执行以下额外任务,这些任务不是 TC39 流程要求的,以确保此提案在轨道上:
在提议进入阶段 2 之前,我们计划:
- 开发多个生产级别的 polyfill 实现,这些实现是坚实的、经过良好测试的(例如,通过来自各种框架的测试以及 test262 风格的测试),并且在性能方面具有竞争力(通过全面的信号/框架基准集验证)。
- 将提议的 Signal API 集成到我们认为具有一定代表性的众多 JS 框架中,并且一些大型应用程序在此基础上有工作。测试在这些上下文中是否高效且正确。
- 对 API 的可能扩展空间有扎实的理解,并已得出结论,哪些(如果有)应添加到本提案中。
信号算法
本节描述暴露给 JavaScript 的每个 API,以其实现的算法为术语。这可以被视为一个原型规范,并在这个早期阶段包含以确定一组可能的语义,同时非常开放于更改。
算法的某些方面:
- 信号在计算内的读取顺序很重要,并且在某些回调执行的顺序中是可观察的(调用哪个
Watcher、equals、new Signal.Computed的第一个参数,以及watched/unwatched回调)。这意味着计算信号的源必须有序存储。 - 这四个回调可能都会抛出异常,这些异常以可预测的方式传播到调用 JS 代码。异常不会停止此算法的执行或在半处理状态下留下图。对于在 Watcher 的
notify回调中抛出的错误,该异常被发送到触发它的.set()调用,如果抛出多个异常,则使用 AggregateError。其他的(包括watched/unwatched?)存储在信号的值中,以便在读取时重新抛出,并且这样的重新抛出信号可以像任何其他具有正常值的信号一样被标记为~clean~。 - 小心避免在未被“观察”的计算信号(被任何 Watcher 观察)的情况下出现循环,以便它们可以与信号图的其他部分独立进行垃圾回收。在内部,这可以通过一种始终收集的世代号系统来实现;注意,优化的实现可能还包括本地每节点世代号,或者避免在某些被观察信号上跟踪一些数字。
隐藏的全局状态
信号算法需要引用某些全局状态。此状态对整个线程或“代理”是全局的。
computing:当前因.get或.run调用而正在重新求值的最内层计算或效果信号,或null。最初为null。frozen:布尔值,表示当前是否有回调执行要求不得修改图。最初为false。generation:一个递增的整数,从 0 开始,用于跟踪值的时效性,同时避免循环。
Signal 命名空间
Signal 是一个普通对象,用作信号相关类和函数的命名空间。
Signal.subtle 是一个类似的内部命名空间对象。
Signal.State 类
Signal.State 内部槽位
value:状态信号的当前值equals:更改值时使用的比较函数watched:当信号被效果观察时调用的回调unwatched:当信号不再被效果观察时调用的回调sinks:依赖于此信号的被观察信号的集合
构造函数:Signal.State(initialValue, options)
- 将此信号的
value设置为initialValue。 - 将此信号的
equals设置为 options?.equals - 将此信号的
watched设置为 options?.[Signal.subtle.watched] - 将此信号的
unwatched设置为 options?.[Signal.subtle.unwatched] - 将此信号的
sinks设置为空集
方法:Signal.State.prototype.get()
- 如果
frozen为 true,则抛出异常。 - 如果
computing不是undefined,则将此信号添加到computing的sources集中。 - 注意:我们不将
computing添加到此信号的sinks集中,直到它被 Watcher 观察。 - 返回此信号的
value。
方法:Signal.State.prototype.set(newValue)
- 如果当前执行上下文是
frozen,则抛出异常。 - 使用此信号和第一个参数作为值运行“设置信号值”算法。
- 如果该算法返回
~clean~,则返回 undefined。 - 将此信号的所有
sinks的状态设置为(如果它是计算信号)~dirty~(如果它们先前是干净的),或(如果它是 Watcher)~pending~(如果先前是~watching~)。 - 将所有接收者的计算信号依赖项(递归地)的状态设置为
~checked~(如果它们先前是~clean~)(即保留脏标记),或对于 Watcher,~pending~(如果先前是~watching~)。 - 对于在该递归搜索中遇到的每个先前
~watching~的 Watcher,然后按深度优先顺序,- 将
frozen设置为 true。 - 调用它们的
notify回调(保存抛出的任何异常,但忽略notify的返回值)。 - 将
frozen恢复为 false。 - 将 Watcher 的状态设置为
~waiting~。
- 将
- 如果从
notify回调中抛出任何异常,则在所有notify回调运行后将其传播给调用者。如果有多个异常,则将它们一起打包成 AggregateError 并抛出。 - 返回 undefined。
Signal.Computed 类
Signal.Computed 状态机
计算信号的状态可以是以下之一:
~clean~:信号的值存在且已知不过时。~checked~:此信号的(间接)源已更改;此信号有一个值,但 可能 过时。是否过时只有在对所有直接源求值后才会知道。~computing~:此信号的回调当前作为.get()调用的副作用正在执行。~dirty~:此信号的值已知过时,或者从未被求值过。
转换图如下:
转换是:
Signal.Computed 内部槽位
value:信号的先前缓存值,或~uninitialized~对于从未读取的计算信号。该值可能是一个在读取时重新抛出的异常。对于效果信号始终为undefined。state:可以是~clean~、~checked~、~computing~或~dirty~。sources:此信号依赖的信号的有序集合。sinks:依赖于此信号的信号的集合。equals:选项提供的 equals 方法。callback:调用以获取计算信号值的回调。设置为传递给构造函数的第一个参数。
Signal.Computed 构造函数
构造函数设置
callback为其第一个参数equals基于选项,如果不存在则默认为Object.isstate为~dirty~value为~uninitialized~
使用 AsyncContext,传递给 new Signal.Computed 的回调闭包覆盖构造函数调用时的快照,并在其执行期间恢复此快照。
方法:Signal.Computed.prototype.get
- 如果当前执行上下文是
frozen或如果此信号的状态为~computing~,或者如果此信号是 Watcher 且computing是计算信号,则抛出异常。 - 如果
computing不是null,则将此信号添加到computing的sources集中。 - 注意:我们不将
computing添加到此信号的sinks集中,直到/除非它成为 Watcher。 - 如果此信号的状态为
~dirty~或~checked~:重复以下步骤,直到此信号为~clean~:- 通过
sources递归向上,找到最深的、最左边的(即最早观察到的)递归源,该源是标记为~dirty~的计算信号(在遇到~clean~计算信号时停止搜索,并将此计算信号作为最后要搜索的内容)。 - 在该信号上执行“重新计算脏的计算信号”算法。
- 通过
- 在这一点上,此信号的状态将为
~clean~,并且没有递归源将是~dirty~或~checked~。返回信号的value。如果该值是异常,则重新抛出该异常。
Signal.subtle.Watcher 类
Signal.subtle.Watcher 状态机
Watcher 的状态可以是以下之一:
~waiting~:notify回调已运行,或者 Watcher 是新的,但没有积极观察任何信号。~watching~:Watcher 正在积极观察信号,但尚未发生需要notify回调的更改。~pending~:Watcher 的依赖项已更改,但notify回调尚未运行。
转换图如下:
转换是:
Signal.subtle.Watcher 内部槽位
state:可以是~watching~、~pending~或~waiting~signals:此 Watcher 正在观察的信号的有序集合notifyCallback:当某些内容更改时调用的回调。设置为传递给构造函数的第一个参数。
构造函数:new Signal.subtle.Watcher(callback)
state设置为~waiting~。- 将
signals初始化为空集。 notifyCallback设置为回调参数。
使用 AsyncContext,传递给 new Signal.subtle.Watcher 的回调不闭包覆盖构造函数调用时的快照,因此写入周围的上下文信息是可见的。
方法:Signal.subtle.Watcher.prototype.watch(...signals)
- 如果
frozen为 true,则抛出异常。 - 如果任何参数不是信号,则抛出异常。
- 将所有参数追加到此对象的
signals的末尾。 - 对于每个新观察的信号,按从左到右的顺序,
- 将此 watcher 作为
sink添加到该信号。 - 如果这是第一个 sink,则递归向上到源以将该信号作为 sink 添加。
- 将
frozen设置为 true。 - 如果存在
watched回调,则调用它。 - 将
frozen恢复为 false。
- 将此 watcher 作为
- 如果信号的状态为
~waiting~,则将其设置为~watching~。
方法:Signal.subtle.Watcher.prototype.unwatch(...signals)
- 如果
frozen为 true,则抛出异常。 - 如果任何参数不是信号,或者未被此 watcher 观察,则抛出异常。
- 对于参数中的每个信号,按从左到右的顺序,
- 从该 Watcher 的
signals集中移除该信号。 - 从该信号的
sink集中移除该 Watcher。 - 如果该信号的
sink集已变为空,则从每个源中移除该信号作为 sink。 - 将
frozen设置为 true。 - 如果存在
unwatched回调,则调用它。 - 将
frozen恢复为 false。
- 从该 Watcher 的
- 如果 watcher 现在没有
signals,并且其state为~watching~,则将其设置为~waiting~。
方法:Signal.subtle.Watcher.prototype.getPending()
- 返回包含
signals中的子集的数组,该子集是处于~dirty~或~pending~状态的计算信号。
方法:Signal.subtle.untrack(cb)
- 让
c为执行上下文的当前computing状态。 - 将
computing设置为 null。 - 调用
cb。 - 将
computing恢复为c(即使cb抛出异常)。 - 返回
cb的返回值(重新抛出任何异常)。
注意:untrack 不会让您脱离 frozen 状态,该状态是严格维护的。
方法:Signal.subtle.currentComputed()
- 返回当前的
computing值。
公共算法
算法:重新计算脏的计算信号
- 清除此信号的
sources集,并从这些源的sinks集中移除它。 - 保存先前的
computing值并将computing设置为此信号。 - 将此信号的状态设置为
~computing~。 - 运行此计算信号的回调,使用此信号作为 this 值。保存返回值,如果回调抛出异常,则存储该异常以供重新抛出。
- 恢复先前的
computing值。 - 将“设置信号值”算法应用于回调的返回值。
- 将此信号的状态设置为
~clean~。 - 如果该算法返回
~dirty~:将此信号的所有 sinks 标记为~dirty~(先前,sinks 可能是已检查和脏的混合)。(或者,如果这是未观察的,则采用新的世代号来表示脏,或类似方式。) - 否则,该算法返回
~clean~:在这种情况下,对于此信号的每个~checked~sink,如果该信号的所有源现在都是干净的,则也将该信号标记为~clean~。将此清理步骤递归应用于进一步的 sinks,应用于任何具有已检查 sinks 的新干净信号。(或者,如果这是未观察的,则以某种方式指示相同,以便清理可以惰性进行。)
设置信号值算法
- 如果此算法收到一个值(而不是从重新计算脏的计算信号算法中重新抛出的异常):
- 调用此信号的
equals函数,传递当前value、新值和此信号作为参数。如果抛出异常,则保存该异常(以便在读取时重新抛出)作为信号的值,并继续,就好像回调返回了 false。 - 如果该函数返回 true,则返回
~clean~。
- 调用此信号的
- 将此信号的
value设置为参数。 - 返回
~dirty~。
常见问题
问:现在标准化与信号相关的东西是不是有点早,它们刚刚在 2022 年开始成为热门新事物?我们不应该给它们更多的时间来发展和稳定吗?
答:Web 框架中信号的当前状态是 10 多年持续发展结果。随着近年来的投资加大,几乎所有 web 框架都在接近一个非常相似的信号核心模型。此提案是大量当前 web 框架领导者之间的共享设计工作成果,如果没有该领域专家在各种上下文中的验证,它不会被推向标准化。
信号如何使用?
问:鉴于信号与渲染和所有权的紧密集成,内置信号甚至能被框架使用吗?
答:更特定于框架的部分往往在效果、调度和所有权/处理领域,此提案不试图解决这些。我们在原型化标准轨道的信号时的首要任务是验证它们能够“位于”现有框架之下,兼容且性能良好。
问:Signal API 是旨在供应用程序开发者直接使用,还是由框架包装?
答:虽然此 API 可以直接被应用程序开发者使用(至少在 Signal.subtle 命名空间之外的部分),但它不是专门为特别人性化而设计的。相反,库/框架作者的需求是优先考虑的。大多数框架预计将包装甚至基本的 Signal.State 和 Signal.Computed API,以表达其人体工程学倾向。在实践中,通常最好通过框架使用信号,该框架管理更棘手的特性(例如 Watcher、untrack),以及管理所有权和处理(例如,弄清楚何时应将信号添加到和从 watchers 中移除),以及调度渲染到 DOM--此提案不试图解决这些问题。
问:当一个小部件被销毁时,我是否必须拆除与该小部件相关的信号?该 API 是什么?
答:相关的拆除操作是 Signal.subtle.Watcher.prototype.unwatch。只有被观察的信号需要清理(通过取消观察它们),而未观察的信号可以自动进行垃圾回收。
问:信号与 VDOM 一起工作,还是直接与底层 HTML DOM 一起工作?
答:是的!信号与渲染技术无关。使用类似信号构造的现有 JavaScript 框架与 VDOM(例如 Preact)、原生 DOM(例如 Solid)以及组合(例如 Vue)集成。内置信号也将有可能做到这一点。
问:在像 Angular 和 Lit 这样的基于类的框架上下文中使用信号会人体工程学吗?像 Svelte 这样的基于编译器的框架呢?
答:类字段可以通过简单的访问器装饰器变为基于信号的,如 Signal polyfill 自述文件 所示。信号与 Svelte 5 的 Runes 非常接近--编译器将 runes 转换为此处定义的 Signal API 是简单的,事实上,这正是 Svelte 5 在内部所做的(但使用它自己的信号库)。
问:信号与 SSR、水合、可恢复性一起工作吗?
答:是的。Qwik 有效地使用信号实现这两个属性,其他框架有其他成熟的方法,以实现不同权衡的信号水合。我们认为可以使用一个 State 和一个 Computed 信号连接在一起来建模 Qwik 的可恢复信号,并计划在代码中证明这一点。
问:信号与 React 这样的单向数据流一起工作吗?
答:是的,信号是单向数据流的一种机制。基于信号的 UI 框架允许您将视图表达为模型的函数(其中模型包含信号)。状态和计算信号的图按构造是非循环的。还可以在信号中创建 React 反模式(!),例如,在 useEffect 中 setState 的信号等效方式是使用 Watcher 安排对 State 信号的写入。
问:信号与像 Redux 这样的状态管理系统有何关系?信号是否鼓励非结构化状态?
答:信号可以构成类似存储的状态管理抽象的高效基础。在多个框架中发现的一个常见模式是一个基于 Proxy 的对象,它在内部使用信号表示属性,例如 Vue reactive() 或 Solid stores。这些系统允许在适合特定应用程序的抽象级别灵活地对状态进行分组。
问:信号提供了什么 Proxy 目前不处理的东西?
答:代理和信号是互补的,并且很好地协同工作。代理允许您拦截浅层对象操作,而信号协调依赖图(细胞)。用信号支持代理是制作嵌套响应式结构并具有良好人体工程学的好方法。
在这个例子中,我们可以使用代理使信号具有 getter 和 setter 属性,而不是使用 get 和 set 方法:
当使用针对细粒度响应性优化的渲染器时,点击按钮将导致 b.value 单元格被更新。
参见:
- 使用信号和代理创建的嵌套响应式结构的示例:signal-utils
- 显示响应式数据和代理之间关系的先前实现示例:tracked-built-ins
- 讨论。
信号如何工作?
问:信号是基于推送还是基于拉取?
答:计算信号的求值是拉式的:计算信号仅在调用 .get() 时求值,即使底层状态更早更改。同时,更改状态信号可能会立即触发 Watcher 的回调,“推送”通知。因此信号可以被认为是“推拉”构造。
问:信号是否在 JavaScript 执行中引入非确定性?
答:不。首先,所有信号操作都有明确定义的语义和顺序,并且在符合规范的实现之间不会有所不同。在更高层次上,信号遵循一组特定的不变量,相对于这些不变量它们是“健全的”。计算信号总是以一致的状态观察信号图,并且其执行不会被其他信号变异代码中断(除了它自己调用的内容)。见上面的描述。
问:当我写入状态信号时,计算信号的更新何时安排?
答:它不是安排的!计算信号将在下次有人读取它时自行重新计算。同步地,Watcher 的 notify 回调可以被调用,使框架能够在他们认为合适的时候安排读取。
问:对状态信号的写入何时生效?立即还是批处理?
答:对状态信号的写入立即反映--下一次读取依赖于状态信号的计算信号时,它将根据需要重新计算,即使紧接着的代码行中也是如此。然而,该机制固有的惰性(计算信号仅在读取时计算)意味着在实践中,计算可能以批处理方式发生。
问:信号实现“无毛刺”执行意味着什么?
答:早期的基于推送的响应性模型面临冗余计算的问题:如果状态信号的更新导致计算信号急切运行,最终可能会将更新推送到 UI。但是,如果在下一次帧之前对源状态信号进行另一次更改,那么此 UI 写入可能为时过早。有时,由于这种毛刺,甚至向最终用户显示了不准确的中间值。信号通过基于拉取而不是基于推送来避免这种动态:在框架安排 UI 渲染时,它将拉取适当的更新,避免在计算和写入 DOM 方面浪费工作。
问:信号是“有损的”意味着什么?
答:这是无毛刺执行的反面:信号表示一个数据单元--只是直接的当前值(可能更改),而不是随时间的数据流。因此,如果您连续两次写入状态信号,而不做任何其他事情,则第一次写入被“丢失”,并且永远不会被任何计算信号或效果看到。这被理解为一个特性而不是一个错误--其他构造(例如异步迭代、可观察量)更适合流。
问:原生信号会比现有的 JS 信号实现更快吗?
答:我们希望如此(通过一个小常数因子),但这还有待在代码中证明。JS 引擎不是魔法,最终将需要实现与 JS 信号实现相同类型的算法。见上面关于性能的部分。
为什么信号这样设计?
问:当效果对于任何实际使用信号都是必要的时,为什么此提案不包含 effect() 函数?
答:效果固有地与调度和处理相关联,这些由框架管理且超出此提案的范围。相反,此提案包含通过更低级的 Signal.subtle.Watcher API 实现效果的基础。
问:为什么订阅是自动的,而不是提供手动界面?
答:经验表明,用于响应性的手动订阅界面不人体工程学且容易出错。自动跟踪更可组合,是信号的核心特性。
问:为什么 Watcher 的回调同步运行,而不是在微任务中调度?
答:因为回调不能读取或写入信号,所以同步调用它不会带来不健全性。典型的回调将添加一个信号到数组中以便稍后读取,或标记某处的位。为所有这些类型的操作单独创建微任务是不必要的且不切实际的昂贵。
问:此 API 缺少我最喜欢的框架提供的一些好东西,这使得使用信号编程更容易。能否也添加到标准中?
答:也许。各种扩展仍在考虑中。请提交问题以讨论您发现重要的任何缺失功能的讨论。
问:此 API 可以减少大小或复杂性吗?
答:保持此 API 最小化绝对是一个目标,我们已尝试使用上面呈现的内容来实现。如果您有更多可以删除的想法,请提交问题讨论。
信号如何标准化?
问:我们不应该使用更原始的概念(如可观察量)来开始此领域的标准化工作吗?
答:可观察量对于某些事情可能是一个好主意,但它们不能解决信号旨在解决的问题。如上所述,可观察量或其他发布/订阅机制不是对许多类型的 UI 编程的完整解决方案,因为开发者需要太多易出错的配置工作,以及由于缺乏惰性而导致的工作浪费,以及其他问题。
问:为什么信号在 TC39 而不是 DOM 中提出,鉴于其大多数应用是基于 Web 的?
答:此提案的一些共同作者对非 Web UI 环境作为目标感兴趣,但如今,任何场所都可能是合适的,因为 Web API 更频繁地在 Web 之外实现。最终,信号不需要依赖任何 DOM API,因此两种方式都有效。如果有人有强烈理由让此组切换,请告诉我们。目前,所有贡献者已签署 TC39 知识产权协议,计划是将此提交给 TC39。
问:我什么时候才能使用标准信号?
答:已经有一个 polyfill,但最好不要依赖其稳定性,因为此 API 在其审查过程中会演变。在几个月或一年内,一个高质量、高性能的稳定 polyfill 应该可用,但这仍将受制于委员会修订,且还不是标准。遵循 TC39 提案的典型轨迹,预计至少需要 2-3 年,信号才能在所有浏览器中原生可用,回到几个版本,从而不需要 polyfill。
问:我们如何防止过早标准化错误的信号类型,就像 {{JS/web 功能你不喜欢}} 一样?
答:此提案的作者计划在请求 TC39 阶段推进之前,额外努力进行原型设计和验证。见上面“状态和发展计划”。如果您看到此计划中的缺陷或改进机会,请提交问题解释。
贡献
我们邀请所有响应式库的作者加入问题和 Discord 的讨论。