书籍链接:https://weread.qq.com/web/bookDetail/c5c32170813ab7177g0181ae
框架设计里到处都体现了权衡的艺术。
当我们设计一个框架时,框架本身的各个模块之间并不是相互独立的,而是相互关联、相互制约的。
作为框架设计者,一定要对框架的定位和方向拥有全局的把控,这样才能做好后续的模块设计和拆分。
作为学习者,在学习框架的时候,也应该从全局的角度对框架的设计拥有清晰的认知,否则很容易被细节困住,看不清全貌。
框架的范式有命令式和声明式,框架的设计有纯运行时、纯编译时和运行时 + 编译时,它们各有优缺,如何选择呢?这里面都体现了“权衡”的艺术。
1. 命令式和声明式
从范式上来看,视图层框架通常分为命令式和声明式,它们各有优缺点。
命令式框架的一大特点就是关注过程。曾一度盛行的 jQuery 就是典型的命令式框架。例如,我们把下面这段话翻译成对应的代码:
- 获取 id 为 app 的 div 标签
- 其文本内容为 hello world
- 为其绑定点击事件
- 当点击时弹出提示:ok
jQuery 对应的代码为:
$('#app') // 获取 div
.text('hello world') // 设置文本内容
.on('click', () => { alert('ok') }) // 绑定点击事件
原生 JavaScript 对应的代码为:
const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
显然,原生 JavaScript 和 jQuery 都是命令式编程,自然语言描述能够与代码产生一一对应的关系,代码本身描述的是“做事的过程”,符合我们的逻辑直觉。
与命令式框架更加关注过程不同,声明式框架更加关注结果。结合 Vue.js,来看一下如何实现上面自然语言描述的功能:
<div @click="() => alert('ok')">hello world</div>
可以看到,我们提供的是一个“结果”,并不关心如何实现它,实现该“结果”的过程则是由 Vue.js 帮我们完成的。换句话说,Vue.js 帮我们封装了过程。
因此,能够猜到 Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。
2. 性能与可维护性的权衡
命令式和声明式各有优缺点,在框架设计方面,则体现在性能与可维护性之间的权衡。这里先抛出一个结论:声明式代码的性能不优于命令式代码的性能。
以上面的例子来说,假设现在要将 div
标签的文本内容修改为 hello vue3,如何用命令式代码实现呢?很简单,直接调用相关命令操作即可:
div.textContent = 'hello vue3' // 直接修改
有没有比上面这句代码性能更好的方案呢?答案是“没有”。可以看到,理论上命令式代码可以做到极致的性能优化,因为我们明确知道哪些发生了变更,只做必要的修改就行了。但是声明式代码不一定能做到这一点,因为它描述的是结果:
<!-- 之前: -->
<div @click="() => alert('ok')">hello world</div>
<!-- 之后: -->
<div @click="() => alert('ok')">hello vue3</div>
对于框架来说,为了实现最优的更新性能,它需要找到前后的差异并只更新变化的地方,但是最终完成这次更新的代码仍然是:
div.textContent = 'hello vue3' // 直接修改
如果把直接修改的性能消耗定义为 A,把找出差异的性能消耗定义为 B,那么有:
- 命令式代码的更新性能消耗 = A
- 声明式代码的更新性能消耗 = B + A
可以看到,声明式会比命令式多出找出差异的性能消耗,最理想的情况是找出差异的消耗为 0 时,声明式代码与命令式代码的性能相同,但无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。
但是,相比命令式,声明式代码的可维护性更强。从上例可以感知,采用命令式开发时,我们需要维护实现目标的整个过程,包括手动完成 DOM 元素的创建、更新、删除等工作。而声明式仅需要我们关注结果,过程不需要关心,心智负担较小,代码更可观。
在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。
3. 虚拟 DOM 的性能
前文说到,声明式代码的更新性能消耗 = 找出差异的性能消耗+ 直接修改的性能消耗,因此,如果我们能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而所谓的虚拟 DOM,就是为了实现这一最小化而出现的。
也就是说,采用虚拟 DOM 的更新技术的性能理论上不可能比原生 JavaScript 操作 DOM 更高。为什么要强调「理论上」这三个字,因为在大部分情况下,我们很难写出绝对优化的命令式代码,尤其在项目工程规模非常大的情况下。
虚拟 DOM 要解决的问题就是在书写可维护性更强的声明式代码时,尽可能的让应用程序的性能更好,甚至逼近命令式代码的性能。
值得一提的是,前文中所说的原生 JavaScript 实际上指的是像
document.createElement
之类的 DOM 操作方法,并不包含innerHTML
,因为它比较特殊,需要单独讨论。
在使用 jQuery 或者直接使用 JavaScript 编写页面的时候,使用 innerHTML
来操作页面非常常见。现在,我们也许要思考一下:
- 使用
innerHTML
操作页面和虚拟 DOM 相比性能如何? innerHTML
和document.createElement
等 DOM 操作方法有何差异?
先看第一个问题,为了比较 innerHTML
和虚拟 DOM 的性能,需要了解它们创建、更新页面的过程。
对于 innerHTML
来说,为了创建页面,需要构造一段 HTML 字符串:
const html = `<div><span>...</span></div>`
接着将该字符串赋值给 DOM 元素的 innerHTML
属性:
div.innerHTML = html
为了渲染出页面,首先要把字符串解析成 DOM 树,这是一个 DOM 层面的计算。我们知道,涉及 DOM 的运算要远比 JavaScript 层面的计算性能差,这有一个跑分结果可供参考,如下:
跑分结果显示,纯 JavaScript 层面的操作要比 DOM 操作快得多,它们不在一个数量级上。
我们可以用一个公式来表达通过 innerHTML
创建页面的性能:HTML 字符串拼接的计算量 + innerHTML
的 DOM 计算量。
接下来,讨论虚拟 DOM 在创建页面时的性能。虚拟 DOM 创建页面的过程分为两步:
- 创建 JavaScript 对象,这个对象可以理解为真实 DOM 的描述
- 递归地遍历虚拟 DOM 树并创建真实 DOM
我们同样可以用一个公式来表达:创建 JavaScript 对象的计算量 + 创建真实 DOM 的计算量。
可以看到,无论是纯 JavaScript 层面的计算,还是 DOM 层面的计算,其实两者差距不大。如果在同一个数量级,则认为没有差异。在创建页面的时候,都需要新建所有 DOM 元素。
给出第一个结论:在同一个数量级下,创建页面时,虚拟 DOM 和 innerHTML
的性能几乎没有区别。
下面看看它们在更新页面时的性能。
使用 innerHTML
更新页面的过程是重新构建 HTML 字符串,再重新设置 DOM 元素的 innerHTML
属性,也就是说,哪怕只更改了一个文字,也要重新设置 innerHTML
属性,这等价于销毁所有旧的 DOM 元素,再全量创建新的 DOM 元素。
虚拟 DOM 更新页面的,需要重新创建 JavaScript 对象(虚拟 DOM 树),然后比较新旧虚拟 DOM,找到变化的元素并更新它。
可以发现,在更新页面时,虚拟 DOM 在 JavaScript 层面的运算要比创建页面时多出一个 Diff 的性能消耗,但它也是 JavaScript 层面的运算,所以不会产生数量级的差异。再观察 DOM 层面的运算,可以发现虚拟 DOM 在更新页面时只会更新必要的元素,但 innerHTML
需要全量更新。这时虚拟 DOM 的优势就体现出来了。
另外,当更新页面时,影响虚拟 DOM 的性能因素与影响 innerHTML
的性能因素不同。对于虚拟 DOM 来说,无论页面多大,都只会更新变化的内容,而对于 innerHTML
来说,页面越大,就意味着更新时的性能消耗越大。如果加上性能因素,那么最终它们在更新页面时的性能如下图:
给出第二个结论:在同一个数量级下,更新页面时,虚拟 DOM 比 innerHTML
的性能要高。
基于此,可以粗略地总结一下 innerHTML
、虚拟 DOM 以及原生 JavaScript(指 createElement
等方法)在更新页面时的性能:
事实上,innerHTML
已经有一点声明式编程的味道了,但它更像是一种没有经过优化的声明式代码,没有做好性能和可维护性两者之间的权衡。
4. 运行时和编译时
当设计一个框架的时候,有三种选择:纯运行时的、运行时 + 编译时的或纯编译时的。这需要你根据目标框架的特征,以及对框架的期望,做出合适的决策。
先看一下纯运行时的框架。假设我们设计了一个框架,它提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。规定树型结构的数据对象如下:
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}
每个对象都有两个属性:tag
代表标签名称,children
既可以是一个数组(代表子节点),也可以直接是一段文本(代表文本子节点)。接着,实现 Render 函数:
function Render(obj, root) {
const el = document.createElement(obj.tag)
if (typeof obj.children === '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)
}
有了这个函数,用户就可以这样来使用它:
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}
// 渲染到 body 下
Render(obj, document.body)
用户在使用 Render 函数渲染内容时,直接为其提供了一个树型结构的数据对象。这里面不涉及任何额外的步骤,用户也不需要学习额外的知识。但是有一天,你的用户抱怨说:“手写树型结构的数据对象太麻烦了,而且不直观,能不能支持用类似于 HTML 标签的方式描述树型结构的数据对象呢?”你会发现,它并不支持。实际上,刚刚编写的框架就是一个纯运行时的框架。
为了满足用户的需求,你开始思考,能不能引入编译的手段,把 HTML 标签编译成树型结构的数据对象,这样不就可以继续使用 Render 函数了吗?思路如下:
为此,你编写了一个叫作 Compiler 的程序,它的作用就是把 HTML 字符串编译成树型结构的数据对象,于是交付给用户去用了。那么用户该怎么用呢?最简单的方式就是让用户分别调用 Compiler 函数和 Render 函数:
const html = `<div><span>hello world</span></div>`
// 调用 Compiler 编译得到树型结构的数据对象
const obj = Compiler(html)
// 再调用 Render 进行渲染
Render(obj, document.body)
这时框架就变成了一个运行时 + 编译时的框架,它既支持运行时,用户可以直接提供数据对象从而无须编译;又支持编译时,用户可以提供 HTML 字符串,然后将其编译为数据对象后再交给运行时处理。
准确地说,上面的代码其实是运行时编译,意思是代码运行的时候才开始编译,而这会产生一定的性能开销,因此也可以在构建的时候就执行 Compiler 程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。
不过,既然编译器可以把 HTML 字符串编译成数据对象,那么能不能直接编译成命令式代码呢?思路如下:
这样只需要一个 Compiler 函数就可以了,连 Render 都不需要了。其实这就变成了一个纯编译时的框架,因为不支持任何运行时内容,用户的代码通过编译器编译后才能运行。
那么,这三种框架都有哪些优缺点呢?
纯运行时的框架没有编译的过程,因此没办法分析用户提供的内容,但是如果加入编译步骤,我们就可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样就可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了。
假如设计的框架是纯编译时的,那么它也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。
Vue 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化,其在保留运行时的情况下,性能甚至不输纯编译时的框架。
更多文章可关注:GopherBlog、GopherBlog副站