浅析无虚拟DOM的SolidJS如何实现响应式

2,270 阅读2分钟

我正在参加「掘金·启航计划」

又来?

前段时间刚写浅析无虚拟DOM的Svelte如何实现响应式一看已经是一个月前了),想着也一起分析一下另一个无虚拟DOM的代表框架:SolidJSwww.solidjs.com/ )。毕竟Vue未来的无虚拟DOM实现看起来会参照SolidJS

SolidJS的响应式实现原理是不是和Svelte一样,重编译呢?显然不是,不然也就没有这篇文章了。

Hello World

为了方便了解其源码执行过程,先写一个简单的Hello World来看看:

import { render } from 'solid-js/web';

function HelloWorld() {
  const name = 'world';
  return <div>Hello {name}!</div>;
}

render(() => <HelloWorld />, document.getElementById('app'))

看这里的实现挺简单的,另外,SolidJS基本都使用JSX来编写组件。

再来看看其编译后的结果:

import { template as _$template } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";

const _tmpl$ = /*#__PURE__*/_$template(`<div>Hello world!</div>`, 2);

import { render } from 'solid-js/web';

function HelloWorld() {
  const name = 'world';
  return _tmpl$.cloneNode(true);
}

render(() => _$createComponent(HelloWorld, {}), document.getElementById('app'));

这代码了看起来比Svelte的少多了啊,看来运行时应该做了挺多东西的。

接着我们先把这边涉及的一些主要方法都看看干了啥。先是:

const _tmpl$ = /*#__PURE__*/_$template(`<div>Hello world!</div>`, 2);

template的实现就是:

export function template(html, check, isSVG) {
  const t = document.createElement("template");
  t.innerHTML = html;
  // ...一些代码
  let node = t.content.firstChild;
  if (isSVG) node = node.firstChild;
  return node;
}

看起来就是生成了模板代码,方便后面_tmpl$.cloneNode(true)直接clone一份,没有像Svelte一样把创建DOM的过程完全展开,这里少了很多代码啊!

注意:当你自己观察后,会发现HelloWorld函数中的name常量没有被使用,其值已经在编译时直接插入了。

接下来是核心代码:

render(() => _$createComponent(HelloWorld, {}), document.getElementById('app'));
// 第二个参数比较简单,就化简一下
render(() => _$createComponent(HelloWorld, {}), dom);

这里涉及到两个方法,先看_$createComponent吧:

export function createComponent<T>(Comp: Component<T>, props: T): JSX.Element {
  // ...一些代码
  return untrack(() => Comp(props || ({} as T)));
}

又涉及到一个untrack方法,其和响应式实现相关,先简单看看:

export function untrack<T>(fn: Accessor<T>): T {
  let result: T,
    listener = Listener;

  Listener = null;
  result = fn();
  Listener = listener;

  return result;
}

可以理解为不收集依赖地执行方法,那么:

_$createComponent(HelloWorld, {})
// 等同于
untrack(() => HelloWorld({}))
// 为表示方便,untrack 化简为前缀 u
uHelloWorld({})
// 那么render方法也可以化简为
render(() => uHelloWorld({}), dom)

看了SolidJS的源码,发现里面充斥着各种箭头函数,一不小心就容易迷失在这是哪一层函数的困惑中。

最后来看看render方法,而这个方法是dom-expressions库提供的

export function render(code, element, init) {
  let disposer;
  root(dispose => {
    disposer = dispose;
    element === document
      ? code()
      : insert(element, code(), element.firstChild ? null : undefined, init);
  });
  return () => {
    disposer();
    element.textContent = "";
  };
}

在后续研究中,会发现dispose其实是销毁时清理用的,暂时可以先不管,这样进一步化简:

render(() => uHelloWorld({}), dom)
// 等效为
root(() => insert(dom, uHelloWorld({}), undefined, false))

这里的root引用自rxcore,但是你是找不到这个库的,这个库是需要引用babel-plugin-transform-rename-import插件来引入。而这个库的作用就是实现数据响应式。在SolidJS的源码中,提供了这个库的实现。(这样的做法自己之前没见过,觉得有些耦合啊

说了这么多,root方法就等效于createRoot,这块逻辑也有好几层,不过可以先简单当作root(fn) == fn()。接着就是insert方法:

export function insert(parent, accessor, marker, initial) {
  if (marker !== undefined && !initial) initial = [];
  if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);
  effect(current => insertExpression(parent, accessor(), current, marker), initial);
}

将参数代入之后:

