Signals:基于 Push-Pull 的算法
- 原文链接:willybrauner.com/journal/sig…
- 原文作者:Willy Brauner
- 原文栏目:Journal
这些年我们已经在生产环境里通过 Solid、Vue 等现代前端框架长期使用 Signals,但真正能把其内部工作机制讲清楚的人并不多。我想深入研究它,尤其是深入到 push-pull 这一核心算法,也就是它们响应式能力背后的关键机制。这个主题非常迷人。
世界的状态
把一个应用想象成一个世界:我们在其中描述一组支配它的规则。一旦某条规则被定义,程序就不能再随意更改这条规则本身。
例如,我们决定在这个世界里,任意 y 都必须等于 2 * x。从这一刻起,无论 x 如何变化,y 都会自动调整。我们可以定义任意多条规则,规则之间还可以互相依赖,比如再定义 z 必须等于 y + 1,以此类推。
接着按下播放键,程序启动,世界开始运行,我们定义的规则也开始在时间中生效(可以把它理解为运行时)。
说明:该 GIF 对应原文 “x/y/z” 状态表模块,展示当规则运行后各变量如何保持联动关系。
然后我们只需要观察:修改 x,y 和 z 就会自动调整以满足已建立的规则。这很像电子表格:依赖单元格会在源数据变化后自动更新。换句话说,派生值会对其依赖的变化产生响应。
这些派生值表现得像纯函数:没有副作用,也没有可变状态。在下一个例子中,time 是持续变化的源,而 rotation 是从它派生出来的值。方块只是反映了这个一次声明的变换结果。
说明:该 GIF 对应原文中 time -> rotation 的可视化模块,展示“源值连续变化,派生值实时响应”的效果。
这个“响应式世界”并非凭空出现。相关思想在 1970 年代出现,并被形式化为 Reactive Programming(响应式编程):一种描述“数据源变化如何自动沿依赖图传播计算”的范式,这正是 Signals 所做的事情。
因此,Signals 可以看作响应式编程范式的继承者。它在 JavaScript 侧的早期实现包括 Knockout.js(2010)和 RxJS(2012),它们把响应式思想带进了浏览器。
现在我们对 Signals 是什么有了更多背景,接下来就进入这套系统核心的 push-pull 算法。
Signals:Push-based
Signal 是一个表示响应式值的抽象,这个值可以被读取和修改。当 signal 变化时,应用中依赖它的部分会自动更新。下面是一个非常基础的实现:
const signal = <T>(initial: T) => {
let value: T = initial
const subs = new Set<(state: T) => void>()
return {
get value(): T {
return value
},
set value(v: T) {
if (value === v) return
value = v
for (const fn of Array.from(subs)) fn(v)
},
subscribe(fn: (state: T) => void) {
subs.add(fn)
return () => subs.delete(fn)
}
}
}
我们可以把 Signals 想象为这个世界规则体系的起点,是用于精准变更的原始入口。
我最开始的反应是:“这不就是一个带 getter/setter 的发布-订阅模式吗?”Signal 的确类似这个模式,只是它还把当前状态保存在闭包里,可读可写。如果你用过 event emitter,这种模式会很熟悉:
const count = signal(0)
// 应用中的某处
count.subscribe((newValue) => {
console.log("Count changed to:", newValue)
})
// 应用中的任意时刻与任意位置
count.value += 1
// "计数变为:1"
这就是所谓的 push 模式,也叫**急切(eager)**求值:当 signal 更新时,会立刻把通知推送给订阅者。更新 signal 会向所有订阅者派发通知。
说明:该 GIF 对应原文“push-based”里的简单节点示意,展示 signal 更新后通知如何向订阅者传播。
我特意说“通知(notification)”而不是“状态(state)”,因为在 push-pull 算法下,Signals 分发的不是状态值,而是“自身状态已变化”的通知。这两者并不相同。下一节会详细讨论“缓存失效(cache invalidation)”。先记住:在节点之间流动的那个点,代表的只是通知。
在更复杂的例子里,会有多个互相依赖的“节点”。它们都可以通知自己的订阅者:我的状态变了。
说明:该 GIF 对应原文 push 的复杂依赖图,展示单一 signal 更新后通知如何沿多分支向下传播。
到这里我们已经理解:push 模式通过“向下通知”传播。接下来要看 pull 模式如何通过“向上重算”传播。这句话到底是什么意思?
Computed:Pull-based
Signals 最重要的部分之一,可能并不是 signal 本身,而是 computed。它是响应式派生函数:基于 signals 或其他 computeds 计算出值。你可以把它理解成“没有 setter 的 signal”。
第一,signal 与 computed 的主要区别在于:computed 是惰性(lazy)的。依赖变化时,它会被标记失效(invalidated),而不是立刻更新。只有在读取它时,并且此前已失效,才会重算(这就是缓存机制)。这就是 pull-based 算法。
第二,computed 会自动追踪依赖。在执行过程中,它访问到哪些 signal/computed,就会订阅它们的变化。这是这个系统最“魔法”的地方之一,也是开发者喜欢它的原因之一:对比 React 中 useEffect / useMemo 需要手动写依赖数组,这里不需要。看一个简化实现:
const computed = <T>(fn: () => T) => {
let cachedValue: T
// 省略若干实现
const _internalCompute = (): void => {
// 省略若干实现
cachedValue = fn()
}
return {
get value() {
_internalCompute()
return cachedValue
}
}
}
这里要注意:访问 computed 对象的 value 属性会触发 _internalCompute,从而重新执行计算并更新缓存值(目前还不是真正完整缓存,后面会补上)。
const count = signal(1)
const doubleCount = computed(() => count.value * 2)
const plusOne = computed(() => doubleCount.value + 1)
// 更新 signal……
count.value = 5
console.log(doubleCount.value) // 输出 10
console.log(plusOne.value) // 输出 11
这段代码我们都很熟悉。现在看这段程序的依赖树,并关注算法中的 “pull” 部分:当我们读取 computed 的值时,重算是如何沿着树向上发生的。
说明:该 GIF 对应原文中的 count -> doubleCount -> plusOne 链式依赖图,展示读取 computed 时的向上重算过程。
我们会发现:被读取的 computed 并不知道整棵树。它只知道自己的源(dependencies)和订阅者(dependents)。
把它放到更复杂的依赖树中也是一样。尤其当某个 computed 同时依赖多个源时(例如树的最下层节点),这个机制同样成立。
说明:该 GIF 对应原文 pull 的复杂依赖图,展示含多依赖节点时的失效与重算路径。
此时还有两个关键问题:
computed如何处理“源与自身之间的连接”(自动依赖追踪)?- 缓存系统如何工作,才能只在必要时重算?
魔法连接(The magic link)
signals 与 computeds 之间的连接有点“魔法感”。如前所述,不需要像 React 那样显式声明 computed 对 signal 的依赖(以及那该死的依赖数组)。系统会自动跟踪:computed 执行时访问了哪些 signals。下面来拆解它。
回到前面的例子:count、doubleCount、plusOne。
const count = signal(1)
const doubleCount = computed(() => count.value * 2)
const plusOne = computed(() => doubleCount.value + 1)
// 更新 signal……
count.value = 5
console.log(doubleCount.value) // 输出 10
console.log(plusOne.value) // 输出 11
请把这段程序放在脑中。要理解自动追踪机制,最好的方式是把一个 Signal 库的实现细看一遍。
流程从 count 的更新开始:
count.value = 5
调用 count 的 value setter 时,它会更新 signal 内部值并通知所有订阅者。但此时它还没有任何可通知对象。因为虽然 computeds 已创建,但 signal 与 computed 之间的链接尚未建立。
console.log(doubleCount.value)
现在轮到 computed 登场:在 console.log 中触发了 doubleCount.value。
程序会进入 computed 工厂返回对象的 getter,内部会发生多件事。我们先直接看 dirty 标志。
getter 会检查内部 dirty 标志。它是一个缓存标记,表示:“如果我脏了,就需要重算。”
默认 dirty = true,因为第一次访问这个 computed 必须先算值。所以会执行 _internalCompute。
在这个函数内部,我们会往全局 STACK 里压入信息,表示:“当前正在执行这个 computed 的是我。”
压入两类能力:
- 把这个 computed 重新标脏(
setDirty),并继续把其订阅者一并标脏。 - 为这个 computed 注册源依赖(
addSource)。
STACK 是一个全局数组,用于跟踪当前正在运行的 computed。它对应用内所有 signals 和 computeds 全局可见。可以把它想象成一块“临时数据区”:任何 signal/computed 在被读取时都可以访问这块数据。
(当然,实现细节上也可以用其他数据结构。)
随后会调用传给 computed 的 fn,并把结果保存到 cachedValue。这就是该 computed 的计算函数。按这段程序,它会得到 count.value * 2,也就是 10。
但关键点来了:执行 fn 时读取了 count.value,它是一个 signal。进入该 getter:
在这个 signal getter 里,我们可以访问全局 STACK 中刚刚压入的数据,并据此建立 doubleCount 与 count 的魔法连接(也就是 computed 与 signal 的连接)。
因为 STACK 里有临时数据,signal 就能知道:
“既然 STACK 里有数据,就说明把数据压进去的那个 computed 依赖我。我应该把‘让它缓存失效’的函数加入自己的订阅列表。”
这样,下次 count.value 更新时,它就会通知订阅者,把 doubleCount 再次设为 dirty = true。缓存系统就建立起来了。🎉
此外,computed 还压入了 addSource:它会把这个 signal 注册为当前 computed 的 source,并保存一个 cleanup 函数。等 computed 未来重算时,可用这个 cleanup 把该 source 上的 setDirty 订阅移除。
这意味着:若未来重算时,这个 computed 不再依赖 count 而改依赖其他 signal,它可以清理旧连接,再建立新连接。
而且,每次开始重算 computed 时都会执行全部 source 的清理。这保证我们不会残留“失效订阅”,并且依赖图始终是最新状态。
回到 _internalCompute 尾部:值算完后,会把 dirty 置为 false,表示缓存值是最新的。
最后,把这次运行写入 STACK 的临时数据弹出,表示:“我已经不在运行了。”
console.log(plusOne.value)
那依赖 doubleCount 的 plusOne 呢?读取其 getter 后会发生与 doubleCount 类似的同样流程。
computed getter 与 signal getter 一样,也会检查 STACK 是否存在临时数据。若它被标脏,就重算,然后返回缓存值。
type Sub<T> = (s: T) => void
type ComputeContext = {
setDirty: () => void
addSource: (cleanup: () => void) => void
}
const STACK: Array<ComputeContext> = []
export const signal = <T>(
initial: T
): {
value: T
suscribe: (fn: Sub<T>) => () => void
} => {
let value: T = initial
const subs = new Set<Sub<T>>()
return {
get value(): T {
const currentComputed = STACK[STACK.length - 1]
if (currentComputed) {
subs.add(currentComputed.setDirty)
currentComputed.addSource(() => {
subs.delete(currentComputed.setDirty)
})
}
return value
},
set value(v: T) {
if (value === v) return
value = v
for (const fn of Array.from(subs)) fn(v)
},
suscribe(fn: Sub<T>) {
subs.add(fn)
return () => subs.delete(fn)
}
}
}
export const computed = <T>(fn: () => T) => {
const subs = new Set<ComputeContext>()
const sources = new Set<() => void>()
let cachedValue: T
let dirty = true
const _internalCompute = (): void => {
sources.forEach((cleanup) => cleanup())
sources.clear()
STACK.push({
setDirty: () => {
if (dirty) return
dirty = true
for (const sub of Array.from(subs)) sub.setDirty()
},
addSource: (unsubscribe) => sources.add(unsubscribe)
})
cachedValue = fn()
dirty = false
STACK.pop()
}
return {
get value() {
const currentComputed = STACK[STACK.length - 1]
if (currentComputed) {
subs.add(currentComputed)
currentComputed.addSource(() => {
subs.delete(currentComputed)
})
}
if (dirty) _internalCompute()
return cachedValue
}
}
}
到这里我们已经拆解了自动依赖追踪系统:通过全局 STACK,让当前执行中的 computed 能与其访问到的 signals/computeds 建立通信关系。
我们也看到缓存系统如何在 pull 模式下工作:通过 dirty 标志判断 computed 是否失效。
最终流程(Final flow)
结合 push 与 pull 两种机制后,signals 的完整流程就成立了。你可以点击 signal 或 computed,观察依赖树中节点如何先失效再重算。
说明:上图为原文“Final flow”交互模块的 GIF 录制版,用于在 Markdown 阅读场景中保留动态演示效果。原文交互版见:Signals, the push-pull based algorithm。
注意:所有
setDirty调用都是同步的;点经过每个节点时,该节点会立即失效。演示中的延迟只是为了可视化。
就是这样。我们已经完整看清 Signals 核心的 push-pull 算法。我这里不展开讲,但需要补充:多数 signal 库会在同一套追踪机制之上再提供 effect 函数,不过那更偏 API 设计层面。
结语(Conclusion)
本文聚焦的是算法本身。Signals 有趣之处不只是“它能更新 UI”,而是它如何在响应式图中传播变化:
- push:急切地传播失效通知;
- pull:仅在必要时惰性重算。
这组组合带来了细粒度响应式(fine-grained reactivity)。Solid、Vue、Preact、Angular、Svelte 等框架都在使用这一思想。它们 API 各异,但底层逻辑相通。
关于 Signals 的资料已经很多,也确实帮我理解了这个主题;但我没看到哪篇从“零实现 push-pull 算法”角度讲得足够深入。为了真正吃透,我实现了一个自己的 Signal 系统版本(原文里该链接也出现为 github.com/willybraune…)。它当然比不上 alien-signals、preact-signals、solidjs-signals 那样成熟,但已足够帮助理解核心概念。
signal-playground:一个用于实验 signals/computed/effects 的 TypeScript playground
另外,也许“很快”(也可能没那么快)我们就不必手动实现这套模型了:它正在朝 JavaScript 原生标准推进,即 TC39 proposal-signals(目前 Stage 1)。如果落地,这会是整个 JavaScript 生态的重要进展:框架可共享统一基础,同时保留各自 API 设计自由。
我很享受写这篇文章和制作交互模块的过程。如果你学到了新东西或读得开心,欢迎支持我的创作 ☕️,也欢迎在 Bluesky 或 LinkedIn 找我交流。👋
参考资料
我非常推荐你听这期播客,它对我深入理解这个主题帮助很大:How signals work,Con Tejas Code with Kristen Maevyn and Daniel Ehrenberg。
文章
- 《Reactivity》,作者 Milo Mighdoll
- 《Introducing Signals Preact》,作者 Marvin Hagemeister 与 Jason Miller
- 《Signal Boosting》,作者 Joachim Viide
- 《The evolution of signals in JavaScript》,作者 Ryan Carniato
- 《State-based vs Signal-based rendering》,作者 Jovi De Croock
- 《Push-pull functional reactive programming》,作者 Conal Elliott
视频与播客
- 《超越 Signals》,Ryan Carniato
- 《Signals 如何工作》,Con Tejas Code(嘉宾 Kristen Maevyn、Daniel Ehrenberg)
- 《控制时间与空间:理解 FRP 的多种形式》,Evan Czaplicki
库
术语表(本篇命中)
| term_en | term_zh | 说明 |
|---|---|---|
| signal | 信号(signal) | 可读写的响应式状态单元 |
| computed | 计算属性(computed) | 基于依赖懒计算并缓存的派生值 |
| push-based | 推送式(push) | 变化发生时立即向下游发送失效通知 |
| pull-based | 拉取式(pull) | 在读取时按需触发重算 |
| cache invalidation | 缓存失效 | 依赖变更时将缓存标记为 dirty |
| dependency tracking | 依赖追踪 | 自动记录计算中访问到的源依赖 |
| fine-grained reactivity | 细粒度响应式 | 仅重算受影响节点,减少无关更新 |