缘起

在最早的时候,前端是 没有 vdom 的,事实上传统的前端开发往往依赖于 字符串拼接 来完成视图层的刷新,譬如下面这样的代码:

$('#app').click(() => {
  const text = "clicked"
  $('.button').html(`<span>${text}</span>`)
});

以上代码为伪代码,并 不能实际运行

大约在 2010年开始,陆续就有不同的框架为了解决这些问题,使用了不同的方式去 简化 这些操作:

  • string template 派系
    • backbone.js
    • angular.js
  • dom template 派系
    • Vue1
    • Knockout.js

这些前端框架 多多少少解决了一些问题,但是 实质上 没有真正的把 render 层的事情纳入考虑。

直到 2013 年左右,react 开始思考这个经典的问题时才得出一个答案1

Pete Hunt:React:重新思考典范实例的意义— JSConf EU

“Rerender on every change? That seemes expensive”

vdom 的诞生

vdom 的诞生是因为 两个关键的思考

  1. 声明式框架的涌现,使得操作 dom 的逻辑变成了声明式的,是不是可以让 数据驱动渲染,让数据和渲染直接挂钩
  2. dom 的操作已经集中通过框架处理了,那么是不是框架可以去 批处理 dom 来减少性能。

声明式与数据绑定

我们可以简单的举一个 vue1 的例子:

<div id="app">
	<h1>{{ message }}</h1>
	<input v-model="message" />
</div>
<script>
	// 创建一个 Vue 实例
	var app = new Vue({
		el: '#app',
		data: {
			message: 'Hello Vue.js 1.x!'
		}
	});
</script>

vue1 早期在设计上可以直接的替换 jquery,他的响应式逻辑在设计上 极为先进,但是 充满魔法。利用 defineProperty 绑定变更带来方便操作能力。

但是他没有解决渲染性能的问题,vue1 中是完全没有虚拟 dom,所以在较大规模的项目中,其刷新性能非常糟糕,好在 react 的实现给大家指出了一条明路。

虚拟 dom 的诞生

虚拟dom 正是在这样的场景下诞生的,有句话说的好 2

David Wheeler

All problems in computer science can be solved by another level of indirection

我们大可加个 中间层 来解决这个问题把:代码 -> dom,变为 代码->vdom->dom

让持有 vdom 的引擎去解决什么时候 批量更新 dom 就可以了,这并不是难事,好处是 显而易见 的:

  1. 跨平台,vdom 实现可以抽象,且在任何平台使用
  2. 减少更新,将多次操作合并为单次
  3. js 比 dom 快很多,diff 出最小的 dom 变更来减小 dom 更新成本,减少 reflow

在结合声明式数据绑定+ vdom 后,react 凑齐了两块最大的框架碎片,在前端框架的路上一骑绝尘,遥遥领先。

但是 vdom 真的一点问题都没有么?

vdom 劣势

vdom 对比直接操作 dom 显然是有成本的:

  • 当首次渲染时,会有大量的 vdom 对象生成,显然比 直接 innerHTML 插入慢
  • 更大的内存消耗
  • 更新越频繁、diff 越频繁,计算时间长

为了解决这些 vdom 的缺陷,大致有四派:

原教旨主义

原教旨主义者通常是 react 派系的绝对拥趸,他们认为 react 的性能并不是大的问题,我们大可用 shouldComponentUpdate 来解决 3

绝杀

你得承认,如果用户能自己 cover 掉问题,那问题显然不是问题。

中立派—时间会抹平一切

Time’s glory is to calm contending kings, to unmask falsehood, and bring truth to light. William Shakespeare

时间会冲刷一切,所以 react 决定用复杂的 fiber,通过对不同事件划分的优先级(lane 模型)的打断机制有一定程度的改善主线程卡顿的现象。

对此,我更愿意称呼其为 “鸵鸟策略” ,当然总体是有用的。

抱头鼠窜

