Solid 之旅 —— Signal 响应式原理

561 阅读16分钟

在本文中,我将通过三个案例来深入到 Solid 源码中解析 Signal 响应式的原理,并通过图解的方式更清晰的展示给大家。

在上一篇文章中,我也讲了该如何去调试 Solid 源码,这里将不再过多赘述,如果不知道的小伙伴,可以再去看一下。

Solid 之旅 —— 调试源码篇(其他框架也适用) - 掘金 (juejin.cn)

前置

这里先提前讲一下 SignalComputationMemo 的类型,方便后续调试的时候理解其含义。

注:含 t 开头的属性(tValuetState …),都是和 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 本质上就是 SingalComputation 的结合体。

全局变量

// 用于 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

第一个案例,我们先以最简单的 createSignalcreateEffect 来演示。

先了解其 依赖收集 的方式和 响应式原理

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: 观察该 SignalComputation
  • observerSlots: 这是对应 observerseffectsources 对应自身的下标位置

readSignalwriteSignal 先不着急去看,我们先按案例的流程走,后续再回来看这两个函数。

接下来,我们再来看看 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: 依赖收集的 Signal
  • sourceSlots: 对应 sourcesSignalobservers 对应自身的位置

sourcessourceSlots 后续会详细讲解,这里先了解一下即可。

接下来,再回到 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;
}

这里可以看到,进行依赖收集了,当前执行的 ComputationListener)和 Signal 相互进行了收集,最后再返回当前 Signal 值。

我们主要看一下依赖收集的逻辑。

依赖收集解析

防止小伙伴忘了这四个属性是啥意思,我这里再声明一下:

  • Signal

    • observers: 观察该 SignalComputation

    • observerSlots: 这是对应 observersComputationsources 对应自身的下标位置

      (observers[i] as Computation).sources[observerSlots[]i] -> 本身

  • Computation

    • sources: 依赖收集的 Signal

    • sourceSlots: 对应 sourcesSignalobservers 对应自身的位置

      (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 里的位置怎么得来呢?

按照刚刚说明的方法,对应 sourcesSignalobservers 对应自身的位置。

也就是 signal.observers.length,因为是新增的嘛,所以 signal.observers 的长度就是该 Listener 的下标位置,而 signal.observers 不存在时,也就是 0,也就是从空到下标为0的过程。

再来看看 Signal,也是同理,先在 observers 里添加当前的 ComputationListener),也就是 [Listener] ,可以看出来就是存储在下标为0的位置,与 Listener.sourceSlots 相对应。

observerSlots 也是一样的,对应 observersComputationsources 对应自身的下标位置。

也就是下标0,在上面 Listener.sources 中已经存储了。

可能看文字还是有点云里雾里的,我再放一张图来展示一下其中的关系:

无标题-2024-08-16-1643.png

现在可能就清晰明了一点了。

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 依赖了 signal1signal2computation2 依赖了 signal1

大家可以先想想这四个对象之间的依赖关系,我下面直接给出图例解释:

SignalComputation

无标题-2024-08-16-1643-1.png

ComputationSignal

无标题-2024-08-16-1643-2.png

现在是不是就清晰很多了呢,如果还是有点懵的话,再回头看看关于四个属性的解释吧。

我们现在再回到 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 的内部关系、状态等内容。

这里面就用到了上面所讲到的四元素:sourcessourceSlotsobserversobserverSlots

利用 sourcesourceSlots 分别找到了

  • 所依赖的 Signal
  • Signal 中该 node 在对应 observerSlots 中的下标位置(也就能找到在 observers 中对应的 node 了)

忘记这之前关系的小伙伴,再来重温一下这张图。

无标题-2024-08-16-1643-2 (1).png

找到这两个位置之后,再利用 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;
}

整体上也就做了两件事:

  • 更新 Signalvalue
  • 通知 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;它是 ComputationSignal 的结合体。那么就会存在一种情况,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,重新执行其副作用函数。

更新的流程差不多就是这样,其实也是很简单的。

