-
Notifications
You must be signed in to change notification settings - Fork 0
Description
命令式与声明式
从范式上来看,视图层框架通常分为命令式和声明式。
命令式:关注过程,自然语言描述能够与代码产生一一对应的关系,代码本身描述的是“做事的过程”。
const div = document.querySelector('#app') // 获取div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件声明式:更加关注结果,我们提供的是一个“结果”,而怎样去实现这个“结果”,我们并不关心。
<div @click="() => alert('ok')">hello world</div>因此,我们知道,Vue.js的内部实现一定是命令式的,而暴露给用户的却更加声明式。
性能与可维护性
声明式代码的性能不优于命令式的代码
假设现在我们要将div标签的文本修改为hello vue3
用命令式代码可以直接实现
div.textContent = 'hello vue3'现在思考一下,还有没有其他办法比上面这句代码的性能更好?答案是“没有”
理论上,命令式代码可以做到极致的性能优化,因为我们明确的知道哪些发生了变更,只做必要的修改就行了。
但是,声明式代码不一定能做到这一点,因为它描述的是结果:
<!-- 之前:-->
<div>hello world</div>
<!-- 之后:-->
<div>hello vue3</div>对于框架来说,为了实现最优的更新性能,它需要找到前后的差异并只更新变化的地方,但是最终完成这次更新的代码仍然是:
div.textContent = 'hello vue3'如果我们把直接修改的性能消耗定义为A,把找出差异的性能消耗定义为B,那么有:
命令式代码的更新性能消耗 = A
声明式代码的更新性能消耗 = B + A
因此,最理想的情况是,当找出差异的性能消耗为0时,声明式代码与命令式代码的性能相同,但是无法做到超越。
既然性能方面命令式代码是更好的选择,那么为什么vue.js要选择声明式的设计方案呢?
原因就在于声明式代码的可维护性更强。
在采用命令式代码开发的时候,我们需要维护实现目标的整个过程,包括要手动完成DOM元素的创建、更新、删除等工作。而声明式代码展示的就是我们要的结果,看上去更加直观,至于实现的过程,并不需要我们关心,vue.js都帮我们封装好了。
这体现了在框架设计上要做出的关于可维护性与性能之间的权衡。在采用声明式提升可维护性的同时,性能就会有一定的损失,框架设计者要做的就是:在保持可维护性的同时,让性能损失最小化。
虚拟DOM的性能
前面讲到,如果我们能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。
而所谓的虚拟DOM,就是为了最小化找出差异的性能消耗而出现的。
我们知道,采用虚拟DOM的更新技术的性能理论上不可能比原生JS操作DOM更高。这里所说的原生JS实际上指的是类似document.createElement之类的DOM操作方法,并不包括 innerHTML,因为它比较特殊,需要单独讨论。
接下来我们来比较一下,使用innerHTML来操作页面 和 虚拟DOM,这两个性能如何。
创建页面
对于innerHTML来说,为了创建页面,我们需要构造一段HTML字符串:
const html = `
<div><span>...</span></div>
`接着将该字符串赋值给DOM元素的innerHTML属性:
div.innerHTML = html然而这句话远没有看上去那么简单,为了渲染出页面,我们需要把字符串解析成DOM树,这是一个DOM层面的计算,涉及DOM的运算远比JS层面的计算性能差。
所以,性能公式为:innerHTML创建页面的性能 = HTML字符串拼接的计算量 + innerHTML的DOM计算量
对于虚拟DOM,创建页面的过程分为两步:
- 创建JS对象,这个对象可以理解为真实DOM的描述
- 递归的遍历虚拟DOM树并创建真实DOM
所以,性能公式为:虚拟DOM创建页面的性能 = 创建JS对象的计算量 + 创建真实DOM的计算量
可见,在创建页面时,无论是纯JS层面的计算,还是DOM层面的计算,其实两者差距不大
更新页面
我们通过下面表格来对比:
| 虚拟DOM | innerHTML | |
|---|---|---|
| 纯JS运算 | 创建新的JS对象 + diff | 渲染HTML字符串 |
| DOM运算 | 必要的DOM更新 | 销毁所有旧DOM + 新建所有新DOM |
可以发现,在更新页面时,虚拟DOM在JS层面的运算要多出一个diff的性能消耗,然而它毕竟也是JS层面的运算,所以不会产生数量级的差异。但innerHTML在DOM层面的运算需要全量的更新,销毁再重建,这时虚拟DOM的优势就体现出来了。
另外,当更新页面时,无论页面多大,都只会更新变化的内容,而对于innerHTML来说,页面越大,就意味着更新时的性能消耗越大。
| 虚拟DOM | innerHTML |
|---|---|
| 性能因素 | 与数据变化量相关 |
性能比较
粗略的比较一下innerHTML、虚拟DOM、原生JS(createElement等方法)在更新页面时的性能:
性能:innerHTML < 虚拟DOM < 原生JS
| innerHTML | 虚拟DOM | 原生JS |
|---|---|---|
| 心智负担中等 | 心智负担小 | 心智负担大 |
| 性能差 | 性能不错 | 性能高 |
| 可维护性强 | 可维护性差 |
可以看到,因为虚拟DOM是声明式的,因此心智负担小,可维护性强,性能虽然比不上极致优化的原生JS,但是在保证心智负担和可维护性的前提下相当不错。
至此,我们有必要思考一下:有没有办法做到,既声明式的描述UI,又具备原生JS的性能呢?我们在之后的章节继续讨论。
运行时和编译时
当设计一个框架的时候,我们有三种选择:
- 纯运行时的
- 运行时 + 编译时的
- 纯编译时的
至于选择哪种,这需要我们根据目标框架的特征,以及对框架的期望,做出合适的决策。
纯运行时
假设我们设计了一个框架,它提供一个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))
}
// 将元素添加到root
root.appendChild(el)
}有了这个函数,用户就可以这样使用它:
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}
// 渲染到body下
Render(obj, document.body)在浏览器中运行上述代码,就可以看到我们预期的内容。
这就是纯运行时的框架,可以发现,用户在使用它渲染内容时,直接为Render函数提供了一个树型结构的数据对象。这里面不涉及任何额外的步骤,用户也不需要学习额外的知识。
运行时 + 编译时
如果有一天,你的用户抱怨说:“手写树型结构的数据对象太麻烦了,而且不直观,能不能支持用类似于HTML标签的方式描述树型结构的数据对象呢?”
为了满足用户的需求,我们可以引入编译的手段,把HTML标签编译成树型结构的数据对象,这样不就可以继续使用Render函数了吗?
例如
<div>
<span>hello world</span>
</div>编译成
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}为此,我们编写了一个叫做compiler的程序,它的作用就是把HTML字符串编译成树型结构的数据对象,于是交付给用户去用了,用户可以这样使用:
const html = `
<div><span>hello world</span></div>
`
// 编译得到HTML树型结构的数据对象
const obj = compiler(html)
// 进行渲染
Render(obj, document.body)这时我们的框架就变成了一个运行时 + 编译时的框架。
纯编译时
运行时编译的框架,是代码运行的时候才开始编译,而这会产生一定的性能开销。因此我们也可以在构建的时候就执行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函数得到这些信息之后就可以做进一步的优化了。
假如我们的框架是纯编译时的,那么它也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的JS代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。
Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能的去优化。
等后面讲解编译优化相关的内容时,你会看到Vue.js 3在保留运行时的情况下,其性能甚至不输纯编译时的框架。