SolidJS源码解读 - 用函数闭包完结高功能发布订阅形式

在最新的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屡次遍历,才干找到详细要更新哪些节点。

SolidJS源码解读 - 用函数闭包完结高功能发布订阅形式

为了处理这个问题,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 的完好表现力,十分具有表现力,能够构建十分复杂的组件。

但是灵敏的语法,也意味着引擎难以了解,无法预判开发者的用户目的,然后难以优化功能。你很可能会写出下面的代码:

SolidJS源码解读 - 用函数闭包完结高功能发布订阅形式

在运用 JavaScript 的时分,编译器不可能hold住一切可能产生的工作,由于 JavaScript 太过于动态化使得很难做目的剖析。也有人对这块做了许多尝试,但从实质上来说很难提供安全的优化。

2.2 Template优缺点

Template模板是一种十分有束缚的语言,你只能以某种方式去编写模板。

例如,当你写出这样的代码的时分,编译器能够马上明白: ”哦!这些 p 标签的次序是不会变的,这个 id 是不会变的,这些 class 也不会变的,唯一会变的便是这个“

SolidJS源码解读 - 用函数闭包完结高功能发布订阅形式

在编译时,编译器对你的目的能够做更多的预判,然后给它更多的空间去做履行优化。左边 template 中,其他一切内容都是静态的,只要 name 可能会产生改动。

2.3 SolidJS的优化

SolidJS选用的计划是:在JSX的基础上做了一层规范,中文译名为操控流,防止了编译器难以了解的代码。这样即学习了 template 更容易做编译阶段优化的优势,又保留了JSX的灵敏性,以 For 为例

SolidJS源码解读 - 用函数闭包完结高功能发布订阅形式

假如不运用引荐的操控流句子

SolidJS源码解读 - 用函数闭包完结高功能发布订阅形式

能够看到,不运用引荐的操控流句子时,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 相同的发布订阅形式,接下来咱们就来看看它是如何完结的。

SolidJS源码解读 - 用函数闭包完结高功能发布订阅形式

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 数据劫持完结依靠收集,而是用函数闭包的形式保存依靠,函数内回来gettersetter 办法,十分的奇妙,不仅减少了这些 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 的完结

SolidJS源码解读 - 用函数闭包完结高功能发布订阅形式

创立 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,让cOwner 构成彼此引证的关系。随后回来 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 功能瓶颈的确高 gitee.com/huang-guanz…

参考文献

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