root(() => insert(dom, uHelloWorld({}), undefined, false))
// 简单当作
insert(dom, uHelloWorld({}), undefined, false)
// 代入,相当于
insertExpression(dom, uHelloWorld({}), false, undefined)

又有一个insertExpression

function insertExpression(parent, value, current, marker, unwrapArray) {
  // ...一些代码
  if (t === "string" || t === "number") {
    // ...一些代码
  } else if (value == null || t === "boolean") {
    // ...一些代码
  } else if (t === "function") {
    // ...一些代码
  } else if (Array.isArray(value)) {
    // ...一些代码
  } else if (value instanceof Node) {
    // ...一些代码
    parent.appendChild(value);
    // ...一些代码
  }

  return current;
}

insertExpression方法比较复杂,把绝大部分逻辑都隐藏了,最终:

insertExpression(dom, uHelloWorld({}), false, undefined)
// 约等于
dom.appendChild(uHelloWorld({}))

这么一系列操作流程看下来,终于走通了其渲染流程,接下来看看其响应式如何实现的吧

响应式

也搞一个简单的例子来看看,逻辑就是每秒自增1

import { render } from 'solid-js/web';
import { createSignal } from "solid-js";

function HelloWorld() {
  const [count, setCount] = createSignal(1);
  setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return <div>Hello {count()}!</div>;
}

render(() => <HelloWorld />, document.getElementById('app'))

其编译后如下:

import { template as _$template } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";

const _tmpl$ = /*#__PURE__*/_$template(`<div>Hello <!>!</div>`, 3);

import { render } from 'solid-js/web';
import { createSignal } from "solid-js";

function HelloWorld() {
  const [count, setCount] = createSignal(1);
  setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return (() => {
    const _el$ = _tmpl$.cloneNode(true),
          _el$2 = _el$.firstChild,
          _el$4 = _el$2.nextSibling,
          _el$3 = _el$4.nextSibling;

    _$insert(_el$, count, _el$4);

    return _el$;
  })();
}

render(() => _$createComponent(HelloWorld, {}), document.getElementById('app'));

看着代码量稍多,但也没有多多少。这边有一个明显与响应式相关的方法,就是createSignal,类似Vue 3里面的ref。其源码实现还是稍稍复杂的,这边就借用SolidJS官网的一段描述性代码来理解一下:

function createSignal(value) {
  const subscribers = new Set();

  const read = () => {
    const listener = getCurrentListener();
    if (listener) subscribers.add(listener);
    return value;
  };

  const write = (nextValue) => {
    value = nextValue;
    for (const sub of subscribers) sub.run();
  };

  return [read, write];
}

所以,其核心就是发布订阅模式,在运行时,自动收集读取的listener,等到值修改时,再通知所有的订阅者。

那么这里就有一个核心问题:怎么获取到当前读取值的listener,也就是上面代码的getCurrentListener()。在SolidJS中,其实就是弄了一个全局变量ListenerL大写)来记录当前的监听者。

感觉是不是写到响应式就会搞个全局变量,比如Vue2中,也有Dep.target来记录,其效果和这里的Listener差不多。虽然全局变量可以非常巧妙得缓存状态,不需要层层传递,但是感觉代码可读性会下降很多,另外,万一代码写了小失误,感觉很难找到原因啊。

现在可以再回头看看之前的untrack方法,其就是把Listener置为null之后,再执行方法,这样在读取Signal的时候,也不会自动监听了。

而根据我们之前写Hello World的时候的分析,最终会执行uHelloWorld({}),那不是应该不会有响应式了么!毕竟Listener已经被untrack方法置空了。

后头看有使用count的地方,也就只有_$insert(_el$, count, _el$4),而insert方法上文也已经给出源代码了。这里和之前分析的一个区别就是count是函数,而之前分析Hello World的时候,这里就是个Node。也就是说其走了另外一个分支:

_$insert(_el$, count, _el$4)
// 走到另外一个分支,相当于
effect(current => insertExpression(_el$, count(), current, _el$4), []);

看到了effect,其就是createEffect,而浏览一下createEffect的源码就会发现,这边会重新将Listener赋值,这样也就可以正常走入发布订阅流程了,而其节点Diff过程,都在insertExpression里面,只不过是直接对比DOM。

小结一下

SolidJS相对Svelte来说,编译时轻了很多,编译生成的代码量也少了很多,当然,它:

  • Vue类似,在运行时自动收集依赖和触发Effect来实现响应式
  • 触发Effect时,直接对真实DOM做简易Diff,然后渲染

另外,感觉SolidJS的源码看起来耦合挺严重,又有很多箭头函数,导致很难理清里面的逻辑,当然,这也可能是自己水平还有待提高。