type
status
date
slug
summary
tags
category
icon
password
Edited
Sep 30, 2024 06:24 AM
Created
Aug 16, 2024 01:23 AM
在本文中,我将通过三个案例来深入到 Solid 源码中解析 Signal 响应式的原理,并通过图解的方式更清晰的展示给大家。
在上一篇文章中,我也讲了该如何去调试 Solid 源码,这里将不再过多赘述,如果不知道的小伙伴,可以再去看一下。
前置
这里先提前讲一下
Signal
、Computation
、Memo
的类型,方便后续调试的时候理解其含义。注:含t
开头的属性(tValue
、tState
…),都是和Transition
相关的状态,本文暂时忽略。
类型
SignalState
Computation
注:先解释一下,在 Solid 内部,类似createEffect
这种计算函数(副作用)的对象统称为Computation
。简单点说,createEffect
就是返回值为空的计算函数,只需要执行其函数就可以了;像createMemo
,就是带返回值的Computaion
。这边只是简单解释一下Computation
的含义,后面还会再详细解释。
Memo
这里其实就能看出来,
Memo
本质上就是 Singal
和 Computation
的结合体。全局变量
现在大概了解一下,有哪些属性即可,后面调试的时候,可以对照着看,有个印象就行。
接下来,我将直接展示案例,并进入到 Solid 源码的世界里。
案例一(Signal
& Effect
)
第一个案例,我们先以最简单的
createSignal
和 createEffect
来演示。先了解其 依赖收集 的方式和 响应式原理。
signal-1.js
接下来,我们将从
createSignal
开始,一步步向内部走。注:下面展示的源码会删除一些无关的代码。
createSignal
可以看到,其实
createSignal
内部做的事情很简单:- 创建一个
Signal
对象
- 返回一个封装好的
[getter, setter]
对于 Signal,我们暂时先关注三个属性:
value
: 状态值
observers
: 观察该Signal
的Computation
observerSlots
: 这是对应observers
中effect
里sources
对应自身的下标位置
readSignal
和 writeSignal
先不着急去看,我们先按案例的流程走,后续再回来看这两个函数。接下来,我们再来看看
createEffect
内部做了些什么。createEffect
和
createSignal
类似,createEffect
内部做了两件事:- 创建一个
Computation
对象(createComputation
)
- 更新
Computation
,执行一次fn
,进行初始化(createComputation
)
接着看一下
createComputation
createComputation
删除了写无用代码,可以看出来简单明了,一个
Computation
的工厂函数。属性的含义,可以看一下前面 前置 内容里对
Computation
所描述的解释。我们暂时只需要关注四个属性:
fn
: 计算函数(副作用函数)
state
: 当前状态(UNSET(0)
、STALE(1)
、PENDING(2)
)
sources
: 依赖收集的Signal
sourceSlots
: 对应sources
中Signal
里observers
对应自身的位置
sources
和 sourceSlots
后续会详细讲解,这里先了解一下即可。接下来,再回到
createEffect
里看下一个函数:updateComputation
updateComputation
cleanNode
是清除 Computation 依赖关系和其本身状态的,到后面再讲,会清晰一点。接着往下,再来看看
runComputation
。runComputation
Listener
的含义可以看一下全局变量那一块的内容。
这里注意一下,
Listener
绑定了当前正在执行的 Computation。然后,我们再看看
fn
的执行。computation1.fn
里面调用了
signal1()
,而 signal1()
本质上,就是调用了 readSignal
。readSignal
readSignal
这里可以看到,进行依赖收集了,当前执行的
Computation
(Listener
)和 Signal
相互进行了收集,最后再返回当前 Signal
值。我们主要看一下依赖收集的逻辑。
依赖收集解析
防止小伙伴忘了这四个属性是啥意思,我这里再声明一下:
- Signal
observers
: 观察该Signal
的Computation
observerSlots
: 这是对应observers
中Computation
里sources
对应自身的下标位置
(observers[i] as Computation).sources[observerSlots[]i]
-> 本身- Computation
sources
: 依赖收集的Signal
sourceSlots
: 对应sources
中Signal
里observers
对应自身的位置
(sources[i] as Signal).observers[sourceSlots]
-> 本身暂时先回到刚刚的案例来看,这是第一次进行依赖收集,所以 Signal 和 Computation 都会走 true 的逻辑,即:
以
Listener
为例,其 sources
收集了该 Signal
,而对应在 sourceSlots
里的位置怎么得来呢?按照刚刚说明的方法,对应
sources
中 Signal
里 observers
对应自身的位置。也就是
signal.observers.length
,因为是新增的嘛,所以 signal.observers
的长度就是该 Listener
的下标位置,而 signal.observers
不存在时,也就是 0,也就是从空到下标为0的过程。再来看看
Signal
,也是同理,先在 observers
里添加当前的 Computation
(Listener
),也就是 [Listener]
,可以看出来就是存储在下标为0的位置,与 Listener.sourceSlots
相对应。observerSlots
也是一样的,对应 observers
中 Computation
里 sources
对应自身的下标位置。也就是下标0,在上面
Listener.sources
中已经存储了。可能看文字还是有点云里雾里的,我再放一张图来展示一下其中的关系:
现在可能就清晰明了一点了。
sources
& sourceSlots
& observers
& observerSlots
四者的关系
我们再来看一个复杂点的依赖关系的案例。
简单赘述一下,
computation1
依赖了 signal1
和 signal2
,computation2
依赖了 signal1
。大家可以先想想这四个对象之间的依赖关系,我下面直接给出图例解释:
Signal
→Computation
Computation
→Signal
现在是不是就清晰很多了呢,如果还是有点懵的话,再回头看看关于四个属性的解释吧。
我们现在再回到
readSignal
里,该函数只干了两件事:- 如果是在被
Computation
包裹时,进行依赖收集
- 返回
Signal
值
现在,读的部分已经了解完了,再开始写的部分之前,我们把之前遗漏的一个函数讲一下:
cleanNode
cleanNode
这个函数的作用就是重置
Computation
的内部关系、状态等内容。这里面就用到了上面所讲到的四元素:
sources
、 sourceSlots
、 observers
、 observerSlots
。利用
source
和 sourceSlots
分别找到了- 所依赖的
Signal
- 此
Signal
中该node
在对应observerSlots
中的下标位置(也就能找到在observers
中对应的node
了)
忘记这之前关系的小伙伴,再来重温一下这张图。
找到这两个位置之后,再利用
Signal.observers
去顶替这一个位置,就成功的把两者之间的依赖关系给消除了。如果
observers
长度只有一,那么什么都不用做,因为这一个 observer
就是需要清除的关系,相当于直接清空了。注:如果这四元素的排放位置和自己想象的不一样,没关系,因为cleanNode
的原因,会导致每次更新之后,在数组中的位置都会发生改变,主要是了解其中的对应关系。
writeSignal
接下来,来看看写(更新)的这一块逻辑吧。
和前面大部分都是相似的,只是多了些更新逻辑相关的内容。
先来看看
writeSignal
函数。writeSignal
整体上也就做了两件事:
- 更新
Signal
的value
值
- 通知
observers
进行更新
而更新是放在
runUpdates
函数里的,让我们先看看它。runUpdates
这里面用到了一个
wait
变量,同时还传入了 completeUpdates
函数里。利用
wait
变量,来实现同一批次的更新都存放在 Effects
这个队列里,然后一起更新。什么情况下,会有这种情形呢?不应该在
writeSignal
更新那里都执行了嘛?这里包含一个还没有讲到的内容:
memo
;它是 Computation
和 Signal
的结合体。那么就会存在一种情况,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
,重新执行其副作用函数。更新的流程差不多就是这样,其实也是很简单的。
我们再通过流程图整体看一下
readSignal
和 writeSignal
两个部分吧。现在只是简单的展示一下执行流程,整体的执行过程将在案例二中展示。
最后,再回到案例看看,它的执行结果吧。
还是很简单的,通过这个案例我们也对 Solid 的响应式了解了个大概。
我们再来看看第二个案例,来完整的了解一下 Signal 响应式的流程吧。
案例二(Signal
& Effect
& Memo
)
这个案例中在原先的基础上添加了
Memo
,来完整的了解整个响应式的流程。Memo
的话,之前也提过一嘴,是 Signal
+ Computation
的结合体(也可以看看 Memo
的定义)。我们通过案例来深入了解一下其中的原理吧。
createSignal
就不过多赘述了,忘了的话,看看第一个案例。Memo
这个案例,我们主要以
Memo
的视角去看整体的流程,我们先来看看 createMemo
。createMemo
看着是不是有点熟悉,这就是相当于
createSingal
和 createEffect
结合啊,完全一模一样。注意两个点:
- 一个是
createComputation
传入的第三个参数,pure = true
。而createEffect
传入的是 false。
这个后面更新的时候会用到,先留意一下。
- 另一个是
createMemo
返回的是一个只读的 Signal。注意它是通过readSignal
来读取的。
根据案例,我们先来看看
Memo
的依赖关系,也能更好的理解它为什么是 Signal
+ Computation
:我们再回过来看看
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
更新产生的,因为依赖此 Memo
的 Computation
并不能直接更新,需要先等 Memo
的值更新完成后,再更新依赖此 Memo
的 Computation
。同时这里还用到了
Updates
更新队列。可以和 Effects
理解为两个更新队列。原因可以如上所述,不然会导致 Computation
更新使用了旧的 Memo
值。官网也对此说明了一下:
If it is possible to use pure functions andcreateMemo
, this is likely more efficient, as Solid optimizes the execution order of memo updates, whereas updating a signal withincreateComputed
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
流程图如下:我们再会过来,重新看看
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
状态的处理。还是一样的意思,如果当前
node
是 PENDING
状态,那么它所依赖的 Signal(Memo)
肯定有未更新完成的,需要利用 lookUpstream
先去更新当前 node
的依赖。runTop
流程图如下:还是提一嘴,
lookUpstream
其实也是变相的去更新 Computation
了,只是是倒过来,通过依赖更新再返回过通知当前 Computation
更新。后续,就是在走
updateComputation
和 runComputation
函数去更新 Computation
了。updateComputation
没变,它本质还是调用 runComputation
去更新,我们来看一下 runComputation
的变化。runComputation
这里多了个返回值
nextValue
和对 Memo
的特殊处理。nextValue
就是利用最新的 Signal
值再计算得出新的 Memo
值。再来看一下对
Memo
的特殊处理:writeSignal(node as Memo<any>, nextValue, true);
其实也就是利用
writeSignal
去手动触发 Signal
的更新,走更新流程。具体流程和案例一的更新流程是一致的。更新
Computation
的流程图如下:虽然我们还在从
readSignal
的角度去看,但是更新的(writeSignal
)也看的差不多了。再看一下
writeSignal
的变化吧。writeSignal
这里多了两个新的地方:
- 一个是
o.pure
的判断,多了个加入Updates
队列。
- 另一个是对
Memo
的特殊处理,调用了一个markDownstream
方法。
pure
属性之前在 createMemo
的时候也提到过了,可以暂时理解为区分 createEffect
和 createMemo
的;另一点原因,也就是前面提到的 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
和上面提过的lookUpstream
是专门用于处理Memo
的observers
和signals
关系的。
在处理完成之后,就开始走
runUpdates
开始更新了,逻辑和之前都是一样的了,因为讲 readSignal
的时候已经差不多讲过更新流程了。就不再赘述了。我们再回到案例看看它的输出结果吧,看看和大家想的一不一样。
这里要注意一点,
Memo
的 observers
是先触发的,我们再从源码上看一下。首先,在初始化完成之后
Signal
的 observers
应该是 [memo, computation1]
,而 Memo
的 observers
为 [computation2]
。那么在
trigger
之后,Signal
会通知其 observers
进行更新,但是呢,第一个 observer
是 Memo
,那么,就会多走一步,markDownstream
,去通知 Memo
的 observers
去更新。所以说,这就是导致 computation2
比 computation1
先执行的原因。我们再换个位置来看看。
初始化没问题,顺序执行,更新的时候,两个
Computation
的执行也没问题,因为现在 Signal
的 observers
位置是 [computation1, memo]
。我们来看看为什么
Memo
的执行优先级比 Effect
高吧,这时候大家可能会想起之前讲的两个队列:Updates
、Effects
。对就是这两个,Memo
会加入到 Updates
队列,执行的优先级是比 Effects
要高的。到这里,我们也差不多了解了 Solid 响应式,以及 Solid 的响应式过程。
这里我放一张完整的流程图。
案例三
我们再通过一个案例来回顾一下之前所讲的内容。
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 响应式的流程图作为结尾
参考链接:
- 作者:JinSo
- 链接:https://jinso365.top/article/099814fc-4f30-4050-9d48-a016408fd01e
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。