【翻译】Signals:基于 Push-Pull 的算法

1 阅读13分钟

Signals:基于 Push-Pull 的算法

文章头图

这些年我们已经在生产环境里通过 Solid、Vue 等现代前端框架长期使用 Signals,但真正能把其内部工作机制讲清楚的人并不多。我想深入研究它,尤其是深入到 push-pull 这一核心算法,也就是它们响应式能力背后的关键机制。这个主题非常迷人。

世界的状态

把一个应用想象成一个世界:我们在其中描述一组支配它的规则。一旦某条规则被定义,程序就不能再随意更改这条规则本身。

例如,我们决定在这个世界里,任意 y 都必须等于 2 * x。从这一刻起,无论 x 如何变化,y 都会自动调整。我们可以定义任意多条规则,规则之间还可以互相依赖,比如再定义 z 必须等于 y + 1,以此类推。

接着按下播放键,程序启动,世界开始运行,我们定义的规则也开始在时间中生效(可以把它理解为运行时)。

table.gif

说明:该 GIF 对应原文 “x/y/z” 状态表模块,展示当规则运行后各变量如何保持联动关系。

然后我们只需要观察:修改 xyz 就会自动调整以满足已建立的规则。这很像电子表格:依赖单元格会在源数据变化后自动更新。换句话说,派生值会对其依赖的变化产生响应

这些派生值表现得像纯函数:没有副作用,也没有可变状态。在下一个例子中,time 是持续变化的源,而 rotation 是从它派生出来的值。方块只是反映了这个一次声明的变换结果。

derived.gif

说明:该 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 会向所有订阅者派发通知。

push-simple.gif

说明:该 GIF 对应原文“push-based”里的简单节点示意,展示 signal 更新后通知如何向订阅者传播。

我特意说“通知(notification)”而不是“状态(state)”,因为在 push-pull 算法下,Signals 分发的不是状态值,而是“自身状态已变化”的通知。这两者并不相同。下一节会详细讨论“缓存失效(cache invalidation)”。先记住:在节点之间流动的那个点,代表的只是通知。

在更复杂的例子里,会有多个互相依赖的“节点”。它们都可以通知自己的订阅者:我的状态变了。

push-complex.gif

说明:该 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 的值时,重算是如何沿着树向上发生的。

pull-chain.gif

说明:该 GIF 对应原文中的 count -> doubleCount -> plusOne 链式依赖图,展示读取 computed 时的向上重算过程。

我们会发现:被读取的 computed 并不知道整棵树。它只知道自己的源(dependencies)和订阅者(dependents)。

把它放到更复杂的依赖树中也是一样。尤其当某个 computed 同时依赖多个源时(例如树的最下层节点),这个机制同样成立。

pull-complex.gif

说明:该 GIF 对应原文 pull 的复杂依赖图,展示含多依赖节点时的失效与重算路径。

此时还有两个关键问题:

  • computed 如何处理“源与自身之间的连接”(自动依赖追踪)?
  • 缓存系统如何工作,才能只在必要时重算?

魔法连接(The magic link)

signals 与 computeds 之间的连接有点“魔法感”。如前所述,不需要像 React 那样显式声明 computed 对 signal 的依赖(以及那该死的依赖数组)。系统会自动跟踪:computed 执行时访问了哪些 signals。下面来拆解它。

回到前面的例子:countdoubleCountplusOne

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

调用 countvalue 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 在被读取时都可以访问这块数据。

(当然,实现细节上也可以用其他数据结构。)

随后会调用传给 computedfn,并把结果保存到 cachedValue。这就是该 computed 的计算函数。按这段程序,它会得到 count.value * 2,也就是 10

但关键点来了:执行 fn 时读取了 count.value,它是一个 signal。进入该 getter:

在这个 signal getter 里,我们可以访问全局 STACK 中刚刚压入的数据,并据此建立 doubleCountcount魔法连接(也就是 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)

那依赖 doubleCountplusOne 呢?读取其 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,观察依赖树中节点如何先失效再重算。

push-pull-final.gif

说明:上图为原文“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-signalspreact-signalssolidjs-signals 那样成熟,但已足够帮助理解核心概念。

signal-playground:一个用于实验 signals/computed/effects 的 TypeScript playground

另外,也许“很快”(也可能没那么快)我们就不必手动实现这套模型了:它正在朝 JavaScript 原生标准推进,即 TC39 proposal-signals(目前 Stage 1)。如果落地,这会是整个 JavaScript 生态的重要进展:框架可共享统一基础,同时保留各自 API 设计自由。

我很享受写这篇文章和制作交互模块的过程。如果你学到了新东西或读得开心,欢迎支持我的创作 ☕️,也欢迎在 BlueskyLinkedIn 找我交流。👋

参考资料

我非常推荐你听这期播客,它对我深入理解这个主题帮助很大:How signals work,Con Tejas Code with Kristen Maevyn and Daniel Ehrenberg。

文章

视频与播客

术语表(本篇命中)

term_enterm_zh说明
signal信号(signal)可读写的响应式状态单元
computed计算属性(computed)基于依赖懒计算并缓存的派生值
push-based推送式(push)变化发生时立即向下游发送失效通知
pull-based拉取式(pull)在读取时按需触发重算
cache invalidation缓存失效依赖变更时将缓存标记为 dirty
dependency tracking依赖追踪自动记录计算中访问到的源依赖
fine-grained reactivity细粒度响应式仅重算受影响节点,减少无关更新