框架设计概览
框架设计里到处都体现了权衡的艺术。
在深入学习 Vue.js 之前,我们需要从全局的角度来对框架的设计进行分析。下面我们就来研究和探讨框架设计上的各种取舍和权衡,作为框架的设计者而言需要基于什么样的考虑做出设计的选择。
命令式和声明式
框架应该设计成命令式还是声明式?这两种方法分别有什么优缺点?能不能将它们的优点集中为我所用?其实这其中都体现了框架设计的==权衡的艺术==。
编程范式通常分为命令式和声明式。假设我们有如下的需求:
- 获取 id 为 app 的 div 标签
- 其文本内容为 hello world
- 绑定一个点击事件
- 点击后弹出提示:ok
命令式的特点是==关注过程==。命令式的代码如下:
const div = document.querySelector('#app')
div.innerText = 'hello world'
div.addEventListener('click', () => alert('ok'))
命令式的代码符合我们思考的逻辑顺序,自然语言可以和机器代码一一对应。
声明式的特点是==关注结果==。声明式的代码如下:
<div @click="()=> alert('ok')">hello world</div>
这段代码由 Vue.js 实现。Vue.js 直接提供了一个结果,而这个结果具体是如何实现的,我们完全不用关心。Vue.js 封装了命令式的代码,以声明式的结果呈现给我们。
性能和可维护性的权衡
在讨论这个问题之前,先抛出一个结论:==命令式代码的性能优于声明式代码==。
下面我们来验证这个结论。假设我们要更改 div
中的文本内容,怎么用命令式代码来实现呢?很简单,我们知道要做什么,所以直接调用相关的命令。
div.innerText = 'hello vue'
那在声明式代码中呢?
<!-- 之前 -->
<div @click="()=> alert('ok')">hello world</div>
<!-- 之后 -->
<div @click="()=> alert('ok')">hello vue</div>
对比这两段代码,显然是命令式的代码性能更好。由于声明式的代码封装了代码,它为了修改内容还得找到更新前后的差异并更新变化的地方。将修改内容的性能损耗设为 A
,将找到差异的性能损耗设为 B
,那么有:
- 命令式代码更新的性能损耗 = A
- 声明式代码更新的性能损耗 = A + B
这就印证了前文给出的结论:==命令式代码的性能优于声明式代码==。
既然如此,那为什么框架选择了性能较差的声明式代码?这就体现了框架设计上的平衡和取舍。尽管声明式代码并不是性能最好的方式,但它的可维护性更强。对于用户而言,我们更关心执行的结果,我们不想手动去寻找 DOM、完成 DOM 的更新和删除等工作。框架设计者权衡了性能和可维护性,采用声明式的同时损失了性能,但保证了可维护性,也就是:==在保持可维护性的同时让性能损失最小化==。
虚拟 DOM 的性能究竟如何
简单概括虚拟 DOM:用 JavaScript 对象来模拟真实 DOM,是为了最小化找出差异的性能消耗而出现的。
由于虚拟 DOM 本质上是为了让声明式代码的性能无限接近于命令式代码,因此采用虚拟 DOM 技术的性能理论上不可能比直接使用 JavaScript 操作原生 DOM 的性能更好。在大部分情况下,我们很难写出绝对优化的命令式代码。特别是在项目工程很大的情况下,即使我们能写出极致优化的命令式代码,但是这种情况的投入产出比并不高。
现在来比较 innerHTML
和 虚拟 DOM
在创建页面时的性能差异。
对于 innerHTML
来说,创建页面需要构造一串字符串:
const html = `
<div>hello world</div>
`
然后将该字符串值赋给 DOM 的 innerHTML
:
div.innerHTML = html
为了渲染出页面,首先要将字符串解析为 DOM 树。用一个公式来表示 innerHTML
创建页面的性能:
innerHTML 创建页面的性能 = HTML 字符串拼接的计算量 + innerHTML 的 DOM 计算量
对于虚拟 DOM 来说,创建页面也需要两步。第一步是创建 JavaScript 对象,该对象是对真实 DOM 的描述;第二步是递归地遍历虚拟 DOM 树并创建真实 DOM。用一个公式来表示虚拟 DOM 创建页面的性能:
虚拟 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 则采用了纯编译时的思路。