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

2,401 阅读10分钟

JS 框架性能测试对比

在最新的Benchmark中,可以看到SolidJS以近乎原生的1.05夺得性能冠军,号称无Virtual DOM的细粒度响应式框架SolidJS究竟为什么这么快,今天我就来解读一下。

1. Virtual DOM真的高效吗

1.1 react和vue的Virtual DOM

  • React对数据的处理是不可变(immutable):具体表现是整树更新,更新时,不关注是具体哪个状态变化了,只要有状态改变,直接整树 Diff 找出差异进行对应更新。
  • Vue对数据的处理是响应式、可变的(mutable):更新时,能够精确知道是哪些状态发生了改变,能够实现精确到组件级的更新。

Rich Harris 在设计 Svelte 的时候没有采用 Virtual DOM 是因为觉得 Virtual DOM Diff 的过程是非常低效的。人们觉得 Virtual DOM 高效的一个理由,就是它不会直接操作原生的 DOM 节点。在浏览器当中,JavaScript的运算在现代的引擎中非常快,但DOM本身是非常缓慢的东西。当你调用原生 DOM API 的时候,浏览器需要在JavaScript引擎的语境下去接触原生的DOM的实现,这个过程有相当的性能损耗。

比如说,下面的例子中,React 为了更新 message 对应的DOM节点,需要做n多次遍历,才能找到具体要更新哪些节点。

为了解决这个问题,React 提供 pureComponent, shouldComponentUpdate, useMemo, useCallback 让开发者来操心哪些 subtree 是需要重新渲染的,哪些是不需要重新渲染的。究其本质,是因为 React 采用的 JSX 语法过于灵活,难以理解开发者写出代码所代表的意义,没有办法做出优化。

而 Vue2 虽然实现了精确到组件级别的响应式更新,但对于组件内部的DOM节点还是需要进行n多次遍历的 Diff 运算。直到Vue3中借鉴Svelte的思路使用静态提升对渲染函数做 AOT (ahead-of-time,可以理解为预编译) 优化后,才使得性能进一步提升。

1.2 更新粒度对比

  • 应用级:有状态改变,就更新整个应用,生成新的虚拟DOM树,与旧树进行 Diff(代表作:React,当然了,现在它的虚拟DOM已升级为了 Fiber )。
  • 组件级:与上方类似,只不过粒度小了一个等级(代表作:Vue2 及之后的版本)。
  • 节点级:状态更新直接与具体的更新节点的操作绑定(代表作 vue1.x 、Svelte、SolidJS)。

Vue1.x 时代,对于数据是每个生成一个对应的 Wather,更新颗粒度为节点级别,但这样创建大量的 Wather 会造成极大的性能开销,因此在 Vue2.x 时代,通过引入虚拟DOM优化响应,改为了组件级颗粒度的更新。

而对于 React 来说,虚拟 DOM 就是至关重要的部分,甚至是核心,React是属于应用级别的更新,因此整个DOM树的更新开销是极大的,所以这里对于 Virtual DOM Diff 的使用就是极其必要的。

1.3 是否采用虚拟DOM

这个选择是与上边采用何种粒度的更新设计紧密相关的:

  • :对应用级的这种更新粒度,虚拟DOM简直是必需品,因为在 Diff 前它并不能得到此次更新的具体节点信息,必须要通过 Virtual DOM Diff 筛选出最小差异,不然整树 append 对性能是灾难(代表框架:React、vue)。
  • :对节点级更新粒度的框架来说,没有必要采用虚拟DOM(代表作:Svelte、SolidJS)

2.  预编译 (AOT) 阶段的代码优化

  • JSX阵营 :React/SolidJs
  • Template阵营 :Vue/Svelte

2.1 JSX 优缺点

JSX 具有 JavaScript 的完整表现力,非常具有表现力,可以构建非常复杂的组件。

但是灵活的语法,也意味着引擎难以理解,无法预判开发者的用户意图,从而难以优化性能。你很可能会写出下面的代码:

在使用 JavaScript 的时候,编译器不可能hold住所有可能发生的事情,因为 JavaScript 太过于动态化使得很难做意图分析。也有人对这块做了很多尝试,但从本质上来说很难提供安全的优化。

2.2 Template优缺点

Template模板是一种非常有约束的语言,你只能以某种方式去编写模板。

例如,当你写出这样的代码的时候,编译器可以立刻明白: ”哦!这些 p 标签的顺序是不会变的,这个 id 是不会变的,这些 class 也不会变的,唯一会变的就是这个“

在编译时,编译器对你的意图可以做更多的预判,从而给它更多的空间去做执行优化。左侧 template 中,其他所有内容都是静态的,只有 name 可能会发生改变。

2.3 SolidJS的优化

SolidJS 采用的方案是:在 JSX 的基础上做了一层规范,中文译名为 控制流,避免了编译器难以理解的代码。这样即借鉴了 template 更容易做编译阶段优化的优势,又保留了 JSX 的灵活性,以 For 为例

image.png

