《前端暴走团》,喜欢请抱走~大家好,我是团长林语冰。
今天,我十分鸡冻地和大家共享,JS Signals(信号)提案的 stage 0 草案、以及符合规范的 polyfill(功能补丁)现已正式公开发布。
大家可能已经在前端生态的某些库和框架中,或多或少听说过 Signals 了,包括但不限于:Vue、Angular、MobX、Preact、Qwik、RxJS、Solid、Svelte 等等。
举个栗子,地表最强响应式框架 ------ Vue 的官方文档就专门阐释了响应式和 Signals 的"超友谊关系":
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 A TC39 Proposal for Signals。
Signals 是什么鬼物?
Signals 是一种数据类型,它对状态单元以及从其他状态或计算值导出的计算建模,从而实现单向数据流。
状态和计算值形成一个非循环图,其中每个节点都拥有其他节点,这些节点是从其值导出状态的 sink(状态槽),或者是将状态贡献给其值的 source(状态源)。节点也可以被跟踪为"clean"(净值)或"dirty"(脏值)。
举个栗子,假设我们有一个想要追踪的计数器,我们可以将其表示为状态:
js
// 计数器
const counter = new Signal.State(0)
我们可以:
- 使用
get()
读取当前值 - 使用
set()
更改当前值
js
console.log(counter.get()) // 0
counter.set(1)
console.log(counter.get()) // 1
现在,假设我们想要另一个信号,用来表示计数器是否为偶数。
js
// 是否为偶数
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0)
计算值是不可写的,但我们总是可以读取其最新值:
js
console.log(isEven.get()) // false
counter.set(2)
console.log(isEven.get()) // true
如上所示,isEven
是 counter
的状态槽,而 counter
是 isEven
的状态源。
我们可以添加另一个计算值来实现计数器的奇偶校验:
js
// 奇偶判定
const parity = new Signal.Computed(() => (isEven.get() ? 'even' : 'odd'))
现在,isEven
是 parity
的状态源,且 parity
是 isEven
的状态槽。我们可以改变最初的 counter
,状态会单向流向 parity
。
js
counter.set(3)
console.log(parity.get()) // odd,奇数
目前为止我们所做的一切似乎都可以通过普通的函数组合来实现。但为何我们还需要 Signals 呢?
回想一下,上文我们提及的 Signals 可以是净值或脏值。
当我们更改 counter
的值时,它就会变成脏值。因为我们有图形关系,所以我们可以将 counter
的所有状态槽标记为脏值,以及对状态槽的所有状态槽如法炮制。
粉丝请注意,Signals 算法不是推送模型 。更改 counter
不会立即推送更新 isEven
的值,然后通过图表推送更新 parity
。
Signals 也不是纯粹的拉取模型。读取 parity
的值并不总是计算 parity
或 isEven
的值。相反,当 counter
更改时,它只将脏值标志中的更改推送到图表中。任何潜在的重新计算都会被延迟,直到明确提取特定 Signals 的值为止。
我们称之为"先推后拉"模型。脏值标志会被迫切更新(推送),而计算值会被延迟求值(拉动)。
将非循环图数据结构与"先推后拉"算法相结合会产生许多优点。包括但不限于:
Signal.Computed
会自动记忆。如果状态源不变,那就无需重新计算。- 即使状态源变更,也不会重新计算不需要的值。如果计算值是脏值,但没有任何内容读取其值,那就不会发生重新计算。
- 错误或"过度更新"可以避免。举个栗子,如果我们将
counter
从2
改为4
,那么它是脏值。但是当我们拉取parity
的值时,它的计算不需要重新运行,因为一旦拉取了isEven
,就会为4
返回与2
相同的结果。 - 当 Signals 变脏时,我们会收到通知,并选择如何响应。
事实证明,这些特性在高效更新 UI 时至关重要。为了了解实现过程,我们可以引入一个虚构的 effect
函数,当其中一个状态源变脏时,该函数会调用某些操作。举个栗子,我们可以使用 parity
更新 DOM 中的文本节点:
js
effect(() => (node.textContent = parity.get()))
counter.set(2)
counter.set(4)
Signals 提案的细节
Signals 提案的具体内容包括但不限于:
- 背景、动机、设计目标和常见问题解答
- 用于创建状态信号和计算信号的 API
- 用于侦听信号的 API
- 符合规范的polyfill,涵盖所有建议的API。
- ......
Signals 提案不包括 effect
API,因为此类 API 通常与高度依赖框架/库的渲染和批处理策略深度集成。然而,Signals 提案确实试图定义一组原语和工具函数,库作者可以使用它们来自定义专属 effect
。
Signals 的用户分为两大类:
- 应用开发者
- 库/框架/基建开发者
供应用开发者使用的 API 直接从 Signal
的命名空间暴露。其中包括 Signal.State()
和 Signal.Computed()
。API 很少在应用程序代码中使用,且更可能涉及微妙的处理,通过 Signal.subtle
命名空间暴露。其中包括 Signal.subtle.Watcher
和 Signal.subtle.untrack()
API。
应用开发者如何使用 Signals?
如今许多人气爆棚的组件和渲染框架已经在使用 Signals 了。一旦 Signals 提案成功通过,应用开发者的模式不会改变。
然而,它们的框架会:
- 更具互操作性,因为 Signals 有官方标准
- 体积更小,因为 Signals 是内置的,不需要 JS 框架提供
- 性能更快,因为 Signals 会作为 JS 运行时的原生功能
使用 Signals 的一种最佳选择是和装饰器强强联手。我们可以创建一个 @signal
装饰器,将访问器转换为 Signals,如下所示:
js
// 装饰器
export function signal(target) {
const { get } = target
return {
get() {
return get.call(this).get()
},
set(value) {
get.call(this).set(value)
},
init(value) {
return new Signal.State(value)
}
}
}
然后我们可以使用该装饰器来减少样板文件,并提高 Counter
类的可读性,如下所示:
js
export class Counter {
@signal accessor #value = 0
get value() {
return this.#value
}
increment() {
this.#value++
}
decrement() {
if (this.#value > 0) {
this.#value--
}
}
}
库/基建开发者如何集成信号?
我们希望视图和组件库的维护者、以及创建状态管理库的维护者能够尝试集成 Signals 提案。
第一个集成步骤是更新库的信号,这样可以在内部使用 Signal.State()
和 Signal.Computed()
,而不是当前特定于库的实现。
常见的下一步是更新任何 effect
或等效基建,因为 Signals 提案没有提供 effect
的实现。我们的研究表明,effect
与渲染和批处理的细节密切相关,目前无法标准化。
相反,Signal.subtle
命名空间提供了框架可以用来构建专属 effect
的原语。
举个栗子,实现一个简单的 effect
函数,该函数对微任务队列进行批量更新。
js
let needsEnqueue = true
const w = new Signal.subtle.Watcher(() => {
if (needsEnqueue) {
needsEnqueue = false
queueMicrotask(processPending)
}
})
function processPending() {
needsEnqueue = true
for (const s of w.getPending()) {
s.get()
}
w.watch()
}
export function effect(callback) {
let cleanup
const computed = new Signal.Computed(() => {
typeof cleanup === 'function' && cleanup()
cleanup = callback()
})
w.watch(computed)
computed.get()
return () => {
w.unwatch(computed)
typeof cleanup === 'function' && cleanup()
}
}
effect
函数首先根据用户提供的回调创建一个 Signal.Computed()
。然后它可以使用 Signal.subtle.Watcher
来侦听计算值的状态源。
为了使侦听器能够"看到"状态源,我们需要至少执行一次计算,这可以通过调用 get()
来实现。
我们的 effect
实现支持回调的基本机制,提供清理函数以及通过返回的函数停止侦听的方法。
我们创建的 Signal.subtle.Watcher
,构造函数采用一个回调,每当其监视的任何信号变脏时,都会同步调用该回调。
由于 Watcher
可以监视任意数量的 Signals,因此我们安排对微任务队列上的所有脏值进行处理。一些基本的保护逻辑确保调度能且仅能发生一次,直到处理待处理的 Signals 为止。
在 processPending()
函数中,我们循环侦听器跟踪的所有待处理 Signals,并通过调用 get()
重新求值,然后要求侦听器恢复监视所有跟踪的 Signals。
大多数框架将以与其渲染或组件系统集成的方式处理调度,并且它们可能会进行其他实现更改以支持其系统的工作模型。
高潮总结
接下来的几周内,我们会在 TC39 上提交 Signals 提案,探索 stage 1。stage 1 意味着 Signals 提案正在考虑中。
目前,Signals 提案仍处于并将长期处于处于 stage 0。在 TC39 技术委员会上发言后,我们会根据委员会的反馈,以及我们从 GitHub 参与者的意见继续完善 Signals 提案。
本期话题是 ------ 你期待 Signals 提案吗?欢迎在本文下方自由言论,文明共享。
坚持阅读,自律打卡,每天一次,进步一点。
《前端暴走团》,喜欢请抱走!我是团长林语冰。谢谢大家的点赞,掰掰~