在最新的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
为例
如果不使用推荐的控制流语句
可以看到,不使用推荐的控制流语句时,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 的 useMemo
,mapArray
中做的事情就是会比较新老数组,仅更新变化的节点,并尽可能的从旧节点中复用,和 Diff 的思路很像。
3. 响应式的实现
令人唏嘘,细粒度的DOM更新在十年前被认为是古老而缓慢的技术,可如今 SolidJS 竟然靠它夺得性能冠军,很大一部分功劳都来自于它成功解决了内存消耗问题。
虽然 SolidJS 的语法与 React 几乎一致,但它响应式的实现是和 Vue 一样的发布订阅模式,接下来我们就来看看它是如何实现的。
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.defineProperty
或 Proxy
数据劫持实现依赖收集,而是用函数闭包的形式保存依赖,函数内返回 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
分支内的逻辑即可。
此处的 this
是 createSignal
中创建的响应式数据 s
,可以看出,this.observers
就是 Dep
,正如节点级更新框架之名,一个数据对应一个 Dep
。Dep
中存放复数的观察者Wathcer
(也就是此处的Listener
),那么 Listener
从何而来,那么就要从看接下来的观察者Wathcer
的实现了
3.2 观察者 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
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 性能瓶颈确实高
试玩地址 源码地址