🗂️Solid 之旅 —— Signal 响应式原理
00 分钟
2024-8-16
2024-8-21
type
status
date
slug
summary
tags
category
icon
password
Edited
Aug 21, 2024 04:31 PM
Created
Aug 16, 2024 01:23 AM
在本文中,我将通过三个案例来深入到 Solid 源码中解析 Signal 响应式的原理,并通过图解的方式更清晰的展示给大家。
在上一篇文章中,我也讲了该如何去调试 Solid 源码,这里将不再过多赘述,如果不知道的小伙伴,可以再去看一下。
 

前置

这里先提前讲一下 SignalComputationMemo 的类型,方便后续调试的时候理解其含义。
注:含 t 开头的属性(tValuetState …),都是和 Transition 相关的状态,本文暂时忽略。
 

类型

SignalState

Computation

注:先解释一下,在 Solid 内部,类似 createEffect 这种计算函数(副作用)的对象统称为 Computation
简单点说,createEffect 就是返回值为空的计算函数,只需要执行其函数就可以了;像 createMemo,就是带返回值的 Computaion
这边只是简单解释一下 Computation 的含义,后面还会再详细解释。

Memo

这里其实就能看出来,Memo 本质上就是 SingalComputation 的结合体。
 

全局变量

 
现在大概了解一下,有哪些属性即可,后面调试的时候,可以对照着看,有个印象就行。
接下来,我将直接展示案例,并进入到 Solid 源码的世界里。
 

