reactivity

SolidJS源码解读 - 用函数闭包实现高性能发布订阅模式 - 掘金

令人唏嘘,细粒度的 DOM 更新在十年前被认为是古老而缓慢的技术,可如今 SolidJS 竟然靠它夺得性能冠军,很大一部分功劳都来自于它成功解决了内存消耗问题。

虽然 SolidJS 的语法与 React 几乎一致,但它响应式的实现是和 Vue 一样的发布订阅模式,接下来我们就来看看它是如何实现的。

发布者 Dep 的实现

createSignal

export function createSignal<T>(value?: T, options?: SignalOptions<T>): Signal<T | undefined> {
  options = options ? Object.assign({}, signalOptions, options) : signalOptions;

  const s: SignalState<T> = {
    value,
    observers: null,
    observerSlots: null,
    comparator: options.equals || undefined
  };

  const setter: Setter<T | undefined> = (value?: unknown) => {
    if (typeof value === "function") {
      if (Transition && Transition.running && Transition.sources.has(s)) value = value(s.tValue);
      else value = value(s.value);
    }
    return writeSignal(s, value);
  };

  return [readSignal.bind(s), setter];
}

SolidJS 不像 Vue 那样使用 Object.definePropertyProxy 数据劫持实现依赖收集,而是用函数闭包的形式保存依赖,函数内返回 getter 和 setter 方法,非常的巧妙,不仅减少了这些 API 的大量内存消耗,也解决了 Proxy 的目标必须是对象的问题 (没错,吐槽的就是 Vue3 的 ref.value)。尤其在响应式数据是大对象或大数组时,由于 Object.defineProperty 对所有属性的递归监听,会造成严重的性能问题。

SolidJS 则将对象整体作为一个 Signal,更新 Signal 需要像 React 一样调用 setXXX 方法整体更新,虽然避免了性能问题,但并不完美。

在以下示例中,我们在一个 Signal 中存放待办事项列表。为了将待办事项标记为完成,我们需要用克隆对象替换旧的待办事项。尽管在 JSX 中使用 For 语句会进行差异对比,但仍然造成了不必要的性能浪费

const [todos, setTodos] = createSignal([])
setTodos(pre => pre.map((todo) => (todo.id !== id ? todo : { ...todo, completed: !todo.completed }))

但这并不代表 SolidJS 不能像 Svelte 一样细粒度的更新对象内的属性,否则怎么对得起细粒度响应式框架的称号,在 SolidJS 中,我们可以使用嵌套的 Signal 初始化数据

const initTodos = () => {
    const res = []
    for (let i = 0; i < 10; i++) {
        const [completed, setCompleted] = createSignal(false);
        res.push({id: i, completed, setCompleted})
    }
    setTodos(res)
}
    
const toggleTodo = (index) => {
    todos()[index].setCompleted(!todo.completed())
}

之后我们可以通过调用 setCompleted 来更新,而无需任何额外的差异对比。因为我们确切地知道数据要如何变化, 所以我们可以将复杂性转移到数据而不是视图。

const toggleTodo = (index) => {
    todos()[index].setCompleted(!todo.completed())
}

这正是 SolidJS 的魅力所在,基于函数实现的响应式 API 非常自由,既可以避免 Object.defineProperty 的全量监听带来的性能浪费,同时支持自定义的细粒度响应式数据控制,将性能压榨到了极致。

readSignal

export function readSignal(this: SignalState<any> | Memo<any>) {
  const runningTransition = Transition && Transition.running;
  if (
    (this as Memo<any>).sources &&
    ((!runningTransition && (this as Memo<any>).state) ||
      (runningTransition && (this as Memo<any>).tState))
  ) {
    if (
      (!runningTransition && (this as Memo<any>).state === STALE) ||
      (runningTransition && (this as Memo<any>).tState === STALE)
    )
      updateComputation(this as Memo<any>);
    else {
      const updates = Updates;
      Updates = null;
      runUpdates(() => lookUpstream(this as Memo<any>), false);
      Updates = updates;
    }
  }
  if (Listener) {
    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);
    }
  }
  if (runningTransition && Transition!.sources.has(this)) return this.tValue;
  return this.value;
}

Transition 的逻辑非主分支逻辑可跳过不看 (此后的这部分逻辑都可跳过),重点看 Listener 分支内的逻辑即可。

此处的 thiscreateSignal 中创建的响应式数据 s ,可以看出,this.observers 就是 Dep,正如节点级更新框架之名,一个数据对应一个 DepDep 中存放复数的观察者 Wathcer(也就是此处的 Listener),那么 Listener 从何而来,那么就要从看接下来的观察者 Wathcer 的实现了

观察者 Wathcer 的实现

创建 Wathcer 是在 effect 函数内实现的,effect 函数是 createRenderEffect 函数的别名

createRenderEffect

export function createRenderEffect<Next, Init>(
  fn: EffectFunction<Init | Next, Next>,
  value?: Init,
  options?: EffectOptions
): void {
  const c = createComputation(fn, value!, false, STALE, "_SOLID_DEV_" ? options : undefined);
  if (Scheduler && Transition && Transition.running) Updates!.push(c);
  else updateComputation(c);
}

createComputation

createComputation 看似很长,但剔除无关逻辑后只走了这一行

if (!Owner.owned) Owner.owned = [c];`

根据 fn 创建出的 Computation 赋值给 Owner.owned,让 c 和 Owner 形成相互引用的关系。随后返回 c 进入 updateComputation(c) 函数

updateComputation

updateComputation 函数中将 node ,也就是 createComputation 返回的 c 赋给了 Listener,我们仿佛看见了胜利的曙光

runComputation

重点关注这一行

nextValue = node.fn(value);

node.fn 则是 () => _el$.value = text(),在访问 text() 时调用 readSignal,至此完成了整个依赖收集的过程。

可以看出 SolidJS 作者的 JavaScript 基本功非常扎实,对 【闭包】【函数是 JavaScript 中的一等公民】 两项特性运用得淋漓尽致,优雅的实现了高性能发布订阅模式框架。