前言
"框架设计里到处都体现了权衡的艺术。"
从范式的角度来看,我们的框架应该设计成命令式的还是声明式的呢?🤔这两种范式有何优缺点?我们能否汲取两者的优点?除此之外,我们的框架要设计成纯运行时的还是纯编译时的,甚至是运行时+编译时的呢?它们之间又有何差异?优缺点分别是什么?这里面都体现了"权衡"的艺术。👩🏿🦽
命令式和声明式
从范式上来看,视图层框架通常分为命令式和声明式,它们各有 优缺点。作为框架设计者,应该对两种范式都有足够的认知,这样才 能做出正确的选择,甚至想办法汲取两者的优点并将其捏合。
命令式
命令式框架的一大特点就是关注过程。
js
const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
可以看到,自然语言描述能够与代码产生一一对应的关系,代码本身描述的是"做事的过程",这符合我们的逻辑直觉。
声明式
与命令式框架更加关注过程不同,声明式框架更加关注结果。
js
<div @click="() => alert('ok')">hello world</div>
可以看到,我们提供的是一个"结果",至于如何实现这个"结果",我们并不关心
Vue.js 帮我们封装了过程。因此,我们能够猜到 Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。
性能与可维护性的权衡
命令式和声明式各有优缺点,在框架设计方面,则体现在性能与可维护性之间的权衡。这里我们先抛出一个结论:声明式代码的性能不优于命令式代码的性能。🤏🏿
如果我们把直接修改的性能消耗定义为 A,把找出差异的性能消耗定义为 B,
那么有:
- 命令式代码的更新性能消耗 = A
- 声明式代码的更新性能消耗 = B + A
既然在性能层面命令式代码是更好的选择,那么为什么 Vue.js 要选择声明式的设计方案呢?🧐原因就在于声明式代码的可维护性更强。
从上面例子的代码中我们也可以感受到,在采用命令式代码开发的时候,我们需要维护实现目标的整个过程,包括要手动完成 DOM 元素的创建、更新、删除等工作。而声明式代码展示的就是我们要的结果,看上去更加直观,至于做事儿的过程,并不需要我们关心,Vue.js都为我们封装好了。💞
这就体现了我们在框架设计上要做出的关于可维护性与性能之间的权衡。在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化
虚拟DOM的性能
前文说到,声明式代码的更新性能消耗 = 找出差异的性能消耗+ 直接修改的性能消耗 ,因此,如果我们能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而所谓的虚拟 DOM,就是为了最小化找出差异这一步的性能消耗而出现的。
至此,相信你也应该清楚一件事了,那就是采用虚拟 DOM 的更新技术的性能理论上不可能比原生 JavaScript 操作 DOM 更高。😭
innerHTML、虚拟 DOM 以及原生 JavaScript(指 createElement 等方法)在更新页面时的性能
有没有办法做到,既声明式地描述UI,又具备原生 JavaScript 的性能呢?😤
运行时和编译时
设计一个框架的时候,我们有三种选择:纯运行时的、运行时 +编译时的或纯编译时的。这需要你根据目标框架的特征,以及对框架的期望,做出合适的决策。另外,为了做出合适的决策,你需要清楚地知道什么是运行时,什么是编译时,它们各自有什么特征,它们对框架有哪些影响
运行时
假设我们设计了一个框架,它提供 一个 Render 函数,用户可以为该函数提供一个树型结构的数据对 象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元 素。我们规定树型结构的数据对象如下:
js
const obj = {
tag: 'div',
children: [
{ tag: 'span', children: 'hello world' }
]
}
接着,我们来实现 Render 函数 :
js
01 function Render(obj, root) {
02 const el = document.createElement(obj.tag)
03 if (typeof obj.children === 'string') {
04 const text = document.createTextNode(obj.children)
05 el.appendChild(text)
06 } else if (obj.children) {
07 // 数组,递归调用 Render,使用 el 作为 root 参数
08 obj.children.forEach((child) => Render(child, el))
09 }
10
11 // 将元素添加到 root
12 root.appendChild(el)
13 }
有了这个函数,用户就可以这样来使用它:
js
01 const obj = {
02 tag: 'div',
03 children: [
04 { tag: 'span', children: 'hello world' }
05 ]
06 }
07 // 渲染到 body 下
08 Render(obj, document.body)
实际上,我们刚刚编写的框架就是一个纯运行时的框架。
为了满足用户的需求,你开始思考,能不能引入编译的手段,把 HTML 标签编译成树型结构的数据对象,这样不就可以继续使用 Render 函数了吗🧐
运行时 + 编译时
为此,你编写了一个叫作 Compiler 的程序,它的作用就是把 HTML 字符串编译成树型结构的数据对象
js
01 const html = `
02 <div>
03 <span>hello world</span>
04 </div>
05 `
06 // 调用 Compiler 编译得到树型结构的数据对象
07 const obj = Compiler(html)
08 // 再调用 Render 进行渲染
09 Render(obj, document.body)
上面这段代码能够很好地工作,这时我们的框架就变成了一个 运行时 + 编译时 的框架。它既支持运行时,用户可以直接提供数据对象从而无须编译;又支持编译时,用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。准确地说,上面的代码其实是运行时编译,意思是代码运行的时候才开始编译,而这会产生一定的性能开销,因此我们也可以在构建的时候就执行 Compiler 程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。
Vue 3 使用的就是 运行时 + 编译时 的架构🎯