Vue.js 权衡的艺术
刚工作时把《Vue.js设计与实现》这本书看了一遍,当时觉得写的确实挺好的,很多地方讲解的很清晰,但奈何当时Vue3用的不多,很多地方没看懂,现在想回过头来重新撸一遍,顺便当作笔记记录一下。
1 命令时和声明式
视图层框架通常分为命令式和声明式,它们各有优缺点,jQuery就是典型的命令式框架。命令式框架的一大特点就是关注过程。而声名式框架更加关注结果。
如用下面两段代码:
//命令式
const div = document.querySelector('#app') //获取div
div.innerText = 'hello world' //设置文本内容
div.addEventListener('click',()=>{alert('ok')}//绑定点击事件
//声明式
<div @click="()=> alert('ok')">hello world</div>
1.2 性能与可维护性的权衡
结论:声明式代码的性能不优于命令式代码的性能。
以上面代码为例子,假设要将div标签的文本内容改为hello vue3:
//命令式
div.textContent = 'hello vue3'//直接修改
//声明式 vue.js
//修改前
<div @click="()=> alert('ok')">hello world</div>
//修改后
<div @click="()=> alert('ok')">hello vue3</div>
可以看到,理论上命令式代码可以做到极致的性能优化,而声明式代码需要找到前后的差异并只更新变化的地方,如果把直接修改的性能小号定义为A,把找出差异的性能消耗定义为B,则
- 命令式代码的更新性能消耗 = A
- 声明式代码的更新消耗 = A+B
因此最理想的情况是,当找出差异的性能消耗为0时,声明式代码与命令式代码的性能相同,当无法超越。毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。
声明式相比于命令式的优点在于它的可维护性更强,因为声明式的代码更加的直观,我们只需要关心结果,至于过程都被Vue.js封装好了。
1.3 虚拟DOM的性能到底如何
虚拟DOM目的是为了最小化找出更新的差异这一步的性能消耗而出现的。
innerHTML创建页面过程:
const html = `
<div><span>...</span></div>
`
div.innerHTML = html
我们知道涉及到DOM层面的运算要远比JavaScript层面的计算性能差,如图跑分结果:
上面是纯JavaScript层面的运算,下面是DOM操作,他们的速度不在一个量级上。
下面直观的对比innerHTML和虚拟DOM在创建页面时的性能:
可以看到在创建时虚拟DOM和innerHTML没有差异,但是在更新页面时性能会有所不同:
可以发现,在更新页面时,虚拟DOM在JavaScript层面的运算要比创建页面时多一个Diff的性能消耗,然而它毕竟时JavaScript层面的运算,不会产生太大的差异。而DOM层面的运算只会更新必要的元素,但是innerHTML需要全部更新。
当更新页面时,对于虚拟DOM来说,无论页面多大,都只会更新变化的内容,而对于innerHTML来说,页面越大,更新时的性能消耗越大。
从三个维度分析innerHTML、虚拟DOM和原生JavaScript在更新页面时的性能:心智负担、可维护性和性能。
- 原生DOM操作方法的心智负担最大,因为你要手动创建、删除、修改大量的DOM元素。但它的性能是最高的,与此同时我们也要承受巨大的心智负担。另外,这种编码方式可维护性也极差。
- innerHTML通过拼接HTML字符串来实现,这有点接近声明式的意思,但是拼接字符串总归也是有一定心智负担的,而且对于事件绑定之类的,还要使用原生JavaScript来处理。如果innerHTML很大,则其更新页面性能会最差,尤其是在只有少量更新时。
- 虚拟DOM,他是声明式的,因此心智负担小,可维护性强,性能虽然比不上极致优化的原生JavaScript,当也不差。
1.4 运行时和编译时
设计一个框架时,有三种选择:纯运行时的、运行时+编译时、纯编译时的。
纯运行时:
假设我们设计一个框架,它提供了一个Render函数,用户可以为该函数提供一个树型结构的数据对象,然后Render函数会根据该对象递归地将数据渲染成DOM元素。
const obj = {
tag:'div',
children:[
{ tag:'span',children:'hello world' }
]
}
function Render(obj,root){
const el = document.createElement(obj.tag)
if(typeof obj.children === 'string') {
const text = document.createTextNode(obj.children)
el.appendChildren(text)
}else if(obj.children){
//数组,递归调用Render,使用el作为root参数
obj.children.forEach((child) => Render(child,el))
}
//将元素添加到root
root.appendChild(el)
}
Render(obj, document.body)
用户在使用它渲染内容时,直接为Render函数提供了一个树形结构的数据对象。这就是一个纯运行时的框架。
编译时+运行时
const html = `
<div>
<span>hello world</span>
</div>
`
//调用Compiler编译得到树型结构的数据对象
const obj = Compiler(html)
//再调用Render进行渲染
Render(obj,document.body)
用户可以直接提供HTML字符串,我们将其编译为数据对象后再交付给运行时处理。
纯编译时
编译器直接将HTML字符串编译成命令式代码,不再需要Render函数。
纯运行时的框架没有编译的过程,因此没办法分析用户提供的任容,如果加入编译步骤,就可以分析用户提供的内容,看看那些内容未来可以改变,哪些不会改变,这样可以在编译的时候提取这些信息,然后传递给Render函数做进一步的优化。
纯编译时的框架不需要任何运行时,而是直接编译为JavaScript代码,因此性能可能会更好,但是灵活性很低,用户提供的内容必须编译后才能用。