如果不使用推荐的控制流语句

image.png

可以看到,不使用推荐的控制流语句时,SolidJS 将流大括号内的流程语句编译为了一整个变量,每次 list 改变时会导致整个列表重新渲染,性能低下

那么For内部做了什么呢,我们打开源码看看

export function For<T, U extends JSX.Element>(props: {
  each: readonly T[] | undefined | null | false;
  fallback?: JSX.Element;
  children: (item: T, index: Accessor<number>) => U;
}) {
  const fallback = "fallback" in props && { fallback: () => props.fallback };
  return createMemo(
    mapArray<T, U>(() => props.each, props.children, fallback ? fallback : undefined)
  );
}

可以看到 For 的返回是 createMemo 包裹的 mapArray 方法,createMemo 类似 React 的 useMemomapArray 中做的事情就是会比较新老数组,仅更新变化的节点,并尽可能的从旧节点中复用,和 Diff 的思路很像。

3. 响应式的实现

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

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

image.png

3.1 发布者 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
  };

  if ("_SOLID_DEV_" && !options.internal)
    s.name = registerGraph(options.name || hashValue(value), s as { value: unknown });

  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的实现了

3.2 观察者 Wathcer 的实现

image.png

创建 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

function createComputation<Next, Init = unknown>(
  fn: EffectFunction<Init | Next, Next>,
  init: Init,
  pure: boolean,
  state: number = 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: null,
    pure
  };

  if (Transition && Transition.running) {
    c.state = 0;
    c.tState = state;
  }

  if (Owner === null)
    "_SOLID_DEV_" &&
      console.warn(
        "computations created outside a `createRoot` or `render` will never be disposed"
      );
  else if (Owner !== UNOWNED) {
    if (Transition && Transition.running && (Owner as Memo<Init, Next>).pure) {
      if (!(Owner as Memo<Init, Next>).tOwned) (Owner as Memo<Init, Next>).tOwned = [c];
      else (Owner as Memo<Init, Next>).tOwned!.push(c);
    } else {
      if (!Owner.owned) Owner.owned = [c];
      else Owner.owned.push(c);
    }
    if ("_SOLID_DEV_")
      c.name =
        (options && options.name) ||
        `${(Owner as Computation<any>).name || "c"}-${
          (Owner.owned || (Owner as Memo<Init, Next>).tOwned!).length
        }`;
  }

  if (ExternalSourceFactory) {
    const [track, trigger] = createSignal<void>(undefined, { equals: false });
    const ordinary = ExternalSourceFactory(c.fn, trigger);
    onCleanup(() => ordinary.dispose());
    const triggerInTransition: () => void = () =>
      startTransition(trigger).then(() => inTransition.dispose());
    const inTransition = ExternalSourceFactory(c.fn, triggerInTransition);
    c.fn = x => {
      track();
      return Transition && Transition.running ? inTransition.track(x) : ordinary.track(x);
    };
  }

  return c;
}

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

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

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

updateComputation

function updateComputation(node: Computation<any>) {
  if (!node.fn) return;
  cleanNode(node);
  const owner = Owner,
    listener = Listener,
    time = ExecCount;
  Listener = Owner = node;
  runComputation(
    node,
    Transition && Transition.running && Transition.sources.has(node as Memo<any>)
      ? (node as Memo<any>).tValue
      : node.value,
    time
  );

  if (Transition && !Transition.running && Transition.sources.has(node as Memo<any>)) {
    queueMicrotask(() => {
      runUpdates(() => {
        Transition && (Transition.running = true);
        runComputation(node, (node as Memo<any>).tValue, time);
      }, false);
    });
  }
  Listener = listener;
  Owner = owner;
}

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

runComputation

function runComputation(node: Computation<any>, value: any, time: number) {
  let nextValue;
  try {
    nextValue = node.fn(value);
  } catch (err) {
    if (node.pure) Transition && Transition.running ? (node.tState = STALE) : (node.state = STALE);
    handleError(err);
  }
  if (!node.updatedAt || node.updatedAt <= time) {
    if (node.updatedAt != null && "observers" in (node as Memo<any>)) {
      writeSignal(node as Memo<any>, nextValue, true);
    } else if (Transition && Transition.running && node.pure) {
      Transition.sources.add(node as Memo<any>);
      (node as Memo<any>).tValue = nextValue;
    } else node.value = nextValue;
    node.updatedAt = time;
  }
}

重点关注这一行

nextValue = node.fn(value);

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

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

DEMO

最后附上我用 SolidJS 写的DEMO,一款三消小游戏,体验感受是 SolidJS 性能瓶颈确实高
试玩地址 源码地址

参考文献

  1. SolidJS · 反应式 JavaScript 库
  2. 新兴前端框架 Svelte 从入门到原理 - 字节前端的文章 - 知乎
  3. 性能爆表的SolidJS - 小帅不太帅的文章 - 稀土掘金
  4. 又一个前端框架 Solid ?性能直逼原生 JS ?- peen的文章 - 稀土掘金