框架设计里到处都体现了权衡的艺术
当我们在设计一个框架的时候,框架本身的各个模块之间并不是相互独立的,而是互相关联、互相制约的,作为框架的设计者,一定要对框架的定位和方法拥有全局的把握,这样才能做好后续的模块设计和拆分。(不仅是学习框架的时候,写业务代码的时候也是需要从全局的角度去考虑,否则容易被细节困住,看不清全貌)
另外,从范式的角度来看,框架应该设计成命令式的还是声明式的?这两种范式的优缺点?能否汲取两者的优点?除此之外,框架要设计成纯运行时的还是纯编译时的,甚至是运行时+编译时?它们有何差异?优缺点是什么?这里面都体现了“权衡”的艺术
1.1 命令式和声明式
从范式上看视图层通常分为
- 命令式:命令式框架的一大特点就是关注过程
- 声明式:声明式框架更加关注结果
我们把下面这段话翻译成对应的代码:
– 获取 id 为 app 的 div 标签
– 它的文本内容为 hello world
– 为其绑定点击事件
– 当点击时弹出提示:ok
早些年流行的 jQuery 就是典型的命令式框架,以 jQuery 和 原生 JavaScript 代码为例:
// 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') }) // 绑定点击事件
可以看到自然语言描述能够与代码产生一一对应的关系,代码本身描述的就是“做事的过程”
声明式框架与命令式框架不同,声明式框架更加关注结果,结合 Vuejs 实现如下
<div @click="() => alert('ok')">hello world</div>
这段类HTML模板就是 Vue.js 实现如上功能的方式。我们提供的是一个结果,至于如何实现这个结果我们并不关心,这就像我们在告诉 Vue.js:“我要的就是一个 div,内容是 hello world,点击弹出ok”。至于实现的结果则是 Vue.js 帮我们完成的。换句话说,Vue.js 帮我们封装了过程。因此,我们能够猜到 Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。
1.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
- 声明式代码的更新性能消耗 = A + B
因此最理想的情况是,当找出差异的性能消耗为0时,声明式代码与命令式代码的性能相同,但是无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。这符合前面给出的性能结论:声明式代码的性能不优于命令式代码的性能
既然在性能层面命令式时更好的选择,为什么 Vue.js 要选择声明式的设计方案呢?原因就在于声明式代码的可维护性更强,从上面例子的代码中,我们可以感受到,采用命令式代码开发的时候,需要维护实现目标的整个过程,包括手动完成DOM元素的创建、更新、删除等工作。而声明式展示的就是我们要的结果,看上去更加直观,至于过程并不关心。
这就体现了在框架设计上要做的关于可维护性与性能之间的权衡。在采用声明式提升可维护性同时,性能就会有一定损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化
1.3 虚拟DOM的性能到底如何
声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接 修改的性能消耗
如果我们能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而所谓的虚拟DOM,就是为了最小化找出差异这一步的性能消耗而出现的。
至此,应该清楚,采用虚拟DOM的更新技术的性能理论上不可能比原生 JavaScript 操作 DOM 更高。因为在大部分情况下,我们很难写出绝对优化的命令式代码,尤其是当程序的规模很大时候,即使写出了极致优化的代码,也一定消耗了巨大的精力,这时投入产出比并不高。
有什么办法能够不用付出太多的努力(写声明式代码),还能够保证应用程序的性能下限,这其实就是虚拟DOM要解决的问题。
前文所说的原生 JavaScript 实际上指的是像 document.createElement 之类的 DOM 操作方法,并不包含 innerHTML。在早年使用 jQuery 或者直接使用 JavaScript 编写页面的时候,innerHTML 操作页面非常常见。
使用 innerHTML 操作页面和虚拟 DOM 相比性能如何? innerHTML 和 document.createElement等 DOM 操作方法有何差异?
innerHTML 创建页面性能
对 innerHTML 来说,为了创建页面,我们需要构造一段 HTML字符串:
const html = `
<div><span>...</span></div>
`
接着将改字符串赋值给 DOM 元素的 innerHTML 属性:
div.innerHTML = html
为了渲染出页面,首先要把字符串解析成DOM树,这是一个 DOM 层面的计算。设计 DOM 的运算要远比 JavaScript 层面的计算性能差
上面是JavaScript层面的计算,每次创建一个 JavaScript 对象并将其添加到数组中;下面是 DOM 操作,每次创建一个 DOM 元素并将其添加到页面中。跑分结果显示,纯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来说,页面越大,更新时的性能消耗越大
- 如果加上性能因素,它们在更新页面时的性能如下图
至此,可以粗略的总结一下 innerHTML、虚拟DOM以及原生JavaScript(指createElement等方法)在页面更新时的性能。
这里分了几个纬度:心智负担、可维护性和性能- 原生DOM操作方法心智负担最大,需要手动创建、删除修改大量的DOM元素。但它的性能是最高的,为了使其性能最佳,同样要承受巨大的心智负担。另外这种方式编写的代码,不好维护
- innerHTML 编写页面有一部分是拼接HTML字符串总归也有一定心智负担,而对于事件绑定还是要使用原生JavaScript来处理,如果innerHTML模板很大,则其页面更新的性能最差,尤其是在只有少量更新的时候。
- 虚拟DOM是声明式的,因此心智负担小,可维护性强,性能虽然不比上极致优化的原生JavaScript,但是在保证心智负担和可维护性的前提下相当不错
1.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))
}
}
有了这个函数,用户就可以这样来使用它:
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}
// 渲染到 body 下
Render(obj, document.body)
回头思考一下用户是如何使用 Render 函数的。可以发现,用户在使用它渲染内容时,直接为 Render 函数提供了一个树型结构的数据对象。这里面不设计任何额外的步骤,用户也不需要学习额外的知识。但是有一天,用户抱怨说:“手写树型结构的数据对象太麻烦了,而且不直观,能不能支持用于类似 HTML 标签的方式描述树型结构的数据对象呢?”
实际上,我们刚刚编写的框架就是一个纯运行时的框架,但是为了满足用户需求,你开始思考,能不能引入编译的手段,把 HTML 标签编译成树型结构的数据对象,这样不就继续使用 Render 函数了吗?
<div>
<span> hello world</span>
</div>
编译后
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}
为此,你编写了一个叫做 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 字符串编译成数据对象,那么能不能直接编译成命令式代码呢?
<div>
<span> hello world</span>
</div>
编译后
const div = document.createElement('div')
const span = document.createElement('span')
span.innerText = 'hello world'
div.appendChild(span)
document.body.appendChild(div)
这样我们只需要一个 Compiler 函数就可以了,连 Render 函数都不需要了。其实这就变成了一个纯编译时的框架,因为不支持任何运行时内容,用户的代码通过编译器编译后才能运行。
框架设计层面的运行时、编译时以及运行时+编译时,它们都有哪些优缺点呢?是不是既支持运行时又至此编译时的框架最好呢?我们逐个分析。
- 纯运行时的框架:由于它没有编译的过程,因此没有办法分析用户提供的内容,但是如果加入编译步骤,就可以分析用户提供的内容,判断哪些内容未来会改变,哪些内容不会改变,这样可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了
- 纯编译时的框架:也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。
而Vue3仍待保持了运行时+编译时的架构,在保持灵活性的基础上能够尽可能的去优化。