你卡的了一时,卡的了一世么?

定向更新

定向更新是 vue 派系中的保守者,他们选择使用细粒度的状态控制来定向的触发更新,本质上他们和激进派的实现并无差别,最大的区别只是他们还在固守 vdom 的大旗而已。

对比粗糙的 react 更新 ,vue3 中的更新总是能直接的触及 component 中相对较小的一块 dom。

激进派

激进派是2022年开始出现的一些新派系,例如国内常常洗脑宣传的 vue vapor 模式就是一个典型的激进派,对比他的老师(solid-js)可以用下面这段对话精确描述4

什么你不用 vdom 了?

solidjs: react!js 的性能是有极限的,越是玩弄时间切片和 diff,就越会触及极限……除非成为超越 vdom 的存在

react、vue: solid 你到底要说什么?

solidjs: 我不要 vdom 拉!

去虚拟 dom 化

现阶段的无 vdom 大军大致有三个,除开 vue,另外两者都已经较为成熟,但他们的使用量遥遥落后,未来可见的是大概率 vue 将会是无 vdom 大军的主力。

  • svelte
  • solid
  • vue vapor

svelte

svelte 是 rollup 的作者 Rich Harris 开发的,他比较喜欢 the best API is no API at all 这个逻辑,所以干脆一点,直接用模板编译成定向更新代码好了:

<script>
	let name = 'world';
	function click () {
		name += 1
	}
</script>
 
<h1>Hello {name}!</h1>
<button on:click={click}>click</button>

例如这个代码会大致编译成下面这样:

function create_fragment(ctx) {
	let div;
	let h1;
	let t0;
	let t1;
	let t2;
	let t3;
	let button;
	let mounted;
	let dispose;
 
	const block = {
		c: function create() {
			div = element("div");
			h1 = element("h1");
			t0 = text("Hello ");
			t1 = text(/*name*/ ctx[0]);
			t2 = text("!");
			t3 = space();
			button = element("button");
			button.textContent = "click";
			add_location(h1, file, 7, 1, 83);
			add_location(button, file, 8, 1, 107);
			add_location(div, file, 6, 0, 76);
		},
		l: function claim(nodes) {
			throw new Error("options.hydrate only works if the component was compiled with the `hydratable: true` option");
		},
		m: function mount(target, anchor) {
			insert_dev(target, div, anchor);
			append_dev(div, h1);
			append_dev(h1, t0);
			append_dev(h1, t1);
			append_dev(h1, t2);
			append_dev(div, t3);
			append_dev(div, button);
 
			if (!mounted) {
				dispose = listen_dev(button, "click", /*click*/ ctx[1], false, false, false, false);
				mounted = true;
			}
		},
		p: function update(ctx, [dirty]) {
			if (dirty & /*name*/ 1) set_data_dev(t1, /*name*/ ctx[0]);
		},
		i: noop,
		o: noop,
		d: function destroy(detaching) {
			if (detaching) {
				detach_dev(div);
			}
 
			mounted = false;
			dispose();
		}
	};
 
	dispatch_dev("SvelteRegisterBlock", {
		block,
		id: create_fragment.name,
		type: "component",
		source: "",
		ctx
	});
	return block;
}
function instance($$self, $$props, $$invalidate) {
	let name = 'world';
	function click() {
		$$invalidate(0, name += 1);
	}
	return [name, click];
}
 

上面的 set_data_dev(t1, /*name*/ ctx[0]); 就实现了定向更新的逻辑。而响应式数据方面则通过 $$invalidate 实现了追踪。

很有意思的编译思路,但是编辑器的提示说不上太好。在小项目上还是用起来很不错的。

vue vapor

尤雨溪此前是单独在知乎开贴谈了谈虚拟 DOM 的问题的5, 从我的角度来讲 vue 引入 vdom 显然是一个非常自然的事情,很理解 vue 做的这个事情。

尤雨溪

