快要被 createMemo 搞疯了


以前一直以为 SolidJS 的createMemo是和 Vue 的computed一样的东西,结果今天发现不是!

SolidJS 的 createMemo是 eagerly evaluating 的,也就是说,它是和createEffect一类,创建即调用的。它文档里说:

They are similar to derived signals in that they are reactive values that automatically re-evaluate when their dependencies change.

明明是用来替代 derived signals 的啊!这样看明明更像 effect 而不是 derived

而且一旦有 dependency 变动了,它是立即 evaluate 然后来根据 equal 与否来判断是否 invalidate 的,而不是按需来。按需来的叫createLazyMemocreateLazyMemo - @solid-primitives/memo - Solid Primitives


为什么看这个,是因为我最近在处理 hmr 库的一些奇奇怪怪的 bug。其中就在create_memo这边没转过来:

  1. 如果它是 lazily evaluate 的,那为了让各种 effect 的值保持最新,它就必须得是 eagerly invalidate 的(这也是我之前的实现)这会导致它比起普通的 signal 就丢失了在 equal 时不 notify 的能力
  2. 如果它是 eagerly evaluate 的(就像 SolidJS 这样),它确实就可以 lazily invalidate 了,因为它可以先比较是否 equal 再 notify 了,不会立马 invalidate,但这样会导致它可能会有一些不必须的 evaluate(比如可能它的 subscriber 正好已经不需要用到它了,即使这些 subscribers 重新执行了,也不会再 observe 这个 signal)

然后就觉得其实这东西是不是没有完美的解法?这是一个权衡:

  1. Effect 这边不可能不重新执行就知道是否还需要某个 Signal
  2. 一个 Derived 在被 trigger 时不可能不重新运行就知道它的结果会不会 equal

一个办法是,可以像createMemo那样,当被 trigger 后就立即 recompute,但是第一次 compute 推迟到第一次被调用时。这样相当于autorun=Falsecreate_effect+ cache 而已。或者说这是我的底线,因为如果 API 是这样实现的,用户也可以在创建了这个 Memoized 之后立即 call 它,来达到和 SolidJS 的版本一样的效果,但是反过来不行。

所以其实可以暴露一个参数,类似

optimization: "lazy-recompute" | "lazy-invalidate" | False

其中 lazy-recompute 代表之前的行为,lazy-invalidate 是上面说的这种,False 代表不检查


刚刚想了下,其实 SolidJS 的 createMemo 等效于:

class _:
    var = State(0)
    derived = State(0)

    def __init__(self):
        @create_effect
        def _():
            self.derived = self.var * 2  # 假设就是乘二的关系
        # 但是好像需要保存一个 这个 effect 的引用,不然会被 gc 掉,但这里就不展示这些细节了

而我上面提到的中间形态等效于:

class _:
    var = State(0)
    _derived = State(0)

    @cache  # 保证只运行一次,而且只在第一次需要时调用
    def ensure_effect(self):
        @create_effect
        def _():
            self._derived = self.var * 2
    
    @property
    def derived(self):
        self.ensure_effect()
        return self._derived

而现在是这样的 API:

class _:
    var = State(0)

    @memoized_property
    def derived(self):
        return self.var * 2

未来允许给这个装饰器加参数,比如memoized_property(optimization="lazy-invalidate")获得上面第二种的效果。


其实刚刚忘说了,Svelte 5 的反应式原语好像略有不同,我略微探索了一下(顺带一提,顺便不小心发现了他们一个 bug:$derived unexpectedly reevaluate · Issue #15414 · sveltejs/svelte),观察到:

  1. 如果某个$derived没有至少一个$effect用到它,它就不会被执行,这说明它是 lazy-evaluate 的
  2. 虽然$effect第一次执行时,是从浅到深调用$derived们的,但是每次更新最底层的$derived先调用,再往外调用的,这说明至少最外层的$effect不是 eagerly-trigger 的

所以我怀疑它是一个 2-pass 的机制,当一个 Signal notify 时,它 invalidate 了某个$derived(相当于我这里的 memo),然后一层一层向上 invalidate,但是 invalidate 并不会立即删掉原来的 cache 的值,而只是把自己 mark 为 stale 状态(或者叫 dirty 吧),这样冒泡到最浅层,这就是“正向传播”

  1. 到达最外层的时候,如果是 memo,就说明这个 memo 没被用上。(可以形象地理解为一个$effect就是在最外层“拉”一个反应性的变量的,如果没有人“拉”,那它就不会被求值。
  2. 如果是个 effect,那这个 effect 并不会立即执行,而是会看它到底被谁 trigger 了。是全是 memo 还是也有 signal。只有被 signal 触发的才说明必须要 invalidate。否则就逐个排查每个 memo,看这些 memo 是否改变了值(相当于看每个 memo 作为一个 signal 的性质),如果改变了,那这个 effect 就得 reevaluate 了

这就是 effect 的“反向传播”。那如何知道某个 memo 是否改变了呢?

  1. 如果 notify 它的 dependencies 中有 signal,那它就得 recompute,然后比较值是否改变了
  2. 如果 notify 它的 dependencies 都是 memo,那就得先看这些 memo 有没有 change 了

这就是 memo 的反向传播

(写到这里我意识到,可能叫这些 memo 为 derived 更合适一些。今后我实现的时候会注意)

这么看来,effect 具有一个反向传播性质,这个确实我还没 implement,而且似乎 SolidJS 之类的也没这样的机制。我之前参考的更多是我见识到的 SolidJS 的一小部分,所以设计还很不成熟。

这么看,其实反应式编程有三种角色:

  1. Signal,具有 track 跟踪调用者(自动绑定 subscribers)、notify(正向传播)的功能
  2. Effect,具有 invalidate(被 notify 时调用)、check/determine(还没想好叫什么名字,总之就是invalidate后会调用,反向传播)的功能
  3. Derived,具有以上二者的性质,Effect 是正向传播的终点,而 Signal 其实是反向传播的终点

这么看这和神经网络太像了!这不就是输入层、输出层和中间层嘛!!


原谅我写文章总是这么意识流。今后有机会肯定会写篇系统的文章跟大家讲讲的!现在如果有人对反应式编程感兴趣的话,我还是会推荐 Ta 去读读 Vue 的这篇文章:

Reactivity in Depth - Vue.js 的前三小节:

  • What is Reactivity?
  • How Reactivity Works? (原标题是 How Reactivity Works in Vue,但其实讲的是 in JavaScript 而非局限于 Vue)
  • Runtime vs. Compile-time Reactivity

这篇文章讲的真的很中肯,提到了反应式编程的鼻祖,也讲到了新潮,而且说到了 why 和 why not,看得出来每个设计都是非常深思熟虑的结果。