案例一(Signal & Effect

第一个案例,我们先以最简单的 createSignalcreateEffect 来演示。
先了解其 依赖收集 的方式和 响应式原理
signal-1.js
 
接下来,我们将从 createSignal 开始,一步步向内部走。
注:下面展示的源码会删除一些无关的代码。
createSignal
可以看到,其实 createSignal 内部做的事情很简单:
  • 创建一个 Signal 对象
  • 返回一个封装好的 [getter, setter]
对于 Signal,我们暂时先关注三个属性:
  • value: 状态值
  • observers: 观察该 SignalComputation
  • observerSlots: 这是对应 observers effect sources 对应自身的下标位置
readSignalwriteSignal 先不着急去看,我们先按案例的流程走,后续再回来看这两个函数。
 
接下来,我们再来看看 createEffect 内部做了些什么。
createEffect
createSignal 类似,createEffect 内部做了两件事:
  • 创建一个 Computation 对象(createComputation
  • 更新 Computation,执行一次 fn,进行初始化(createComputation
 
接着看一下 createComputation
createComputation
删除了写无用代码,可以看出来简单明了,一个 Computation 的工厂函数。
属性的含义,可以看一下前面 前置 内容里对 Computation 所描述的解释。
我们暂时只需要关注四个属性:
  • fn: 计算函数(副作用函数)
  • state: 当前状态(UNSET(0)STALE(1)PENDING(2)
  • sources: 依赖收集的 Signal
  • sourceSlots: 对应 sourcesSignalobservers 对应自身的位置
sources sourceSlots 后续会详细讲解,这里先了解一下即可。
 
接下来,再回到 createEffect 里看下一个函数:updateComputation
updateComputation
cleanNode 是清除 Computation 依赖关系和其本身状态的,到后面再讲,会清晰一点。
接着往下,再来看看 runComputation
runComputation
Listener 的含义可以看一下全局变量那一块的内容。
这里注意一下,Listener 绑定了当前正在执行的 Computation
然后,我们再看看 fn 的执行。
computation1.fn
里面调用了 signal1(),而 signal1() 本质上,就是调用了 readSignal

readSignal

readSignal
这里可以看到,进行依赖收集了,当前执行的 ComputationListener)和 Signal 相互进行了收集,最后再返回当前 Signal 值。
我们主要看一下依赖收集的逻辑。

依赖收集解析

防止小伙伴忘了这四个属性是啥意思,我这里再声明一下:
  • Signal
    • observers: 观察该 SignalComputation
    • observerSlots: 这是对应 observers Computationsources 对应自身的下标位置
      • (observers[i] as Computation).sources[observerSlots[]i] -> 本身
  • Computation
    • sources: 依赖收集的 Signal
    • sourceSlots: 对应 sourcesSignalobservers 对应自身的位置
      • (sources[i] as Signal).observers[sourceSlots] -> 本身
 
暂时先回到刚刚的案例来看,这是第一次进行依赖收集,所以 Signal 和 Computation 都会走 true 的逻辑,即:
Listener 为例,其 sources 收集了该 Signal,而对应在 sourceSlots 里的位置怎么得来呢?
按照刚刚说明的方法,对应 sourcesSignalobservers 对应自身的位置。
也就是 signal.observers.length,因为是新增的嘛,所以 signal.observers 的长度就是该 Listener 的下标位置,而 signal.observers 不存在时,也就是 0,也就是从空到下标为0的过程。
再来看看 Signal,也是同理,先在 observers 里添加当前的 ComputationListener),也就是 [Listener],可以看出来就是存储在下标为0的位置,与 Listener.sourceSlots 相对应。
observerSlots 也是一样的,对应 observers Computationsources 对应自身的下标位置。
也就是下标0,在上面 Listener.sources 中已经存储了。
可能看文字还是有点云里雾里的,我再放一张图来展示一下其中的关系:
notion image
现在可能就清晰明了一点了。

sources & sourceSlots & observers & observerSlots 四者的关系

我们再来看一个复杂点的依赖关系的案例。
简单赘述一下,computation1 依赖了 signal1signal2computation2 依赖了 signal1
大家可以先想想这四个对象之间的依赖关系,我下面直接给出图例解释:
SignalComputation
Signal 示意图
Signal 示意图
Computation Signal
Computation 示意图
Computation 示意图
现在是不是就清晰很多了呢,如果还是有点懵的话,再回头看看关于四个属性的解释吧。
 
我们现在再回到 readSignal 里,该函数只干了两件事:
  • 如果是在被 Computation 包裹时,进行依赖收集
  • 返回 Signal
 
现在,读的部分已经了解完了,再开始写的部分之前,我们把之前遗漏的一个函数讲一下: cleanNode
cleanNode
这个函数的作用就是重置 Computation 的内部关系、状态等内容。
这里面就用到了上面所讲到的四元素:sources sourceSlots observers observerSlots
利用 sourcesourceSlots 分别找到了
  • 所依赖的 Signal
  • Signal 中该 node 在对应 observerSlots 中的下标位置(也就能找到在 observers 中对应的 node 了)
忘记这之前关系的小伙伴,再来重温一下这张图。
Computation 示意图
Computation 示意图
找到这两个位置之后,再利用 Signal.observers 去顶替这一个位置,就成功的把两者之间的依赖关系给消除了。
如果 observers 长度只有一,那么什么都不用做,因为这一个 observer 就是需要清除的关系,相当于直接清空了。
注:如果这四元素的排放位置和自己想象的不一样,没关系,因为 cleanNode 的原因,会导致每次更新之后,在数组中的位置都会发生改变,主要是了解其中的对应关系。
 

writeSignal

接下来,来看看写(更新)的这一块逻辑吧。
和前面大部分都是相似的,只是多了些更新逻辑相关的内容。
先来看看 writeSignal 函数。
writeSignal
整体上也就做了两件事:
  • 更新 Signalvalue
  • 通知 observers 进行更新
而更新是放在 runUpdates 函数里的,让我们先看看它。
runUpdates
这里面用到了一个 wait 变量,同时还传入了 completeUpdates 函数里。
利用 wait 变量,来实现同一批次的更新都存放在 Effects 这个队列里,然后一起更新。
 
什么情况下,会有这种情形呢?不应该在 writeSignal 更新那里都执行了嘛?
这里包含一个还没有讲到的内容:memo;它是 ComputationSignal 的结合体。那么就会存在一种情况,Computation 函数内依赖了 memo,那么此时就形成了 Computation 的嵌套。
如果直接更新的话,那就会导致同一批次的内容,触发了两次更新。
 
我们往下看,执行了 fn 函数,即
这里就将需要更新的 observer 添加到 Effects 当中,并更新当前 observer 状态,标明需要更新。
找到需要更新的 observer 之后,我们再来看看下面的 completeUpdates 函数。
completeUpdates
这里也利用到的 wait,可以看到,如果需要 wait 的,那么不需要执行下面的内容。
其实就是 completeUpdates 只需要执行一次,所以只需要第一个没有 wait 的来走即可,后续如果还有同批次更新的话,都走 wait 即可。
到这里其实更新的流程也就走完了,后续其实就是再走一遍 Computation 的更新流程。
我们接着往下看,这里利用 runUpdates 执行了一个 runEffects 函数。
runEffects
Computation 上的 user 变量还没有标明,其实就是标明是内部还是用户创建的,然后分出来两部分,两个优先级更新,可以先暂时不用管。
后面为每一个 Computation 执行了一个 runTop 函数。
runTop
owner 这一块的内容可以先忽略,它和 createRoot 有关,暂时可以先假设 ancestors = [node]
接着往下看,利用 for 去执行了每一个 Computation,这里也用到了 STALE,只要需要更新的状态才会去走更新流程,最后就是去执行 updateComputation 了。
后续就是 Computation 更新的流程了,之前在 createEffect 的时候讲过一次。
 
源码就不放了,大概讲述一下,忘记的小伙伴可以往前再看一下 updateComputation 的流程。
现在情况简单说就是通过 runEffects 去更新每一个 Computation,然后再执行 updateComputation 去更新每一个 Computation,重新执行其副作用函数。
更新的流程差不多就是这样,其实也是很简单的。
 
我们再通过流程图整体看一下 readSignalwriteSignal 两个部分吧。
Signal 流程图
Signal 流程图
现在只是简单的展示一下执行流程,整体的执行过程将在案例二中展示。
 
最后,再回到案例看看,它的执行结果吧。
还是很简单的,通过这个案例我们也对 Solid 的响应式了解了个大概。
我们再来看看第二个案例,来完整的了解一下 Signal 响应式的流程吧。
 

案例二(Signal & Effect & Memo

这个案例中在原先的基础上添加了 Memo,来完整的了解整个响应式的流程。
Memo 的话,之前也提过一嘴,是 Signal + Computation 的结合体(也可以看看 Memo 的定义)。
我们通过案例来深入了解一下其中的原理吧。
 
createSignal 就不过多赘述了,忘了的话,看看第一个案例。
 

Memo

这个案例,我们主要以 Memo 的视角去看整体的流程,我们先来看看 createMemo
createMemo
看着是不是有点熟悉,这就是相当于 createSingalcreateEffect 结合啊,完全一模一样。
注意两个点:
  • 一个是 createComputation 传入的第三个参数,pure = true。而 createEffect 传入的是 false。
    • 这个后面更新的时候会用到,先留意一下。
  • 另一个是 createMemo 返回的是一个只读的 Signal。注意它是通过 readSignal 来读取的。
 
根据案例,我们先来看看 Memo 的依赖关系,也能更好的理解它为什么是 Signal + Computation
Memo 依赖关系
Memo 依赖关系
 
我们再回过来看看 readSignal
readSignal
这里面多了些 Memo 相关的额外处理,我们来看看。
先看看这个 (this as Memo<any>).sources && ((this as Memo<any>).state),因为 Memo 本身也是 Computation,需要它也会依赖 Signal。这段语句的意思就是 如果 Memo 存在依赖并且它的状态不是0(UNSET),说明数据脏了,需要更新。
再看看里面的,如果状态是 STALE,那么就是正常的 updateComputation 逻辑,更新一下最新的值即可。而 else 其实也就是 PENDING 状态。这个状态是由 Memo 作为 Computation 更新产生的,因为依赖此 MemoComputation 并不能直接更新,需要先等 Memo 的值更新完成后,再更新依赖此 MemoComputation
同时这里还用到了 Updates 更新队列。可以和 Effects 理解为两个更新队列。原因可以如上所述,不然会导致 Computation 更新使用了旧的 Memo 值。
 
官网也对此说明了一下:
If it is possible to use pure functions and createMemo, this is likely more efficient, as Solid optimizes the execution order of memo updates, whereas updating a signal within createComputed will immediately trigger reactive updates some of which may turn out to be unnecessary. —— createComputed - SolidDocs (solidjs.com)
Solid 专门对 Memo 做了写优化。
 
我们先看一下 runUpdates 里的 lookUpstream 方法。
lookUpstream
如果走这个函数的话,说明当前 node 的状态是 PENDING,也就意味着 node.sources 中有的状态还未更新完(STALE),需要先更新 sources 里的,同时如果 sources 里还有 Memo 的话,再递归向上,直到所有 STALE 状态更新完成。
而在 runTop 执行的时候,因为两者的依赖关系,所以待 sources 更新完成后,当前 node 也会更新为最新值。
ps: 如果忘了 PENDING 含义的,可以看一下 readSignal 那里。
ignore 参数的话,就是避免递归死循环,导致调用栈溢出了。
lookUpstream 流程图如下:
lookUpstream 流程图
lookUpstream 流程图
 
我们再会过来,重新看看 runUpdates,里面多了对 Updates 队列的处理。
runUpdates
主要就是多了个 Updates 初始化和执行,和之前 Effects 处理逻辑很类似,只需要第一个往下走就行了,其余的更新添加到 Updates 即可,最后由第一个统一继续更新。
 
再来看看 completeUpdates 的变化。
completeUpdates
这里也新增了对 Updates 队列更新的处理,我们和之前的 runUpdates 连着看,首先,Updates 相关的更新能走到这,说明 Updates 队列已经都处理好了,执行最后的 completeUpdates 了。
同时从结构上也可以看出来,Updates 的优先级是比 Effects 的高的。
一方面是因为 PENDING 的原因,另一方面也是之前官文提到的,对 Memo 的特殊优化处理。
更新流程图如下:
更新流程
更新流程
 
我们继续看看 Updates 里的东西, runQueue 是如何更新 Updates 队列的。
runQueue
只是简单的给每一个 Computation 执行 runTop,接下去就是更新 Computation 了,后面的流程都差不多,我们来看看里面对 Memo 的额外处理吧。
runTop
和之前都是一样的,就多了个对 PENDING 状态的处理。
还是一样的意思,如果当前 nodePENDING 状态,那么它所依赖的 Signal(Memo)肯定有未更新完成的,需要利用 lookUpstream 先去更新当前 node 的依赖。
runTop 流程图如下:
runTop 流程图
runTop 流程图
还是提一嘴,lookUpstream 其实也是变相的去更新 Computation 了,只是是倒过来,通过依赖更新再返回过通知当前 Computation 更新。
 
后续,就是在走 updateComputationrunComputation 函数去更新 Computation 了。
updateComputation 没变,它本质还是调用 runComputation 去更新,我们来看一下 runComputation 的变化。
runComputation
这里多了个返回值 nextValue 和对 Memo 的特殊处理。
nextValue 就是利用最新的 Signal 值再计算得出新的 Memo 值。
再来看一下对 Memo 的特殊处理:writeSignal(node as Memo<any>, nextValue, true);
其实也就是利用 writeSignal 去手动触发 Signal 的更新,走更新流程。具体流程和案例一的更新流程是一致的。
 
更新 Computation 的流程图如下:
更新 Computation 流程图
更新 Computation 流程图
 
虽然我们还在从 readSignal 的角度去看,但是更新的(writeSignal)也看的差不多了。
再看一下 writeSignal 的变化吧。
writeSignal
这里多了两个新的地方:
  • 一个是 o.pure 的判断,多了个加入 Updates 队列。
  • 另一个是对 Memo 的特殊处理,调用了一个 markDownstream 方法。
pure 属性之前在 createMemo 的时候也提到过了,可以暂时理解为区分 createEffectcreateMemo 的;另一点原因,也就是前面提到的 PENDING,因为 Memo 此时依赖的 sources 中可能存在其他还未更新的值,所以并不能直接更新,需要做特殊处理。
再往下看看这个语句:if ((o as Memo<any>).observers) markDownstream(o as Memo<any>);
因为 Memo 本身不仅仅可以作为 Computation,还可以作为 Signal,所以当前 Signal 更新的同时,内部 Memo 也要更新,而 Memo 更新,导致监听它的 observer 也要跟着更新。
 
我们去看看 markDownstream 函数。
markDownstream
这个和上面已经讲过的 lookUpstream 是类似的,之前那个是处理 sources 的,而这个是专门处理 observers 的。
里面的处理和刚刚的 writeSignal 的类似的,除了状态是 PENDING
markDownstream 流程图如下:
markDownstream 流程图
markDownstream 流程图
 
注:markDownstream 和上面提过的 lookUpstream 是专门用于处理 Memoobserverssignals 关系的。
 
在处理完成之后,就开始走 runUpdates 开始更新了,逻辑和之前都是一样的了,因为讲 readSignal 的时候已经差不多讲过更新流程了。就不再赘述了。
 
我们再回到案例看看它的输出结果吧,看看和大家想的一不一样。
这里要注意一点,Memoobservers 是先触发的,我们再从源码上看一下。
首先,在初始化完成之后 Signalobservers 应该是 [memo, computation1],而 Memoobservers[computation2]
那么在 trigger 之后,Signal 会通知其 observers 进行更新,但是呢,第一个 observerMemo,那么,就会多走一步,markDownstream,去通知 Memoobservers 去更新。所以说,这就是导致 computation2computation1 先执行的原因。
 
我们再换个位置来看看。
初始化没问题,顺序执行,更新的时候,两个 Computation 的执行也没问题,因为现在 Signalobservers 位置是 [computation1, memo]
我们来看看为什么 Memo 的执行优先级比 Effect 高吧,这时候大家可能会想起之前讲的两个队列:UpdatesEffects。对就是这两个,Memo 会加入到 Updates 队列,执行的优先级是比 Effects 要高的。
 
到这里,我们也差不多了解了 Solid 响应式,以及 Solid 的响应式过程。
这里我放一张完整的流程图。
notion image
 

案例三

我们再通过一个案例来回顾一下之前所讲的内容。
index.html
signal-dom.js
 
案例很简单,只是正常的 Solid 使用,多了些 DOM 的交互。
我们来看看它们的依赖关系:
  • signal1
    • observers:[memo1, computation1]
    • observerSlots: [0, 0]
  • signal2
    • observers:[computation1, computation2]
    • observerSlots: [1, 0]
  • memo1
    • observers:[computation3]
    • observerSlots: [0]
    • sources:[signal1]
    • sourceSlots: [0]
  • computation1
    • sources:[signal1, signal2]
    • sourceSlots: [1, 0]
  • computation2
    • sources:[signal2]
    • sourceSlots: [1]
  • computation3
    • sources:[memo1]
    • sourceSlots: [0]
 
其实把这一块内容搞清楚了,Signal 的响应式也就差不多了解了。
现在再回过头来看,我们会熟知 Solid 内部做了哪些处理,了解了其原理,定位问题也会变得简单很多。
 

总结

文中的案例地址:
 
如果了解过 Vue 响应式原理的小伙伴们,看完 Solid 的响应式原理之后,会很熟悉。
它们形上是一样的,只是实现的方式不一样。
Vue 是通过 Proxy 去实现的响应式,而 Solid 则是自己写了一套响应式。
Solid 的响应式主要就是通过这四个属性去实现的:sources & sourceSlots & observers & observerSlots
所以了解它们的原理很重要,文章中我也专门抽出了一小部分去专门讲了这块内容。
了解了这四个属性的工作方式,那么也差不多了解了 Solid 的响应式原理了。
 
最后,以 Singal 响应式的流程图作为结尾
notion image
 

参考链接:

  1. Solid Docs (solidjs.com)
  1. GitHub - solidjs/solid: A declarative, efficient, and flexible JavaScript library for building user interfaces.
 
上一篇
想法与思考 — 2024.08.28
下一篇
pnpm monorepo 联调方案

评论
Loading...