基于推拉的信号算法
Signals, the push-pull based algorithm

原始链接: https://willybrauner.com/journal/signal-the-push-pull-based-algorithm

## 信号:深入了解响应式编程 信号是一种现代的响应式编程方法,在Solid、Vue等前端框架中越来越受欢迎,但往往缺乏清晰的内部理解。 它们的核心是管理状态,并在状态改变时自动更新依赖值——就像一个电子表格,单元格会根据公式自动更新。 这种响应性由一种“推拉”算法驱动。**基于推的信号**在值改变时通知订阅者(不共享新的状态本身)。**基于拉的“计算值”**是惰性的;它们只有在被读取*并且*依赖项发生变化时才会重新计算。 重要的是,计算值会自动跟踪这些依赖项,无需手动指定——这是优于传统方法的关键优势。 这种依赖跟踪利用一个全局堆栈将计算与它们访问的信号关联起来。 带有“脏”标志的缓存系统确保仅在必要时才重新评估,从而优化性能。 推用于失效和拉用于重新评估的结合,创造了一种细粒度、高效的响应式系统。 标准化工作正在进行中,旨在将信号原生集成到JavaScript中(TC39 proposal-signals),可能为未来的框架提供一个共同的基础。 这种方法侧重于*如何*传播变化,为现有的状态管理解决方案提供了一种强大的替代方案。

黑客新闻 新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 Signals,基于推拉算法 (willybrauner.com) 3点 由 mpweiher 1小时前 | 隐藏 | 过去 | 收藏 | 讨论 帮助 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请YC | 联系 搜索:
相关文章

原文

We have been using Signals in production for years via several modern front-end frameworks like Solid, Vue, and others, but few of us are able to explain how they work internally. I wanted to dig into it, especially diving deep into the push-pull based algorithm, the core mechanism behind their reactivity. The subject is fascinating.

The state of the world

Imagine an application as a world where we describe the set of rules that govern it. Once a rule is defined, our program will no longer be able to change it.

For example, we decide that in our world, any y value must be equal to 2 * x. We define this rule, and from then on, whenever x changes, y will automatically adjust. We can define as many rules as we want. They can even depend on each other by deciding that z must be equal to y + 1, and so on.

Now we press the play button, our program starts, the world is running, and the rules we have defined are now in effect over time. (think of it as our runtime).

And then, we just have to observe. We can modify x and see how y and z automatically adjust to comply with the rules we have established. It's like a spreadsheet where dependent cells automatically update when their sources change. In other words, derived values are reactive to changes in their dependencies.

These derived values behave like pure functions: no side effects, no mutable state. In the next example, time is a source that changes continuously while rotation is derived from it. The square simply reflects the result of this transformation that is declared once.

This "reactive world" didn't come out of nowhere. The idea emerged in the 1970s and was formalized as Reactive Programming, a paradigm that describes systems where changes in data sources automatically propagate through a graph of dependent computations, which is exactly what Signals do.

Signals are thus heirs to the Reactive Programming paradigm, whose first JavaScript implementations came with libraries like Knockout.js (2010) and then RxJS (2012), which brought reactive ideas to the browser.

Now that we have more context on what Signals are, let's dive into the push-pull based algorithm that is at the core of this system.

Signals: Push-based

A Signal is an abstraction that represents a reactive value that can be read and modified. When a signal changes, all parts of the application that depend on this signal are automatically updated. I went through the exercise of implementing a very basic version:

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)
    }
  }
}

We can imagine Signals as the starting point of the rules of our world, the primitive entry points of targeted mutations.

My first thought was "Ok, it's just a simple publish–subscriber pattern with a getter and a setter." The Signal itself works like that, except the function keeps a reference to the current state that can be read and modified. If you have ever used an event emitter, this pattern will seem familiar to you:

const count = signal(0)

// Somewhere in the application
count.subscribe((newValue) => {
  console.log("Count changed to:", newValue)
})

// Anytime and anywhere in the application
count.value += 1
// "Count changed to: 1"

This is what we call the push approach, also known as eager evaluation. A notification is immediately pushed to its subscribers when the signal is updated. Updating the signal dispatches a notification to all its subscribers.

I deliberately use the term "notification" and not "state" because Signals, using the push-pull based algorithm, don't dispatch a state value; they notify that their own state has changed; this is not the same. We will talk about cache invalidation in detail in the next section. Keep in mind that the dot moving between "nodes" is only a notification. (In the next modules, you can click the Signal to see the notifications being dispatched to its subscribers).

In this more complex example, we have multiple "nodes" that depend on each other. All of them can notify their own subscribers that their state has changed.

At this point, we understand that the push-based approach propagates downward through notifications, and now we have to explore how the pull-based approach propagates upward through re-evaluation. What does that mean?

Computed: Pull-based

One of the most important aspects of Signals may not be the signal function itself, but the computed. They are reactive derived functions that compute values based on signals or computeds. We can imagine them as signals without a setter.