我们再通过流程图整体看一下 readSignalwriteSignal 两个部分吧。

无标题-2024-08-16-1643-3.png

现在只是简单的展示一下执行流程,整体的执行过程将在案例二中展示。

最后,再回到案例看看,它的执行结果吧。

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

看着是不是有点熟悉,这就是相当于 createSingalcreateEffect 结合啊,完全一模一样。

注意两个点:

  • 一个是 createComputation 传入的第三个参数,pure = true。而 createEffect 传入的是 false。

    这个后面更新的时候会用到,先留意一下。

  • 另一个是 createMemo 返回的是一个只读的 Signal。注意它是通过 readSignal 来读取的。

根据案例,我们先来看看 Memo 的依赖关系,也能更好的理解它为什么是 Signal + Computation

memo.png

我们再回过来看看 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 更新产生的,因为依赖此 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

/**
 * 向上查找 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 流程图如下:

lookUpstream.png

我们再会过来,重新看看 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 的特殊优化处理。

更新流程图如下:

update.png

我们继续看看 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 状态的处理。

还是一样的意思,如果当前 nodePENDING 状态,那么它所依赖的 **Signal(Memo)**肯定有未更新完成的,需要利用 lookUpstream 先去更新当前 node 的依赖。

runTop 流程图如下:

runTop.png

还是提一嘴,lookUpstream 其实也是变相的去更新 Computation 了,只是是倒过来,通过依赖更新再返回过通知当前 Computation 更新。

后续,就是在走 updateComputationrunComputation 函数去更新 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 的流程图如下:

computation update.png

虽然我们还在从 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 的时候也提到过了,可以暂时理解为区分 createEffectcreateMemo 的;另一点原因,也就是前面提到的 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.png

注:markDownstream 和上面提过的 lookUpstream 是专门用于处理 Memoobserverssignals 关系的。

在处理完成之后,就开始走 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

这里要注意一点,Memoobservers 是先触发的,我们再从源码上看一下。

首先,在初始化完成之后 Signalobservers 应该是 [memo, computation1] ,而 Memoobservers[computation2]

那么在 trigger 之后,Signal 会通知其 observers 进行更新,但是呢,第一个 observerMemo,那么,就会多走一步,markDownstream,去通知 Memoobservers 去更新。所以说,这就是导致 computation2computation1 先执行的原因。

我们再换个位置来看看。

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 的执行也没问题,因为现在 Signalobservers 位置是 [computation1, memo]

我们来看看为什么 Memo 的执行优先级比 Effect 高吧,这时候大家可能会想起之前讲的两个队列:UpdatesEffects。对就是这两个,Memo 会加入到 Updates 队列,执行的优先级是比 Effects 要高的。

到这里,我们也差不多了解了 Solid 响应式,以及 Solid 的响应式过程。

这里我放一张完整的流程图。

all.png

案例三

我们再通过一个案例来回顾一下之前所讲的内容。

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 的交互。

我们来看看它们的依赖关系:

  • 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 内部做了哪些处理,了解了其原理,定位问题也会变得简单很多。

总结

文中的案例地址:

Serendipity/packages/solid/solid-v1.8.19/packages/solid/example at feat/solid · JinSooo/Serendipity (github.com)

如果了解过 Vue 响应式原理的小伙伴们,看完 Solid 的响应式原理之后,会很熟悉。

它们形上是一样的,只是实现的方式不一样。

Vue 是通过 Proxy 去实现的响应式,而 Solid 则是自己写了一套响应式。

Solid 的响应式主要就是通过这四个属性去实现的:sources & sourceSlots & observers & observerSlots

所以了解它们的原理很重要,文章中我也专门抽出了一小部分去专门讲了这块内容。

了解了这四个属性的工作方式,那么也差不多了解了 Solid 的响应式原理了。

最后,以 Singal 响应式的流程图作为结尾

all.png

参考链接:

  1. Solid Docs (solidjs.com)
  2. GitHub - solidjs/solid: A declarative, efficient, and flexible JavaScript library for building user interfaces.