本节的核心在于:框架设计里处处体现了权衡的艺术
1、命令式与声明式
编程范式主要分为以下两种:
- 命令式:命令式编程是一种以计算机执行的命令为中心的编程范式,它更关注过程,主要分为面向过程和面向对象两种形式
- 声明式:声明式编程是一种以描述问题为中心的编程范式,它更关注结果,主要分为函数式和响应式两种形式
对于这两种范式,我们可以在前端的几个主流框架上找到它们的影子。
命令式框架的代表是jQuery,当我们使用jQuery时,自然语言的描述能够与代码之间一一对应,代码本身就描述了我们做了什么事情。
$('#app') //获取#app
.text('Hello') //设置文本内容
.on(click,HandleClick()) //绑定点击事件
而声明式框架应用在Vue.js的类HTML模板上,例如我们通过@click="HandleClick()"为一段div绑定点击事件,div的内部文本为Hello,此时我们只关注到了这段代码执行后的结果,对于其的实现过程,Vue.js已经帮我们进行了封装。
<div @click="HandleClick()">Hello</div>
Vue.js在范式方面的权衡主要体现在其内部实现是通过命令式的,暴露给用户的是声明式的。
2、性能与可维护性的权衡
性能
当我们想要将div内部的内容修改为Hello World的时候,两种编程范式分别需要做些什么呢?
对于命令式,我们直接通过以下命令就可以修改:
div.textContent = 'Hello World'
显然,声明式在性能上不可能比命令式这种直接修改更高,他需要找出模板中前后差异的地方并进行修改。
可维护性
那么Vue还要选择声明式的设计方案为的是什么呢?这是因为声明式代码可维护性更高。
使用声明式代码可以免去DOM元素的创建、更新、删除等等工作,只看结果更加直观。至于具体的DOM操作,Vue.js为我们封装好了。
总结
总的来说,框架设计者选需要保证可维护性与性能的平衡。
附上总结表格:
| 范式 | 声明式 | 命令式 |
|---|---|---|
| 性能 | 低 | 高 |
| 更新性能消耗 | 直接修改的性能消耗+找出差异的性能消耗 | 直接修改的性能消耗 |
| 可维护性 | 高 | 低 |
3、虚拟DOM的性能
在第二点中我们知道了声明式的性能消耗为:直接修改的性能消耗+找出差异的性能消耗
那么我们想要降低性声明式框架的性能消耗就只能从找出差异的性能消耗的角度出发,尽量让这里的消耗的性能降低。
书中在这里说到采用虚拟DOM的更新技术的性能理论上不可能比原生JS(指document.createElement之类的DOM操作方法)操作DOM更高,这里重点关注理论上这三个字,我们理论上可以通过高质量的JS代码实现很低的更新性能消耗,但是这样编写代码会带来很高的心智负担,显然是需要投入巨大的精力的。
而虚拟DOM正是为了解决这个问题而提出的解决方案,通过虚拟DOM,我们可以不用付出巨大的精力,也能构建出具有一定下限,逼近命令式代码性能的声明式代码。
直观对比innerHTML、虚拟DOM创建页面时的性能:
| 虚拟DOM | innerHTML | |
|---|---|---|
| 纯JS运算 | 创建VNode | 渲染HTML字符串 |
| DOM运算 | 新建所有DOM元素 | 新建所有DOM元素 |
这么看来,二者似乎相差并不大,那么虚拟DOM的优势体现在哪里呢?其实在更新页面时,虚拟DOM的优势才得以体现。
使用innerHTML更新页面的过程是
- 重新构建HTML字符串
- 重新设置DOM元素的innerHTML属性
使用虚拟DOM更新页面的过程是
- 重新创建虚拟DOM树
- 比较新旧虚拟DOM
- 找到差异并更新
显然,innerHMTL每次更新页面就像重新创建了一个新页面一样,销毁了原有的所有DOM元素,重新创建新的DOM元素。而虚拟DOM在比较过后只做必要的更新。其中diff算法是JS级别的运算,不会对总的性能消耗带来
直观对比innerHTML、虚拟DOM更新页面时的性能:
| 虚拟DOM | innerHTML | |
|---|---|---|
| 纯JS运算 | 创建新的VNode+Diff | 渲染HTML字符串 |
| DOM运算 | 必要DOM更新 | 销毁原有DOM,创建新DOM |
最后总结一下原生JS,innerHTML以及虚拟DOM三者在心智负担、可维护性、性能这三个方面的优劣:
| 虚拟DOM | innerHTML | 原生JS | |
|---|---|---|---|
| 性能 | 一般 | 差 | 好 |
| 心智负担 | 小 | 一般 | 大 |
| 可维护性 | 好 | 差 | 差 |
4、运行时和编译时
设计一个框架一般有三种选择:纯运行时、纯编译时、运行时+编译时,下面举出具体例子来讲讲他们的区别。
4.1纯运行时
假设我们设计了一个框架,它提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。
我们规定树型结构的数据对象如下:
const obj = {
tag: 'div',
children: [
{
tag: 'span',
children: 'hello world'
}
]
}
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))
}
}
用户使用这个框架,不需要学习额外的知识,仅仅是创建对象、调用函数。如果用户某一天觉得手写树状对象很麻烦,纯运行时框架没有办法解决这个问题,因为在用户代码与运行之间不会进行任何操作。
4.2编译时+运行时
为了用户的使用方便,我们想让用户写类似HTML标签的代码,而不是树形结构的数据对象就可以实现DOM元素的渲染,于是我们实现一个函数能够将HTML标签的字符串转换为树形结构的数据对象。(这里不列出具体的函数,主要理解这种思想)
这一步实际上就是对用户的代码进行一次编译,我们将编译完成以后得到的树形结构的数据对象调用render函数就可以实现DOM元素的渲染。
值得注意的是,上面的例子中属于运行时编译,简单来说就是当代码运行时我们才对用户的代码进行编译,这样显然会让我们产生许多额外的性能开销。框架的选择是使用构建时编译,也就是预编译,在vue框架中,vue-loader负责.vue文件的预编译的工作。
4.3纯编译时
在编译时+运行时的例子中,我们是通过HTML标签的字符串转换为树形结构的数据对象再调用render函数实现的DOM元素渲染。
而在纯编译时的代码可以将直接将HTML标签模板经过一系列操作直接编译为DOM元素,听起来是不是更加的简单了?
4.4 框架设计应该怎么选择
我们依次来对他们的优缺点进行分析。
首先是纯运行时代码,由于不存在编译这个阶段,我们无法获得用户的输入,也就是不知道他想干什么,这一点对于框架设计来说是存在一定问题的,也决定了他的性能上限不会高(无法实现部分更新,而是直接全部更新)。
而纯编译时代码我们可以获得用户的输入是什么,同时它是直接通过编译成可执行的js代码,这一点让他在性能上有了优势。纯编译时代码的代表框架是Svelte。
本书认为纯编译时代码在灵活性上有一定缺陷,但是个人认为这个对于框架的设计影响不大,如果有不同意见,欢迎评论区讨论
而vue选择了编译时+运行时的代码,保留了灵活性的同时也兼顾了一定的性能。
5、总结
本章着重讲述了视图层框架在设计上的需要考虑的各个方面,最终要实现的目标是降低用户心智负担、减少性能消耗、可维护性这三点的平衡。
无论我们选择怎样的编程范式以及代码风格,都无法实现三者都不错的效果。所谓框架设计是权衡的艺术,大概就是出于这里的考虑。