我正在参加「掘金·启航计划」
又来?
前段时间刚写浅析无虚拟DOM的Svelte如何实现响应式(一看已经是一个月前了),想着也一起分析一下另一个无虚拟DOM的代表框架:SolidJS( www.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中,其实就是弄了一个全局变量Listener(L大写)来记录当前的监听者。
感觉是不是写到响应式就会搞个全局变量,比如
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的源码看起来耦合挺严重,又有很多箭头函数,导致很难理清里面的逻辑,当然,这也可能是自己水平还有待提高。