First, the main difference between signals and computeds is that computeds are lazy. They are invalidated (not updated) whenever one of their dependencies changes. Furthermore, they are updated only when they are read, if they have been invalidated first (our cache system). This is what we call the pull-based algorithm.

Secondly, computeds automatically track their dependencies. They subscribe to changes in the signals/computeds they access during their execution. It's one of the most "magical" aspects of this system that developers love, compared to React where we have to manually specify the dependencies of a useEffect or useMemo with the dependency array. Let's see how we can implement a simple version:

const computed = <T>(fn: () => T) => {
  let cachedValue: T
  // ...
  const _internalCompute = (): void => {
    // ...
    cachedValue = fn()
  }
  return {
    get value() {
      _internalCompute()
      return cachedValue
    }
  }
}

The thing to note here is that accessing the value property of the computed object triggers the _internalCompute function, which re-evaluates the computation and updates the cached value (not actually cached for now, but we will address this later).

const count = signal(1)
const doubleCount = computed(() => count.value * 2)
const plusOne = computed(() => doubleCount.value + 1)

// Update the signal…
count.value = 5

console.log(doubleCount.value) // 10
console.log(plusOne.value) // 11

We know this code, right? Now, look at the dependency tree of this program and focus on the "pull" aspect of the algorithm. You can click the computed to see how the dot moves up the tree when we read its value.

We can observe that the computed being read has no knowledge of the entire tree. It only knows what its sources (dependencies) are and what its subscribers (dependents) are.

Check the same module with a more complex dependency tree. We can see what happens when a computed function has multiple dependencies at the same time (this is the case for the lowest node in the tree):

Some questions remain about the implementation of this system at this point, and this is where Signals become more complex and interesting:

  • How does the computed function process the link between its sources and itself? (the auto-tracking of dependencies)
  • How does the cache system work, allowing re-evaluation of the computation only when necessary?

The magic link

The link between signals and computeds is somewhat magical. As mentioned before, no need to explicitly declare the dependencies of a computed value on signals, as we do in React (with the damned dependency array). The system automatically tracks which signals are accessed during the execution of the computed function. This is what we will discover in this section.

Back to our previous example with the count signal, doubleCount and plusOne computeds.

const count = signal(1)
const doubleCount = computed(() => count.value * 2)
const plusOne = computed(() => doubleCount.value + 1)

// Update the signal…
count.value = 5

console.log(doubleCount.value) // 10
console.log(plusOne.value) // 11

Keep this program in mind. To understand the mechanism of the auto-tracking, the best way is to look at the implementation of a Signal library in detail:

We have demystified the auto-tracking dependency system, using the global STACK, which enables communication between the currently executing computed and the signals/computeds it accesses during its execution.

We have also seen how the cache system works in the pull-based algorithm, using the dirty flag to know when a computed is invalidated.

Final flow

As described above, the final flow of signals is now possible by combining both push and pull mechanisms! You can click the signal or a computed to observe the invalidation and re-evaluation of nodes in the tree. Let's play with it!

Note that all setDirty calls are synchronous; each node is invalidated when the dot passes through it. The delay is purely for visual purposes.

And that's it! We now have a complete picture of the push-pull algorithm at the core of Signals. I will not cover it here, but I still need to mention that most signal libraries also expose an effect function on top of the same tracking mechanism, but that belongs more to API design than to the algorithm itself.

Conclusion

The article focused on the algorithm, so what makes Signals interesting is not just that they update some UI, but how they propagate change through a reactive graph:

  • push for eagerly propagating invalidation;
  • pull for lazily re-evaluating only when necessary.

This combination gives us a fine-grained reactivity system already adopted by many frameworks like Solid, Vue, Preact, Angular, Svelte, and others. Each comes with its own API surface, but shares the same underlying logic.

The Signals topic has already been covered in a large number of publications that greatly helped me understand the subject, but none that I found offered an in-depth analysis of implementing the push-pull based algorithm from scratch. To explore this subject in depth, I implemented my own version of the Signal system, certainly very naive compared to the great alien-signals, preact-signals or solidjs-signals, but functional enough to understand the concept.

Note that we may "soon" (maybe?) no longer need to implement this system manually, as this model is being standardized natively in JavaScript: TC39 proposal-signals (currently at Stage 1). This would be a major advancement for the entire JavaScript ecosystem, as it would allow each framework to rely on a common foundation, while retaining the freedom to choose the API that best suits them.

I greatly enjoyed writing this article and building interactive modules for it. If you learned something new or enjoyed reading it, consider supporting my work ☕️ or feel free to connect with me on Bluesky or LinkedIn 👋

Sources

I highly recommend taking the time to listen to this podcast episode, which helped me dive deep into this subject: How signals work by Con Tejas Code w/ Kristen Maevyn and Daniel Ehrenberg

Articles

Videos & podcasts

Libraries

联系我们 contact @ memedata.com