React 的 vdom 其实性能不怎么样。Vue 2.0 引入 vdom 的主要原因是 vdom 把渲染过程抽象化了,从而使得组件的抽象能力也得到提升,并且可以适配 DOM 以外的渲染目标。这一点是借鉴 React 毫无争议

我对此总体是赞同的,本身标记性极强的 sfc 肯定是需要一个中间设施来完成抽象的,并且对于 vue 的 vapor mode 总体是乐观的。

在 2022 年的时候掘金的稀土开发者大会上,尤雨溪就对此有过详细的阐述6

尤雨溪

我们也在探索一种新的受 Solid 启发的编译策略 (Vapor Mode),它不依赖于虚拟 DOM,而是更多地利用 Vue 的内置响应性系统。

我们姑且不提性能,至少是 vue 对前端框架新时代的探索,是有历史意义和历程意义的。

未来在写

由于 vue 的 vapor mode 虽然开发进程已经趋于结束,但是总体而言对于整体 api 还有大量尚未确定的地方,这里就不多做解析了。

solid.js

简称 solid,solid 本身是 基于编译的响应式系统,很多人说他不算模板,对此我的观点还是:

远远不算灵活的 jsx

本质上是一个利用 jsx 作为编译手段的特殊模板。

事实上,如果 不使用模板,要完全实现自动抓取更新显然是一个 非常困难且繁琐 的事情,下面两个二选一:

  • 自己手动实现
  • 编译器魔法 🪄

这点平衡上 solid 做的是相当不错的。

与其说 solid 是 react 的 reactive 版本,不如说更像是 完善 jsx 版本的 vue

solid 的响应式系统是魔法程度最低的,也是最好理解的。

很多解析文章都试图将 solid 的实现归功于 proxy 的使用,但我要说明的是: solid 仅仅只在部分系统上使用了 proxy,而 solid 的核心响应式系统是基于 最最原始的发布订阅者模型 来实现的。

solid 的响应式系统基于 SignalState 和 Computation 实现, 如果要详细讲整个 solid 的实现这就太长了,这里不多赘述,后面可以单独出一个原理解析,当然在此处可以先跳过。

他的核心逻辑是通过在 渲染 dom 时通过访问器访问数据来创建订阅者,随后在数据被修改后,通过发布来通知所有订阅者刷新对应的代码。

譬如这样一个代码:

function Counter() {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount(count => count + 1);
 
  return (
    <button type="button" onClick={increment}>
      {count()}
    </button>
  );
}

会编译成下面这样

function Counter() {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount(count => count + 1);
  return (() => {
    var _el$ = _tmpl$();
    _el$.$$click = increment;
    _$insert(_el$, count);
    return _el$;
  })();
}

只要对 dom 操作的 api 提供 track(进行订阅)7 就可以知道什么时候可以重新跑这一个操作了

结语

最后附一个下载量

下载量
vue335,318,462
svelte70,866,297
solid-js13,551,967

脚注

  1. https://www.youtube.com/watch?reload=9&v=x7cQ3mrcKaY

  2. Spinellis, Diomidis (2007). “Another level of indirection”. In Oram, Andy; Wilson, Greg (eds.). Beautiful Code: Leading Programmers Explain How They Think. Sebastopol, California: O’Reilly and Associates. pp. 279–291.

  3. https://www.uber.com/en-HK/blog/m-uber/

  4. 源于《JOJO的奇妙冒险》漫画第二卷第31页(动画第一部第三集)。

  5. https://zhuanlan.zhihu.com/p/23752826

  6. https://juejin.cn/live/xdc202201

  7. 上文中代码里面的 _$insert

本文标题:虚拟 dom

永久链接:https://iceprosurface.com/code/front-end/vdom/

作者授权:本文由 icepro 原创编译并授权刊载发布。

版权声明:本文使用「署名-非商业性使用-相同方式共享 4.0 国际」创作共享协议,转载或使用请遵守署名协议。

查看源码: