《Vue.js 设计与实现》-- 霍春阳 这本书并非是 vue.js 的源码解读,而是向开发人员由简入繁的介绍 vue.js 的各个功能模块。

视图层框架通常分为命令式和声明式。早年间流行的 jQuery 就是典型的命令式框架。命令式框架的一大特点就是关注过程。什么是声明式框架呢?与命令式框架更加关注过程不同,声明式框架更加关注结果。Vue.js 帮我们封装了过程,Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。

《Vue.js 设计与实现》-- 霍春阳 主要讨论一下内容:
1、框架设计的核心要素以及框架设计过程中要做出的权衡;
2、三种常见的虚拟 DOM 的 diff 算法
3、组件化的视线与 vue.js 内建组件的原理
4、服务端渲染、客户端渲染、同构渲染之间的差异,以及同构渲染的原理

一、命令式框架和声明式框架的性能

结论:声明式框架的性能不好于命令式框架。

如果我们把直接修改的性能消耗定义为 A,把找出差异的性能消
耗定义为 B,那么有:

命令式代码的更新性能消耗 = A
声明式代码的更新性能消耗 = A + B

声明式代码会比命令式代码多出找出差异的性能消耗,因此最理想的情况是,当找出差异的性能消耗为 0 时,声明式代码与命令式代码的性能相同,但是无法做到超越,毕竟框架本身就是 封装了命令式代码才实现了面向用户的声明式。

为什么 Vue.js 要选择声明式的设计方案呢?原因就在于声明式代码的可维护性更强。

在采用命令式代码开发的时候,我们需要维护实现目标的整个过程,包括要手动完成 DOM 元素
的创建、更新、删除等工作。而声明式代码展示的就是我们要的结 果,看上去更加直观,至于做事儿的过程,并不需要我们关心,Vue.js 都为我们封装好了。

(一)命令式框架

特点:关注过程
例如:
1、获取 ID 为 app 的 div 标签
2、它的文本内容为 hello world
3、为其绑定点击事件
4、点击弹出提示:OK

翻译为对应的 jQuery 代码和 js 代码

1
2
3
4
5
6
7
8
9
10
11
$("#app")
.text("hello world")
.on("click", function () {
alert("OK");
});
// 原生js
const div = document.querySelector("#app");
div.innerText = "hello world";
div.addEventListener("click", function () {
alert("OK");
});

(二)声明式框架

特点:关注结果
例如:
1、获取 ID 为 app 的 div 标签
2、为它绑定点击事件
3、点击弹出提示:OK
vue.js 代码

1
<div @click="()=> alert('ok')">hello world</div>

由此可以看出,vue.js 的内部实现一定是命令式的,而暴露给用户的却是声明式的。

假设现在我们要将 div 标签的文本内容修改为 hello vue3,那么如何用命令式代码实现呢?

1
div.textContent = "hello vue3";

vue.js

1
<div @click="() => alert('ok')">hello vue3</div>

还有没有其他办法比上面这句代码的性能更好?答案是没有。命令式代码可以做到极致的性能优化,我们明确哪里做了变更,只做必要修改就行了。声明式代码不一定能做到这一点,它描述的是过程。

声明式代码与命令式代码的性能相同,但是无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。为什么 Vue.js 要选择声明式的设计方案呢?原因就在于声明式代码的可维护性更强。

二、虚拟 DOM 的性能

声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能耗
因此,如果我们能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而所谓的虚拟 DOM,就是为了最小化找出差异这一步的性能消耗而出现的。

采用虚拟 dom 的更新技术性能理论上不可能比原生的 JS 操作 dom 更高。主要是在大部分情况下,我们很难写出绝对优化的命令式代码,尤其是在应用程序规模很大的时候,即使写出了极致优化的代码,也一定耗费了巨大的经理,投入产出比并不高。

虚拟 DOM 解决的问题:写声明式代码,还能保证应用程序的性能下限,甚至逼近命令式代码的性能。

innerHTML 和虚拟 DOM

innerHTML 创建、更新页面的过程:

1
2
3
const str = `<div><span>i am text</span></div>`;
const div = document.createElement("div");
div.innerHTML = str;

为了渲染出页面,首先要把字符串解析成 DOM 树,这是一个 DOM 层面的计算,涉及DOM的运算要远比js层面的计算性能差(不在一个数量级)。

innerHTML 创建页面的性能:HTML 字符串拼接的计算量 + innerHTML 的 DOM 计算量。

