Skip to content

框架设计概览

框架设计里到处都体现了权衡的艺术。

在深入学习 Vue.js 之前,我们需要从全局的角度来对框架的设计进行分析。下面我们就来研究和探讨框架设计上的各种取舍和权衡,作为框架的设计者而言需要基于什么样的考虑做出设计的选择。

命令式和声明式

框架应该设计成命令式还是声明式?这两种方法分别有什么优缺点?能不能将它们的优点集中为我所用?其实这其中都体现了框架设计的==权衡的艺术==。

编程范式通常分为命令式和声明式。假设我们有如下的需求:

txt
- 获取 id 为 app 的 div 标签
- 其文本内容为 hello world
- 绑定一个点击事件
- 点击后弹出提示:ok

命令式的特点是==关注过程==。命令式的代码如下:

js
const div = document.querySelector('#app')
div.innerText = 'hello world'
div.addEventListener('click', () => alert('ok'))

命令式的代码符合我们思考的逻辑顺序,自然语言可以和机器代码一一对应。

声明式的特点是==关注结果==。声明式的代码如下:

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

这段代码由 Vue.js 实现。Vue.js 直接提供了一个结果,而这个结果具体是如何实现的,我们完全不用关心。Vue.js 封装了命令式的代码,以声明式的结果呈现给我们。

性能和可维护性的权衡

在讨论这个问题之前,先抛出一个结论:==命令式代码的性能优于声明式代码==。

下面我们来验证这个结论。假设我们要更改 div 中的文本内容,怎么用命令式代码来实现呢?很简单,我们知道要做什么,所以直接调用相关的命令。

js
div.innerText = 'hello vue'

那在声明式代码中呢?

html
<!-- 之前 -->
<div @click="()=> alert('ok')">hello world</div>

<!-- 之后 -->
<div @click="()=> alert('ok')">hello vue</div>

对比这两段代码,显然是命令式的代码性能更好。由于声明式的代码封装了代码,它为了修改内容还得找到更新前后的差异并更新变化的地方。将修改内容的性能损耗设为 A,将找到差异的性能损耗设为 B,那么有:

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

这就印证了前文给出的结论:==命令式代码的性能优于声明式代码==。

既然如此,那为什么框架选择了性能较差的声明式代码?这就体现了框架设计上的平衡和取舍。尽管声明式代码并不是性能最好的方式,但它的可维护性更强。对于用户而言,我们更关心执行的结果,我们不想手动去寻找 DOM、完成 DOM 的更新和删除等工作。框架设计者权衡了性能和可维护性,采用声明式的同时损失了性能,但保证了可维护性,也就是:==在保持可维护性的同时让性能损失最小化==。

虚拟 DOM 的性能究竟如何

简单概括虚拟 DOM:用 JavaScript 对象来模拟真实 DOM,是为了最小化找出差异的性能消耗而出现的。

由于虚拟 DOM 本质上是为了让声明式代码的性能无限接近于命令式代码,因此采用虚拟 DOM 技术的性能理论上不可能比直接使用 JavaScript 操作原生 DOM 的性能更好。在大部分情况下,我们很难写出绝对优化的命令式代码。特别是在项目工程很大的情况下,即使我们能写出极致优化的命令式代码,但是这种情况的投入产出比并不高。

现在来比较 innerHTML虚拟 DOM 在创建页面时的性能差异。

对于 innerHTML 来说,创建页面需要构造一串字符串:

js
const html = `
<div>hello world</div>
`

然后将该字符串值赋给 DOM 的 innerHTML

js
div.innerHTML = html

为了渲染出页面,首先要将字符串解析为 DOM 树。用一个公式来表示 innerHTML 创建页面的性能:

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

对于虚拟 DOM 来说,创建页面也需要两步。第一步是创建 JavaScript 对象,该对象是对真实 DOM 的描述;第二步是递归地遍历虚拟 DOM 树并创建真实 DOM。用一个公式来表示虚拟 DOM 创建页面的性能:

txt
虚拟 DOM 创建页面的性能 = 创建 JavaScript 对象的计算量 + 创建真实 DOM 的计算量

可以看到,两者在创建页面时的性能差距其实不大。

接下来我们来讨论两者在更新页面时的性能。对于 innherHTML 而言,更新页面是重新构建 HTML 字符串,然后重新设置 DOM 元素,等价于销毁所有 DOM 元素再重新创建;而对于虚拟 DOM 而言,更新页面是重新创建 JavaScript 对象,然后比较新旧 DOM 并作出更新。

可以发现,虚拟 DOM 的优势在于更新页面时只更改必要的元素,而 innerHTML 需要全量更新。尽管虚拟 DOM 需要额外进行 Diff 的运算,但我们知道==涉及 DOM 的运算远比 JavaScript 层面的计算性能差==。因此得出结论:虚拟 DOM 在更新页面时的性能更佳。

基于以上的讨论,我们粗略地来对比一下虚拟 DOM、innerHTML 和原生 JavaScript 的性能。性能最高的当然是原生 JavaScript,但需要付出额外的大量精力维护,造成了最大的心智负担;性能最差的是 innerHTML,但只需要拼接字符串所带来的心智负担中等;性能不错的是虚拟 DOM,可维护性强所带来更少的心智负担的优势不言而喻。

那么,我们就理解了为什么 Vue.js 选择了虚拟 DOM。

运行时和编译时

当设计一个框架时,我们有三个选择:纯运行时、纯编译时和运行时 + 编译时。

假设我们设计了一个框架,提供一个 Render 函数,这个函数接收由用户提供的树形结构的数据对象,然后根据该对象递归地将数据渲染为 DOM 元素。那这就是纯运行时的框架。

然而,用户觉得每次都手写树形结构的数据太麻烦了,不直观也不易于维护。他们想用类似于原生 HTML 的方式来描述数据。这时我们可以引入编译的手段,即 Compiler 函数,把 HTML 标签编译成树形结构的数据。此时用户可以手写 HTML 结构,编译完后调用渲染函数生成数据。此时的框架即运行时 + 编译时。

既然我们可以把 HTML 字符串编译成数据对象,那我们同样也可以将其直接编译成命令式的代码。这样的框架就是纯编译时。

这三种不同的方式有什么特点呢?首先是纯运行时的框架,由于没有编译的过程,框架没有办法分析用户提供的内容;其次是纯编译时的框架,虽然可以分析用户的内容,但是有损灵活性,因为用户的内容必须编译后才能使用;最后是运行时 + 编译时,在可以分析用户内容的同时也能保证一定的灵活性。

实际上,这三个框架都有不同的探索。比如 Vue.js 是编译时 + 运行时的框架,而另一个新兴框架 Svelte 则采用了纯编译时的思路。

Released under the MIT License.