第一章 权衡的艺术


第一章 权衡的艺术

1.1 命令式与声明式

从范式上来看,视图层框架通常分为命令式和声明式,它们各有优缺点。

JQuery是命令式框架,命令式一大特点就是关注过程。

例如,我们把下面这段话翻译成对应的代码:

1
2
3
4
01 - 获取 id 为 app 的 div 标签
02 - 它的文本内容为 hello world
03 - 为其绑定点击事件
04 - 当点击时弹出提示:ok

Vue.js的内部一定是命令式的,而暴露给用户的确实更加声明式的。(Vue.js帮我们封装了过程)

这段类 HTML 的模板就是 Vue.js 实现如上功能的方式。可以看到,我们提供的是一个“结果”,至于如何实现这个“结果”,我们并不关心,这就像我们在告诉 Vue.js:“嘿,Vue.js,看到没,我要的就是一个div,文本内容是 hello world,它有个事件绑定,你帮我搞定吧。”至于实现该“结果”的过程,则是由 Vue.js 帮我们完成的。换句话说,Vue.js 帮我们封装了过程。

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

1.2 性能与可维护性的权衡

命令式和声明式各有优缺点,在框架设计方面,则体现在性能与可维护性之间的权衡。
这里我们先抛出一个结论:声明式代码的性能不优于命令式代码的性能

还是拿上面的例子来说,假设现在我们要将 div 标签的文本内容修改为 hello vue3,那么如何用命令式代码实现呢?很简单,因为我们明确知道要修改的是什么,所以直接调用相关命令操作即可:

1
01 div.textContent = 'hello vue3' // 直接修改

现在思考一下,还有没有其他办法比上面这句代码的性能更好?
答案是“没有”。可以看到,理论上命令式代码可以做到极致的性能优化,因为我们明确知道哪些发生了变更,只做必要的修改就行了。但是声明式代码不一定能做到这一点,因为它描述的是结果:

1
2
3
4
01 <!-- 之前: -->
02 <div @click="() => alert('ok')">hello world</div>
03 <!-- 之后: -->
04 <div @click="() => alert('ok')">hello vue3</div>

对于框架来说,为了实现最优的更新性能,它需要找到前后的差异并只更新变化的地方,但是最终完成这次更新的代码仍然是:

1
01 div.textContent = 'hello vue3' // 直接修改

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

  1. 命令式代码的更新性能消耗 = A
  2. 声明式代码的更新性能消耗 = B + A
    可以看到,声明式代码会比命令式代码多出找出差异的性能消耗,因此最理想的情况是,当找出差异的性能消耗为 0 时,声明式代码与命令式代码的性能相同,但是无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。这符合前文中给出的性能结论:声明式代码的性能不优于命令式代码的性能

既然在性能层面命令式代码是更好的选择,那么为什么 Vue.js 要选择声明式的设计方案呢?原因就在于声明式代码的可维护性更强

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

这就体现了我们在框架设计上要做出的关于可维护性与性能之间的权衡。在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。

1.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 字符串:

1
2
3
01 const html = `
02 <div><span>...</span></div>
03 `

接着将该字符串赋值给 DOM 元素的 innerHTML 属性:

1
01 div.innerHTML = html

然而这句话远没有看上去那么简单。为了渲染出页面,首先要把字符串解析成 DOM 树,这是一个 DOM 层面的计算。我们知道,涉及DOM 的运算要远比 JavaScript 层面的计算性能差,这有一个跑分结果可供参考,如图 1-1 所示。

在图 1-1 中,上边是纯 JavaScript 层面的计算,循环 10 000 次,每次创建一个 JavaScript 对象并将其添加到数组中;下边是 DOM 操作,每次创建一个 DOM 元素并将其添加到页面中。跑分结果显示,纯JavaScript 层面的操作要比 DOM 操作快得多,它们不在一个数量级上。基于这个背景,我们可以用一个公式来表达通过 innerHTML 创建页面的性能:HTML 字符串拼接的计算量 + innerHTML 的 DOM计算量

总结

我们分了几个维度:心智负担、可维护性和性能。其中原生 DOM操作方法心智负担最大,因为你要手动创建、删除、修改大量的DOM 元素。但它的性能是最高的,不过为了使其性能最佳,我们同样要承受巨大的心智负担。另外,以这种方式编写的代码,可维护性也极差。而对于 innerHTML 来说,由于我们编写页面的过程有一部分是通过拼接 HTML 字符串来实现的,这有点儿接近声明式的意思,但是拼接字符串总归也是有一定心智负担的,而且对于事件绑定之类的事情,我们还是要使用原生 JavaScript 来处理。如果 innerHTML 模板很大,则其更新页面的性能最差,尤其是在只有少量更新时。最后,我们来看看虚拟 DOM,它是声明式的,因此心智负担小,可维护性强,性能虽然比不上极致优化的原生 JavaScript,但是在保证心智负担和可维护性的前提下相当不错

1.4 运行时和编译时

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

参考文章

Vuejs设计与实现书籍