虚拟 DOM 创建页面
1、创建 js 对象,这个对象可以理解为真实 DOM 的描述
2、递归地遍历虚拟 DOM 树并创建真实的 DOM。

虚拟 DOM 创建页面的性能:js 对象创建的计算量 + 递归创建真实 DOM 的计算量。

二者在创建页面的性能上,二者差距不大,虚拟 DOM 相比 innerHTML 没有优势可言。

二者在更新页面的过程中会存在显著的差异,innerHTML 更新页面时,会直接重新构建 HTML 字符串,再重新设置 DOM 元素的 innerHTML 属性。可以理解为,哪怕我只修改了一个文字,也要重新设置 innerHTML 属性,而重现设置 innerHTML 就相当于销毁了所有旧的 DOM 元素,再全量创建新的 DOM 元素。

而对于虚拟 DOM 而言,需要重新创建 js 对象(虚拟 DOM 树),然后比较新旧虚拟 DOM,找到变化元素并更新它。

在更新页面时,虚拟 DOM 在 JavaScript 层面的运算要比创建页面时多出一个 Diff 的性能消耗,然而它毕竟也是 JavaScript 层面的运算,所以不会产生数量级的差异。再观察 DOM 层面的运算,可以发现虚拟 DOM 在更新页面时只会更新必要的元素,但 innerHTML 需要全量更新。这时虚拟 DOM 的优势就体现出来了。当更新页面时,影响虚拟 DOM 的性能因素与影响 innerHTML 的性能因素不同。对于虚拟 DOM 来说,无论页面多
大,都只会更新变化的内容,而对于 innerHTML 来说,页面越大,就意味着更新时的性能消耗越大。

三、运行时和编译时

设计框架的三种选择:纯运行时、运行时 + 编译时、纯编译时框架。

一、纯运行时框架

假如我们设计的框架,提供了一个 render 函数,为该函数提供一个树形结构的数据对象,render 函数会根据该对象递归地将数据渲染成 DOM 元素。

1
2
3
4
5
6
7
8
9
const obj = {
tag: "div",
children: [
{
tag: "span",
children: "hello world",
},
],
};

每个对象都有两个属性:tag 代表标签名称,children 既可以
是一个数组(代表子节点),也可以直接是一段文本(代表文本子节
点)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function render(obj, root) {
const el = document.createElement(obj.tag);
if (typeof obj.chidlren === "string") {
const text = document.createTextNode(obj.children);
el.appendChild(text);
} else if (obj.children) {
// 数组,递归调用render函数,使用el作为root参数
obj.children.forEach((child) => {
render(child, el);
});
}
// 将元素添加到root
root.appendChild(el);
}
// 使用该render函数
render(obj, document.body);

手写树形结构的数据对象太麻烦了,能不能引入编译手段,把 HTML 标签编译成树形结构的数据对象,这样不就可以继续使用 render 函数了吗?

1
2
3
4
const str = `<div><span>hello world</span></div>`;
// 调用compiler编译得到树形数据结构的数据对象
const obj = compile(str);
render(obj, document.body);

这就是一个运行时编译的‘框架’,编译器可以将 HTML 字符串编译成数据对象,那么能不能直接编译成命令式代码,这样我们只需要一个 compiler 函数,不需要 render 函数,这其实就是纯编译时的框架。

这三种框架设计层面的对比,有哪些优缺点?

首先是纯运行时的框架。由于它没有编译的过程,因此我们没办
法分析用户提供的内容,但是如果加入编译步骤,可能就大不一样
了,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,
哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信
息,然后将其传递给 Render 函数,Render 函数得到这些信息之
后,就可以做进一步的优化了。然而,假如我们设计的框架是纯编译
时的,那么它也可以分析用户提供的内容。由于不需要任何运行时,
而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是
这种做法有损灵活性,即用户提供的内容必须编译后才能用。实际
上,在这三个方向上业内都有探索,其中 Svelte 就是纯编译时的框
架,但是它的真实性能可能达不到理论高度。Vue.js 3 仍然保持了运行
时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。

四、小结

命令式框架在理论上可以做到极致优化,但是用户要承受巨大的心智负担;而声明式框架能够有效减轻用户的心智负担,但是性能上有一定的牺牲。

虚拟 DOM 的意义就在于使找出差异的性能消耗最小化。用
原生 JavaScript 操作 DOM 的方法(如
document.createElement)、虚拟 DOM 和 innerHTML 三者操
作页面的性能,不可以简单地下定论,这与页面大小、变更部分的大 小都有关系,除此之外,与创建页面还是更新页面也有关系

vue.js 3 是编译时+运行时框架