在本文中,我将通过三个案例来深入到 Solid 源码中解析 Signal 响应式的原理,并通过图解的方式更清晰的展示给大家。
在上一篇文章中,我也讲了该如何去调试 Solid 源码,这里将不再过多赘述,如果不知道的小伙伴,可以再去看一下。
前置
这里先提前讲一下 Signal、Computation、Memo 的类型,方便后续调试的时候理解其含义。
注:含
t开头的属性(tValue、tState…),都是和Transition相关的状态,本文暂时忽略。
类型
SignalState
export interface SignalState<T> {
value: T;
/**
* signal 收集的 effect
*/
observers: Computation<any>[] | null;
/**
* 这是对应 observers 中 effect 里 sources 对应的位置
* (observers[i] as Signal).sources[observerSlots] -> 本身
* signal 和 effect 两者的 observers、sources、observerSlots、sourceSlots 是一一对应的
*/
observerSlots: number[] | null;
/**
* tValue 是用于 Transition 时,在加载数据时显示回退内容,即旧的 value
*/
tValue?: T;
/**
* 用于判断是否需要重新渲染,通过 options.equals 去配置
*/
comparator?: (prev: T, next: T) => boolean;
}
Computation
export type ComputationState = 0 | 1 | 2;
export interface Computation<Init, Next extends Init = Init> extends Owner {
/**
* 副作用函数
*/
fn: EffectFunction<Init, Next>;
/**
* 标识 effect 当前状态,未设置(0)、STALE(1)、PENDING(2)
*/
state: ComputationState;
tState?: ComputationState;
/**
* effect 中依赖收集的 Signal
*/
sources: SignalState<Next>[] | null;
/**
* 这是对应 sources 中 Signal 里 observers 对应的位置
* (sources[i] as Signal).observers[sourceSlots] -> 本身
* signal 和 effect 两者的 observers、sources、observerSlots、sourceSlots 是一一对应的
*/
sourceSlots: number[] | null;
/**
* 用于 createMemo 这种特殊的 effect,存在返回值,只读 Signal
*/
value?: Init;
updatedAt: number | null;
/**
* TODO: pure 暂时的作用看是用作 memo 和 effect 做区分的
* 同时它也是区分 Updates 和 Effects 的
*/
pure: boolean;
/**
* TODO: 像是区分是否是用户手动定义的,例如 createEffect 时,user 为 true,而 createMemo 时,user 为 false
*/
user?: boolean;
suspense?: SuspenseContextType;
}
注:先解释一下,在 Solid 内部,类似
createEffect这种计算函数(副作用)的对象统称为Computation。简单点说,
createEffect就是返回值为空的计算函数,只需要执行其函数就可以了;像createMemo,就是带返回值的Computaion。这边只是简单解释一下
Computation的含义,后面还会再详细解释。
Memo
export interface Memo<Prev, Next = Prev> extends SignalState<Next>, Computation<Next> {
value: Next;
tOwned?: Computation<Prev | Next, Next>[];
}
这里其实就能看出来,Memo 本质上就是 Singal 和 Computation 的结合体。
全局变量
// 用于 Computation.state
// 标识当前 computation 值已过期,需要更新
const STALE = 1;
// 目前看到是在 memo 在 effect 中使用时,该 effect 会赋予 PENDING
const PENDING = 2;
// 全局变量
/**
* Listener 指向当前执行的 effect,用于后续依赖收集 Signal
*/
let Listener: Computation<any> | null = null;
/**
* 目前看的话,是把 Updates 和 Effects 看作两种优先级的队列,像 Updates,是存放 memo 等内置特殊处理的 pure effect
* 而 Effects 是 createEffect 创建的普通 effect
* 一次更新过程中,Updates > Effects,即先执行 Updates,再执行 Effects
*/
let Updates: Computation<any>[] | null = null;
let Effects: Computation<any>[] | null = null;
现在大概了解一下,有哪些属性即可,后面调试的时候,可以对照着看,有个印象就行。
接下来,我将直接展示案例,并进入到 Solid 源码的世界里。
案例一(Signal & Effect)
第一个案例,我们先以最简单的 createSignal 和 createEffect 来演示。
先了解其 依赖收集 的方式和 响应式原理。
signal-1.js
import { createSignal, createEffect } from "../dist/solid.js";
debugger
const [signal1, trigger1] = createSignal(1)
const [signal2, trigger2] = createSignal('1')
// computation1
createEffect(() => {
console.log('track1', signal1())
})
// computation2
createEffect(() => {
console.log('track2', signal2())
})
trigger1(Math.random() * 100)
trigger2(`${Math.random() * 100}`)
接下来,我们将从 createSignal 开始,一步步向内部走。
注:下面展示的源码会删除一些无关的代码。
createSignal
export const equalFn = (a: T, b: T) => a === b;
// 默认的 signal options
const signalOptions = { equals: equalFn };
export function createSignal<T>(): Signal<T | undefined>;
export function createSignal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
export function createSignal<T>(
value?: T,
options?: SignalOptions<T | undefined>
): Signal<T | undefined> {
options = options ? Object.assign({}, signalOptions, options) : signalOptions;
/**
* Signal 对象,包括当前的值、观察者队列、比较器
* 从这里也可以看出来,signal 只对 value (最外层的对象)做了处理,所以并不能嵌套响应,类似 Vue 的 ref。
*/
const s: SignalState<T | undefined> = {
value,
observers: null,
observerSlots: null,
comparator: options.equals || undefined
};
const setter: Setter<T | undefined> = (value?: unknown) => {
// 如果 setter 的是一个函数,则传入 prev 值,先进行计算,获得最新的 value 值
if (typeof value === "function") {
value = value(s.value);
}
return writeSignal(s, value);
};
return [readSignal.bind(s), setter];
}
可以看到,其实 createSignal 内部做的事情很简单:
- 创建一个
Signal对象 - 返回一个封装好的
[getter, setter]
对于 Signal,我们暂时先关注三个属性:
value: 状态值observers: 观察该Signal的ComputationobserverSlots: 这是对应observers中effect里sources对应自身的下标位置
readSignal 和 writeSignal 先不着急去看,我们先按案例的流程走,后续再回来看这两个函数。
接下来,我们再来看看 createEffect 内部做了些什么。
createEffect
export function createEffect<Next>(fn: EffectFunction<undefined | NoInfer<Next>, Next>): void;
export function createEffect<Next, Init = Next>(
fn: EffectFunction<Init | Next, Next>,
value: Init,
options?: EffectOptions & { render?: boolean }
): void;
export function createEffect<Next, Init>(
fn: EffectFunction<Init | Next, Next>,
value?: Init,
options?: EffectOptions & { render?: boolean }
): void {
const c = createComputation(fn, value!, false, STALE, "_SOLID_DEV_" ? options : undefined)
// 这里可以注意一下,user 标识这是用户创建的一个 Computation
if (!options || !options.render) c.user = true;
// 暂时先忽略前半部分
Effects ? Effects.push(c) : updateComputation(c);
}
和 createSignal 类似,createEffect 内部做了两件事:
- 创建一个
Computation对象(createComputation) - 更新
Computation,执行一次fn,进行初始化(createComputation)
接着看一下 createComputation
createComputation
function createComputation<Next, Init = unknown>(
fn: EffectFunction<Init | Next, Next>,
init: Init,
pure: boolean,
state: ComputationState = STALE,
options?: EffectOptions
): Computation<Init | Next, Next> {
const c: Computation<Init | Next, Next> = {
fn,
state: state,
updatedAt: null,
owned: null,
sources: null,
sourceSlots: null,
cleanups: null,
value: init,
owner: Owner,
context: Owner ? Owner.context : null,
pure
};
return c;
}
删除了写无用代码,可以看出来简单明了,一个 Computation 的工厂函数。
属性的含义,可以看一下前面 前置 内容里对 Computation 所描述的解释。
我们暂时只需要关注四个属性:
fn: 计算函数(副作用函数)state: 当前状态(UNSET(0)、STALE(1)、PENDING(2))sources: 依赖收集的SignalsourceSlots: 对应sources中Signal里observers对应自身的位置
sources 和 sourceSlots 后续会详细讲解,这里先了解一下即可。
接下来,再回到 createEffect 里看下一个函数:updateComputation
updateComputation
function updateComputation(node: Computation<any>) {
if (!node.fn) return;
cleanNode(node);
runComputation(
node,
Transition && Transition.running && Transition.sources.has(node as Memo<any>)
? (node as Memo<any>).tValue
: node.value,
time
);
}
cleanNode 是清除 Computation 依赖关系和其本身状态的,到后面再讲,会清晰一点。
接着往下,再来看看 runComputation。
runComputation
function runComputation(node: Computation<any>, value: any, time: number) {
/**
* 这里实际上可以理解为 listener 的调用栈(嵌套)
* 先保存 prev 的 listener,在赋值到当前的 listener 进行处理
* 并在最后将当前的 listener 赋值到 prev 的 listener
* 如果内部还有嵌套的 runComputation,那么就会递归继续存储 listener
*/
const listener = Listener;
// 从这里可以看出,Listener 应该指向当前执行的 Computation(effect)
Listener = node;
try {
// 执行计算函数
node.fn(value);
} finally {
// 恢复为之前的
Listener = listener;
}
}
Listener的含义可以看一下全局变量那一块的内容。
这里注意一下,Listener 绑定了当前正在执行的 Computation。
然后,我们再看看 fn 的执行。
computation1.fn
() => {
console.log('track1', signal1())
}
里面调用了 signal1(),而 signal1() 本质上,就是调用了 readSignal。
readSignal
readSignal
export function readSignal(this: SignalState<any> | Memo<any>) {
// 这里的 Listener 就是当前执行的 effect,赋值是在 runComputation 中传递的,用于后续进行依赖收集的
if (Listener) {
// 这边的逻辑就是 signal 和 effect 的相互收集
const sSlot = this.observers ? this.observers.length : 0;
if (!Listener.sources) {
Listener.sources = [this];
Listener.sourceSlots = [sSlot];
} else {
Listener.sources.push(this);
Listener.sourceSlots!.push(sSlot);
}
if (!this.observers) {
this.observers = [Listener];
this.observerSlots = [Listener.sources.length - 1];
} else {
this.observers.push(Listener);
this.observerSlots!.push(Listener.sources.length - 1);
}
}
return this.value;
}
这里可以看到,进行依赖收集了,当前执行的 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 的逻辑,即:
// Computation
const sSlot = this.observers ? this.observers.length : 0;
Listener.sources = [this];
Listener.sourceSlots = [sSlot]; // sSlot = 0
// Signal
this.observers = [Listener];
this.observerSlots = [Listener.sources.length - 1]; // Listener.sources.length - 1 = 0
以 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 四者的关系
我们再来看一个复杂点的依赖关系的案例。
const [signal1, trigger1] = createSignal(1)
const [signal2, trigger2] = createSignal('1')
// computation1
createEffect(() => {
console.log('track1', signal1(), signal2())
})
// computation2
createEffect(() => {
console.log('track2', signal1())
})
简单赘述一下,computation1 依赖了 signal1 和 signal2,computation2 依赖了 signal1。
大家可以先想想这四个对象之间的依赖关系,我下面直接给出图例解释:
Signal→Computation
Computation→Signal
现在是不是就清晰很多了呢,如果还是有点懵的话,再回头看看关于四个属性的解释吧。
我们现在再回到 readSignal 里,该函数只干了两件事:
- 如果是在被
Computation包裹时,进行依赖收集 - 返回
Signal值
现在,读的部分已经了解完了,再开始写的部分之前,我们把之前遗漏的一个函数讲一下: cleanNode
cleanNode
/**
* 清空 node(Computation) 与 signal 之前存在的依赖关系,同时重置 node state
*/
function cleanNode(node: Owner) {
let i;
if ((node as Computation<any>).sources) {
while ((node as Computation<any>).sources!.length) {
const source = (node as Computation<any>).sources!.pop()!,
index = (node as Computation<any>).sourceSlots!.pop()!,
obs = source.observers;
if (obs && obs.length) {
// 利用 source.observers 的最后一位去覆盖当前需要清除的 effect
const n = obs.pop()!,
s = source.observerSlots!.pop()!;
if (index < obs.length) {
// 更新最后一位 observer 的位置,为需要覆盖的位置
n.sourceSlots![s] = index;
// 更新最后一位 observer 在 source.observers 中的位置
obs[index] = n;
// 更新 source.observerSlots 中最后一位 observer 的位置
source.observerSlots![index] = s;
}
}
}
}
(node as Computation<any>).state = 0;
}
这个函数的作用就是重置 Computation 的内部关系、状态等内容。
这里面就用到了上面所讲到的四元素:sources 、 sourceSlots 、 observers 、 observerSlots 。
利用 source 和 sourceSlots 分别找到了
- 所依赖的
Signal - 此
Signal中该node在对应observerSlots中的下标位置(也就能找到在observers中对应的node了)
忘记这之前关系的小伙伴,再来重温一下这张图。
找到这两个位置之后,再利用 Signal.observers 去顶替这一个位置,就成功的把两者之间的依赖关系给消除了。
如果 observers 长度只有一,那么什么都不用做,因为这一个 observer 就是需要清除的关系,相当于直接清空了。
注:如果这四元素的排放位置和自己想象的不一样,没关系,因为
cleanNode的原因,会导致每次更新之后,在数组中的位置都会发生改变,主要是了解其中的对应关系。
writeSignal
接下来,来看看写(更新)的这一块逻辑吧。
和前面大部分都是相似的,只是多了些更新逻辑相关的内容。
先来看看 writeSignal 函数。
writeSignal
export function writeSignal(node: SignalState<any> | Memo<any>, value: any, isComp?: boolean) {
let current = node.value;
if (!node.comparator || !node.comparator(current, value)) {
// 赋值
node.value = value;
// 通知观察者更新
if (node.observers && node.observers.length) {
runUpdates(() => {
for (let i = 0; i < node.observers!.length; i += 1) {
const o = node.observers![i];
// 根据不同情况,将 effect 加入不同的更新队列中
// 如果当前 effect 的状态还未处于更新,只加入更新队列
if (!o.state) {
Effects!.push(o)
}
// 这边就可以看到 STALE 是用于标识当前数据已经过期了,需要更新
o.state = STALE;
}
}, false);
}
}
return value;
}
整体上也就做了两件事:
- 更新
Signal的value值 - 通知
observers进行更新
而更新是放在 runUpdates 函数里的,让我们先看看它。
runUpdates
function runUpdates<T>(fn: () => T, init: boolean) {
// init 暂时先不管
let wait = false;
// 如果存在 Effects,则继续等待,直到所以需要的 effect 都被处理完成
if (Effects) wait = true;
else Effects = [];
try {
// 执行 fn,通知 observers,更新 Effects
const res = fn();
completeUpdates(wait);
return res;
} catch (err) {
if (!wait) Effects = null;
handleError(err);
}
}
这里面用到了一个 wait 变量,同时还传入了 completeUpdates 函数里。
利用 wait 变量,来实现同一批次的更新都存放在 Effects 这个队列里,然后一起更新。
什么情况下,会有这种情形呢?不应该在 writeSignal 更新那里都执行了嘛?
这里包含一个还没有讲到的内容:memo;它是 Computation 和 Signal 的结合体。那么就会存在一种情况,Computation 函数内依赖了 memo,那么此时就形成了 Computation 的嵌套。
如果直接更新的话,那就会导致同一批次的内容,触发了两次更新。
我们往下看,执行了 fn 函数,即
for (let i = 0; i < node.observers!.length; i += 1) {
const o = node.observers![i];
// 根据不同情况,将 effect 加入不同的更新队列中
// 如果当前 effect 的状态还未处于更新,只加入更新队列
if (!o.state) {
Effects!.push(o)
}
// 这边就可以看到 STALE 是用于标识当前数据已经过期了,需要更新
o.state = STALE;
}
这里就将需要更新的 observer 添加到 Effects 当中,并更新当前 observer 状态,标明需要更新。
找到需要更新的 observer 之后,我们再来看看下面的 completeUpdates 函数。
completeUpdates
function completeUpdates(wait: boolean) {
if (wait) return;
// false: 能走到这,那么已经不需要 wait 了,该执行并更新了
const e = Effects!;
Effects = null;
// 更新完成后,更新所有 effect
if (e!.length) runUpdates(() => runEffects(e), false);
}
这里也利用到的 wait,可以看到,如果需要 wait 的,那么不需要执行下面的内容。
其实就是 completeUpdates 只需要执行一次,所以只需要第一个没有 wait 的来走即可,后续如果还有同批次更新的话,都走 wait 即可。
到这里其实更新的流程也就走完了,后续其实就是再走一遍 Computation 的更新流程。
我们接着往下看,这里利用 runUpdates 执行了一个 runEffects 函数。
runEffects
function runUserEffects(queue: Computation<any>[]) {
let i,
userLength = 0;
// 区分出用户创建的 effect,还是内部创建的
for (i = 0; i < queue.length; i++) {
const e = queue[i];
// 先不管,假设都走的 else
if (!e.user) runTop(e);
else queue[userLength++] = e;
}
for (i = 0; i < userLength; i++) runTop(queue[i]);
}
Computation 上的 user 变量还没有标明,其实就是标明是内部还是用户创建的,然后分出来两部分,两个优先级更新,可以先暂时不用管。
后面为每一个 Computation 执行了一个 runTop 函数。
runTop
/**
* 向上查找 effect 的所有 owner,并通知它们更新
*/
function runTop(node: Computation<any>) {
if ((node.state) === 0) return;
// 收集相关联的 effect
const ancestors = [node];
// 这个可以先忽略
while (
(node = node.owner as Computation<any>) &&
(!node.updatedAt || node.updatedAt < ExecCount)
) {
if (node.state) ancestors.push(node);
}
// 准备更新 node 及其相关的每一个 effect
for (let i = ancestors.length - 1; i >= 0; i--) {
node = ancestors[i];
// 标明 signal 已经更新,对应的 effect 需要重新计算
if ((node.state) === STALE) {
updateComputation(node);
}
}
}
owner 这一块的内容可以先忽略,它和 createRoot 有关,暂时可以先假设 ancestors = [node] 。
接着往下看,利用 for 去执行了每一个 Computation,这里也用到了 STALE,只要需要更新的状态才会去走更新流程,最后就是去执行 updateComputation 了。
后续就是 Computation 更新的流程了,之前在 createEffect 的时候讲过一次。
源码就不放了,大概讲述一下,忘记的小伙伴可以往前再看一下 updateComputation 的流程。
现在情况简单说就是通过 runEffects 去更新每一个 Computation,然后再执行 updateComputation 去更新每一个 Computation,重新执行其副作用函数。
更新的流程差不多就是这样,其实也是很简单的。
我们再通过流程图整体看一下 readSignal 和 writeSignal 两个部分吧。
现在只是简单的展示一下执行流程,整体的执行过程将在案例二中展示。
最后,再回到案例看看,它的执行结果吧。
const [signal1, trigger1] = createSignal(1)
const [signal2, trigger2] = createSignal('1')
createEffect(() => {
console.log('track1', signal1())
})
createEffect(() => {
console.log('track2', signal2())
})
trigger1(Math.random() * 100)
trigger2(`${Math.random() * 100}`)
// track1 1
// track2 '1'
// track1 randomNum
// track2 'randomNum'
还是很简单的,通过这个案例我们也对 Solid 的响应式了解了个大概。
我们再来看看第二个案例,来完整的了解一下 Signal 响应式的流程吧。
案例二(Signal & Effect & Memo)
这个案例中在原先的基础上添加了 Memo,来完整的了解整个响应式的流程。
Memo 的话,之前也提过一嘴,是 Signal + Computation 的结合体(也可以看看 Memo 的定义)。
我们通过案例来深入了解一下其中的原理吧。
import { createSignal, createEffect, createMemo } from "../dist/solid.js";
debugger
const [signal, trigger] = createSignal(1)
const memo = createMemo(() => signal() * 2)
// computation1
createEffect(() => {
console.log('track1', signal())
})
// computation2
createEffect(() => {
console.log('track2', memo())
})
trigger(Math.random() * 100)
createSignal 就不过多赘述了,忘了的话,看看第一个案例。
Memo
这个案例,我们主要以 Memo 的视角去看整体的流程,我们先来看看 createMemo。
createMemo
export function createMemo<Next extends Prev, Prev = Next>(
fn: EffectFunction<undefined | NoInfer<Prev>, Next>
): Accessor<Next>;
export function createMemo<Next extends Prev, Init = Next, Prev = Next>(
fn: EffectFunction<Init | Prev, Next>,
value: Init,
options?: MemoOptions<Next>
): Accessor<Next>;
export function createMemo<Next extends Prev, Init, Prev>(
fn: EffectFunction<Init | Prev, Next>,
value?: Init,
options?: MemoOptions<Next>
): Accessor<Next> {
options = options ? Object.assign({}, signalOptions, options) : signalOptions;
// memo 存在 computation(effect) 和 signal
const c: Partial<Memo<Init, Next>> = createComputation(
fn,
value!,
true,
0,
"_SOLID_DEV_" ? options : undefined
) as Partial<Memo<Init, Next>>;
// signal 所有的
c.observers = null;
c.observerSlots = null;
c.comparator = options.equals || undefined;
updateComputation(c as Memo<Init, Next>);
// 返回一个只读的 signal
return readSignal.bind(c as Memo<Init, Next>);
}
看着是不是有点熟悉,这就是相当于 createSingal 和 createEffect 结合啊,完全一模一样。
注意两个点:
-
一个是
createComputation传入的第三个参数,pure = true。而createEffect传入的是 false。这个后面更新的时候会用到,先留意一下。
-
另一个是
createMemo返回的是一个只读的 Signal。注意它是通过readSignal来读取的。
根据案例,我们先来看看 Memo 的依赖关系,也能更好的理解它为什么是 Signal + Computation:
我们再回过来看看 readSignal。
readSignal
export function readSignal(this: SignalState<any> | Memo<any>) {
// 新增
/**
* 这里是对 createMemo 的处理,因为 memo 本身也是 effect 的一种衍生,所以它也会监听内部的 signal 变化,即 sources
*/
if (
(this as Memo<any>).sources &&
((this as Memo<any>).state)
) {
// 这边的 STALE 应该是标识数据更新了,当前 memo 需要重新执行了
if ((this as Memo<any>).state) === STALE)
updateComputation(this as Memo<any>);
else {
const updates = Updates;
Updates = null;
runUpdates(() => lookUpstream(this as Memo<any>), false);
Updates = updates;
}
}
// 这里的 Listener 就是当前执行的 effect,赋值是在 runComputation 中传递的,用于后续进行依赖收集的
if (Listener) {
// 这边的逻辑就是 signal 和 effect 的相互收集
const sSlot = this.observers ? this.observers.length : 0;
if (!Listener.sources) {
Listener.sources = [this];
Listener.sourceSlots = [sSlot];
} else {
Listener.sources.push(this);
Listener.sourceSlots!.push(sSlot);
}
if (!this.observers) {
this.observers = [Listener];
this.observerSlots = [Listener.sources.length - 1];
} else {
this.observers.push(Listener);
this.observerSlots!.push(Listener.sources.length - 1);
}
}
return this.value;
}
这里面多了些 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 and
createMemo, this is likely more efficient, as Solid optimizes the execution order of memo updates, whereas updating a signal withincreateComputedwill immediately trigger reactive updates some of which may turn out to be unnecessary. —— createComputed - SolidDocs (solidjs.com)
Solid 专门对 Memo 做了写优化。
我们先看一下 runUpdates 里的 lookUpstream 方法。
lookUpstream
/**
* 向上查找 memo 所依赖的 sources,如果存在未更新的 memo,则更新
* 同时递归向上找到所有依赖的 memo,直至所有依赖更新完成后,当前 memo 可更新成最新值
*/
function lookUpstream(node: Computation<any>, ignore?: Computation<any>) {
// 注意,重置当前 node 的状态,不然后续当前 node 无法更新
node.state = 0;
for (let i = 0; i < node.sources!.length; i += 1) {
// 这里 source 本应该是个 Signal,再通过下面 source.sources 的判断,我们就可以理解了
// 就是 Memo 依赖的向上递归更新
const source = node.sources![i] as Memo<any>;
if (source.sources) {
const state = source.state;
if (state === STALE) {
if (source !== ignore && (!source.updatedAt || source.updatedAt < ExecCount))
runTop(source);
} else if (state === PENDING) lookUpstream(source, ignore);
}
}
}
如果走这个函数的话,说明当前 node 的状态是 PENDING,也就意味着 node.sources 中有的状态还未更新完(STALE),需要先更新 sources 里的,同时如果 sources 里还有 Memo 的话,再递归向上,直到所有 STALE 状态更新完成。
而在 runTop 执行的时候,因为两者的依赖关系,所以待 sources 更新完成后,当前 node 也会更新为最新值。
ps: 如果忘了 PENDING 含义的,可以看一下 readSignal 那里。
ignore 参数的话,就是避免递归死循环,导致调用栈溢出了。
lookUpstream 流程图如下:
我们再会过来,重新看看 runUpdates,里面多了对 Updates 队列的处理。
runUpdates
function runUpdates<T>(fn: () => T, init: boolean) {
// 新增
// 如果存在 Updates,则继续执行 fn,再次进行 Updates、Effects 的收集,直到所有的 Updates 先被处理完成
if (Updates) return fn();
let wait = false;
// 新增
if (!init) Updates = [];
// 如果存在 Effects,则继续等待,直到所以需要的 effect 都被处理完成
if (Effects) wait = true;
else Effects = [];
try {
// 执行 fn,通知 observers,更新 Updates、Effects
const res = fn();
completeUpdates(wait);
return res;
} catch (err) {
if (!wait) Effects = null;
Updates = null;
}
}
主要就是多了个 Updates 初始化和执行,和之前 Effects 处理逻辑很类似,只需要第一个往下走就行了,其余的更新添加到 Updates 即可,最后由第一个统一继续更新。
再来看看 completeUpdates 的变化。
completeUpdates
function completeUpdates(wait: boolean) {
// 新增
// 优先更新 Updates
if (Updates) {
runQueue(Updates);
Updates = null;
}
if (wait) return;
const e = Effects!;
Effects = null;
// 更新完成后,更新所有 effect
// false: 能走到这,那么已经不需要 wait 了,该执行并更新了
if (e!.length) runUpdates(() => runEffects(e), false);
}
这里也新增了对 Updates 队列更新的处理,我们和之前的 runUpdates 连着看,首先,Updates 相关的更新能走到这,说明 Updates 队列已经都处理好了,执行最后的 completeUpdates 了。
同时从结构上也可以看出来,Updates 的优先级是比 Effects 的高的。
一方面是因为 PENDING 的原因,另一方面也是之前官文提到的,对 Memo 的特殊优化处理。
更新流程图如下:
我们继续看看 Updates 里的东西, runQueue 是如何更新 Updates 队列的。
runQueue
function runQueue(queue: Computation<any>[]) {
for (let i = 0; i < queue.length; i++) runTop(queue[i]);
}
只是简单的给每一个 Computation 执行 runTop,接下去就是更新 Computation 了,后面的流程都差不多,我们来看看里面对 Memo 的额外处理吧。
runTop
/**
* 向上查找 effect 的所有 owner,并通知它们更新
*/
function runTop(node: Computation<any>) {
if ((node.state) === 0) return;
// 新增
if ((node.state) === PENDING) return lookUpstream(node);
// 收集相关联的 effect
const ancestors = [node];
while (
(node = node.owner as Computation<any>) &&
(!node.updatedAt || node.updatedAt < ExecCount)
) {
if (node.state) ancestors.push(node);
}
// 准备更新 node 及其相关的每一个 effect
for (let i = ancestors.length - 1; i >= 0; i--) {
node = ancestors[i];
// 标明 signal 已经更新,对应的 effect 需要重新计算
if ((node.state) === STALE) {
updateComputation(node);
// 新增
} else if ((node.state) === PENDING) {
const updates = Updates;
Updates = null;
runUpdates(() => lookUpstream(node, ancestors[0]), false);
Updates = updates;
}
}
}
和之前都是一样的,就多了个对 PENDING 状态的处理。
还是一样的意思,如果当前 node 是 PENDING 状态,那么它所依赖的 **Signal(Memo)**肯定有未更新完成的,需要利用 lookUpstream 先去更新当前 node 的依赖。
runTop 流程图如下:
还是提一嘴,lookUpstream 其实也是变相的去更新 Computation 了,只是是倒过来,通过依赖更新再返回过通知当前 Computation 更新。
后续,就是在走 updateComputation 和 runComputation 函数去更新 Computation 了。
updateComputation 没变,它本质还是调用 runComputation 去更新,我们来看一下 runComputation 的变化。
runComputation
/**
* 执行 effect
*/
function runComputation(node: Computation<any>, value: any, time: number) {
let nextValue;
/**
* 这里实际上可以理解为 listener 的调用栈(嵌套)
* 先保存 prev 的 listener,在赋值到当前的 listener 进行处理
* 并在最后将当前的 listener 赋值到 prev 的 listener
* 如果内部还有嵌套的 runComputation,那么就会递归继续存储 listener
*/
const listener = Listener;
// 从这里可以看出,Listener 应该指向当前执行的 Computation(effect)
Listener = node
try {
nextValue = node.fn(value);
} catch (err) {
if (node.pure) {
node.state = STALE;
node.owned && node.owned.forEach(cleanNode);
node.owned = null;
}
return handleError(err);
} finally {
// 恢复为之前的
Listener = listener;
}
// 新增
if (!node.updatedAt || node.updatedAt <= time) {
// 对于 memo effect 的特殊处理,手动更新 Signal,通知依赖它的 effect
if (node.updatedAt != null && "observers" in node) {
writeSignal(node as Memo<any>, nextValue, true);
} else node.value = nextValue;
// 更新 updateAt
node.updatedAt = time;
}
}
这里多了个返回值 nextValue 和对 Memo 的特殊处理。
nextValue 就是利用最新的 Signal 值再计算得出新的 Memo 值。
再来看一下对 Memo 的特殊处理:writeSignal(node as Memo<any>, nextValue, true);
其实也就是利用 writeSignal 去手动触发 Signal 的更新,走更新流程。具体流程和案例一的更新流程是一致的。
更新 Computation 的流程图如下:
虽然我们还在从 readSignal 的角度去看,但是更新的(writeSignal)也看的差不多了。
再看一下 writeSignal 的变化吧。
writeSignal
export function writeSignal(node: SignalState<any> | Memo<any>, value: any, isComp?: boolean) {
let current = node.value;
if (!node.comparator || !node.comparator(current, value)) {
node.value = value;
// 通知观察者更新
if (node.observers && node.observers.length) {
runUpdates(() => {
for (let i = 0; i < node.observers!.length; i += 1) {
const o = node.observers![i];
// 根据不同情况,将 effect 加入不同的更新队列中
// 如果当前 effect 的状态还未处于更新,只加入更新队列
if (!o.state) {
// TODO: pure、Updates、Effects
if (o.pure) Updates!.push(o);
else Effects!.push(o);
// memo 比较特殊,因为它是一个 effect 和 signal 的结合体,所以还需要处理它的 observers
if ((o as Memo<any>).observers) markDownstream(o as Memo<any>);
}
// 这边就可以看到 STALE 是用于标识当前数据已经过期了,需要更新
if (!TransitionRunning) o.state = STALE;
else o.tState = STALE;
}
if (Updates!.length > 10e5) {
Updates = [];
if ("_SOLID_DEV_") throw new Error("Potential Infinite Loop Detected.");
throw new Error();
}
}, false);
}
}
return value;
}
这里多了两个新的地方:
- 一个是
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
/**
* 向下查找 监听当前 memo 的 observers,通知所有 observer 更新
* 同时如果监听的是 memo,也需要向下查找所有依赖的 memo,直至所有 observer 都通知完成
*/
function markDownstream(node: Memo<any>) {
for (let i = 0; i < node.observers!.length; i += 1) {
const o = node.observers![i];
if (!o.state) {
// 这里是使用 PENDING 状态的地方
// 原因就是当前 node 还未更新,监听它的 observer 需要暂时等待
o.state = PENDING;
// 这里和之前的判断逻辑类似
if (o.pure) Updates!.push(o);
else Effects!.push(o);
(o as Memo<any>).observers && markDownstream(o as Memo<any>);
}
}
}
这个和上面已经讲过的 lookUpstream 是类似的,之前那个是处理 sources 的,而这个是专门处理 observers 的。
里面的处理和刚刚的 writeSignal 的类似的,除了状态是 PENDING。
markDownstream 流程图如下:
注:
markDownstream和上面提过的lookUpstream是专门用于处理Memo的observers和signals关系的。
在处理完成之后,就开始走 runUpdates 开始更新了,逻辑和之前都是一样的了,因为讲 readSignal 的时候已经差不多讲过更新流程了。就不再赘述了。
我们再回到案例看看它的输出结果吧,看看和大家想的一不一样。
const [signal, trigger] = createSignal(1)
const memo = createMemo(() => signal() * 2)
// computation1
createEffect(() => {
console.log('track1', signal())
})
// computation2
createEffect(() => {
console.log('track2', memo())
})
trigger(Math.random() * 100)
// track1 1
// track1 2
// track2 randomNum * 2
// track1 randomNum
这里要注意一点,Memo 的 observers 是先触发的,我们再从源码上看一下。
首先,在初始化完成之后 Signal 的 observers 应该是 [memo, computation1] ,而 Memo 的 observers 为 [computation2] 。
那么在 trigger 之后,Signal 会通知其 observers 进行更新,但是呢,第一个 observer 是 Memo,那么,就会多走一步,markDownstream,去通知 Memo 的 observers 去更新。所以说,这就是导致 computation2 比 computation1 先执行的原因。
我们再换个位置来看看。
const [signal, trigger] = createSignal(1)
// computation1
createEffect(() => {
console.log('track1', signal())
})
const memo = createMemo(() => {
console.log('memo track')
return signal() * 2
})
// computation2
createEffect(() => {
console.log('track2', memo())
})
trigger(Math.random() * 100)
// track1 1
// memo track
// track1 2
// memo track
// track2 randomNum * 2
// track1 randomNum
初始化没问题,顺序执行,更新的时候,两个 Computation 的执行也没问题,因为现在 Signal 的 observers 位置是 [computation1, memo] 。
我们来看看为什么 Memo 的执行优先级比 Effect 高吧,这时候大家可能会想起之前讲的两个队列:Updates、Effects。对就是这两个,Memo 会加入到 Updates 队列,执行的优先级是比 Effects 要高的。
到这里,我们也差不多了解了 Solid 响应式,以及 Solid 的响应式过程。
这里我放一张完整的流程图。
案例三
我们再通过一个案例来回顾一下之前所讲的内容。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Signal</title>
</head>
<body>
<div>
<span>Signal1: </span>
<span id="signal1"></span>
</div>
<div>
<span>Signal2: </span>
<span id="signal2"></span>
</div>
<div>
<span>Memo1: </span>
<span id="memo1"></span>
</div>
<div>
<button id="trigger">Trigger</button>
</div>
<script type="module" src="./signal-dom.js" />
</body>
</html>
signal-dom.js
import { createSignal, createEffect, createMemo } from "../dist/solid.js";
const signal1Elem = document.getElementById('signal1')
const signal2Elem = document.getElementById('signal2')
const memo1Elem = document.getElementById('memo1')
const triggerElem = document.getElementById('trigger')
const [signal1, trigger1] = createSignal(1)
const [signal2, trigger2] = createSignal('1')
const memo1 = createMemo(() => signal1() * 2)
// computation1
createEffect(() => {
console.log('track1', signal1() + '_' + signal2())
signal1Elem.textContent = signal1() + '_' + signal2()
})
// computation2
createEffect(() => {
console.log('track2', signal2())
signal2Elem.textContent = signal2()
})
// computation3
createEffect(() => {
console.log('track2', memo1())
memo1Elem.textContent = memo1()
})
triggerElem.addEventListener('click', () => {
trigger1(Math.random() * 100)
trigger2(`${Math.random() * 100}`)
})
案例很简单,只是正常的 Solid 使用,多了些 DOM 的交互。
我们来看看它们的依赖关系:
-
signal1observers:[memo1,computation1]observerSlots: [0,0]
-
signal2observers:[computation1,computation2]observerSlots: [1,0]
-
memo1observers:[computation3]observerSlots: [0]sources:[signal1]sourceSlots: [0]
-
computation1sources:[signal1,signal2]sourceSlots: [1,0]
-
computation2sources:[signal2]sourceSlots: [1]
-
computation3sources:[memo1]sourceSlots: [0]
其实把这一块内容搞清楚了,Signal 的响应式也就差不多了解了。
现在再回过头来看,我们会熟知 Solid 内部做了哪些处理,了解了其原理,定位问题也会变得简单很多。
总结
文中的案例地址:
如果了解过 Vue 响应式原理的小伙伴们,看完 Solid 的响应式原理之后,会很熟悉。
它们形上是一样的,只是实现的方式不一样。
Vue 是通过 Proxy 去实现的响应式,而 Solid 则是自己写了一套响应式。
Solid 的响应式主要就是通过这四个属性去实现的:sources & sourceSlots & observers & observerSlots。
所以了解它们的原理很重要,文章中我也专门抽出了一小部分去专门讲了这块内容。
了解了这四个属性的工作方式,那么也差不多了解了 Solid 的响应式原理了。
最后,以 Singal 响应式的流程图作为结尾