1、框架设计的权衡
当我们设计一个框架的时候,框架本身的各个模块之间并不是相互独立的,而是相互关联、相互制约的。因此作为框架设计者,一定要对框架的定位和方向拥有全局的把控,这样才能做好后续的模块设计和拆分。同样,作为学习者,我们在学习框架的时候,也应该从全局的角度对框架的设计拥有清晰的认知,否则很容易被细节困住,看不清全貌。
1.1、命令式和声明式
从范式上来看,视图层框架通常分为命令式和声明式,它们各有优缺点。作为框架设计者,应该对两种范式都有足够的认知,这样才能做出正确的选择,甚至想办法汲取两者的优点并将其捏合。
命令式框架的一大特点就是关注过程。例如,我们把下面这段话翻译成对应的代码:
01 - 获取 id 为 app 的 div 标签
02 - 它的文本内容为 hello world
03 - 为其绑定点击事件
04 - 当点击时弹出提示:ok
对应的代码为:
01 $('#app') // 获取 div
02 .text('hello world') // 设置文本内容
03 .on('click', () => { alert('ok') }) // 绑定点击事件
以上就是 jQuery 的代码示例,考虑到有些读者可能没有用过 jQuery,因此我们再用原生 JavaScript 来实现同样的功能:
01 const div = document.querySelector('#app') // 获取 div
02 div.innerText = 'hello world' // 设置文本内容
03 div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
可以看到,自然语言描述能够与代码产生一一对应的关系,代码本身描述的是“做事的过程”,这符合我们的逻辑直觉。
声明式框架:与命令式框架更加关注过程不同,声明式框架更加关注结果。结合 Vue.js,我们来看看如何实现上面自然语言描述的功能:
01 <div @click="() => alert('ok')">hello world</div>
这段类 HTML 的模板就是 Vue.js 实现如上功能的方式。可以看到,我们提供的是一个“结果”,至于如何实现这个“结果”,我们并不关心。
Vue.js 帮我们封装了过程。因此,我们能够猜到 Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。
1.2、性能与可维护性的权衡
命令式和声明式各有优缺点,在框架设计方面,则体现在性能与可维护性之间的权衡。这里我们先抛出一个结论:声明式代码的性能不优于命令式代码的性能。
假设现在我们要将 div 标签的文本内容修改为 hello vue3,那么如何用命令式代码实现呢?很简单,因为我们明确知道要修改的是什么,所以直接调用相关命令操作即可:
01 div.textContent = 'hello vue3' // 直接修改
现在思考一下,还有没有其他办法比上面这句代码的性能更好?答案是“没有”。可以看到,理论上命令式代码可以做到极致的性能优化,因为我们明确知道哪些发生了变更,只做必要的修改就行了。但是声明式代码不一定能做到这一点,因为它描述的是结果:
01 <!-- 之前: -->
02 <div @click="() => alert('ok')">hello world</div>
03 <!-- 之后: -->
04 <div @click="() => alert('ok')">hello vue3</div>
对于框架来说,为了实现最优的更新性能,它需要找到前后的差异并只更新变化的地方,但是最终完成这次更新的代码仍然是:
01 div.textContent = 'hello vue3' // 直接修改
如果我们把直接修改的性能消耗定义为 A,把找出差异的性能消耗定义为 B,那么有:
- 命令式代码的更新性能消耗 = A
- 声明式代码的更新性能消耗 = B + A
可以看到,声明式代码会比命令式代码多出找出差异的性能消耗,因此最理想的情况是,当找出差异的性能消耗为 0 时,声明式代码与命令式代码的性能相同,但是无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。这符合前文中给出的性能结论:声明式代码的性能不优于命令式代码的性能。
既然在性能层面命令式代码是更好的选择,那么为什么 Vue.js 要选择声明式的设计方案呢?原因就在于声明式代码的可维护性更强。从上面例子的代码中我们也可以感受到,在采用命令式代码开发的时候,我们需要维护实现目标的整个过程,包括要手动完成 DOM 元素的创建、更新、删除等工作。而声明式代码展示的就是我们要的结果,看上去更加直观,至于做事儿的过程,并不需要我们关心,Vue.js 都为我们封装好了。
这就体现了我们在框架设计上要做出的关于可维护性与性能之间的权衡。在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。
1.3、虚拟 DOM 的性能到底如何
声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗,因此,如果我们能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而所谓的虚拟 DOM,就是为了最小化找出差异这一步的性能消耗而出现的。
至此,相信你也应该清楚一件事了,那就是采用虚拟 DOM 的更新技术的性能理论上不可能比原生 JavaScript 操作 DOM 更高。这里我们强调了理论上三个字,因为这很关键,为什么呢?因为在大部分情况下,我们很难写出绝对优化的命令式代码,尤其是当应用程序的规模很大的时候,即使你写出了极致优化的代码,也一定耗费了巨大的精力,这时的投入产出比其实并不高。
那么,有没有什么办法能够让我们不用付出太多的努力(写声明式代码),还能够保证应用程序的性能下限,让应用程序的性能不至于太差,甚至想办法逼近命令式代码的性能呢?这其实就是虚拟 DOM 要解决的问题。
innerHTML、虚拟 DOM 以及原生 JavaScript(指 createElement 等方法)在更新页面时的性能比较:
1.4、运行时和编译时
1、纯运行时的框架
设我们设计了一个框架,它提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。我们规定树型结构的数据对象如下:
01 const obj = {
02 tag: 'div',
03 children: [
04 { tag: 'span', children: 'hello world' }
05 ]
06 }
每个对象都有两个属性:tag 代表标签名称,children 既可以是一个数组(代表子节点),也可以直接是一段文本(代表文本子节点)。接着,我们来实现 Render 函数:
每个对象都有两个属性:tag 代表标签名称,children 既可以是一个数组(代表子节点),也可以直接是一段文本(代表文本子节点)。接着,我们来实现 Render 函数:
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 }
有了这个函数,用户就可以这样来使用它:
01 const obj = {
02 tag: 'div',
03 children: [
04 { tag: 'span', children: 'hello world' }
05 ]
06 }
07 // 渲染到 body 下
08 Render(obj, document.body)
以上编写的框架就是一个纯运行时的框架。
2、编译时框架
为了满足用户的需求,你开始思考,能不能引入编译的手段,把 HTML 标签编译成树型结构的数据对象,这样不就可以继续使用 Render 函数了吗?思路如下图:
为此,你编写了一个叫作 Compiler 的程序,它的作用就是把 HTML 字符串编译成树型结构的数据对象,于是交付给用户去用了。那么用户该怎么用呢?其实这也是我们要思考的问题,最简单的方式就是让用户分别调用 Compiler 函数和Render 函数:
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 程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。
不过,聪明的你一定意识到了另外一个问题:既然编译器可以把 HTML 字符串编译成数据对象,那么能不能直接编译成命令式代码呢?下图 展示了将 HTML 字符串编译为命令式代码的过程:
这样我们只需要一个 Compiler 函数就可以了,连 Render 都不需要了。其实这就变成了一个纯编译时的框架,因为我们不支持任何运行时内容,用户的代码通过编译器编译后才能运行。
首先是纯运行时的框架。由于它没有编译的过程,因此我们没办法分析用户提供的内容,但是如果加入编译步骤,可能就大不一样了,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了。然而,假如我们设计的框架是纯编译时的,那么它也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。实际上,在这三个方向上业内都有探索,其中 Svelte 就是纯编译时的框架,但是它的真实性能可能达不到理论高度。Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。