命令式和声明式&&运行时和编译时

95 阅读8分钟

本文主要摘抄自《Vue.js设计与实现》第一章,介绍了框架的基础知识,详情可阅读原书籍

1.命令式与声明式

了解框架一定要了解框架的范式,通常分为声明式和命令式,他们各有优缺点,作为框架设计者应该对这两种框架有足够的认知,甚至想办法汲取两者的优点并将其结合。

早年间流行的JQuery就是典型的命令式框架。命令式框架的一大特点就是关注过程。例如,我们将下面这段话翻译成对应的代码:

- 获取id为app的div标签
- 他的文本内容是hello world
- 为其绑定点击事件
- 当点击时弹出提示:ok

const div = document.querySelector('#app') //获取div
div.innerText = 'hello world' //设置文本内容
div.addEventListener('click',()=>{alert('ok')}) //绑定点击事件

可以看到,自然语言描述可以和代码一一对应,代码本身描述的是‘做事的过程’,这符合我们的逻辑自觉。

那么,什么是声明式呢?声明式不关注过程,更加关注结果。Vue.js就是声明式框架

<div @click="()=> alert('ok')">hello world</div>

可以看到,声明式提供的是结果,至于这个结果怎么实现,我们不关心。实现该结果的过程,Vue.js 会帮我们完成。换句话说,Vue.js帮我们封装了过程。因此,我们能猜到Vue内部一定是命令式的,而暴露给用户的却是声明式的

2.性能与可维护下的权衡

声明式代码的性能不优于命令式代码的性能

用上面的例子来说明,要将div标签的文本内容修改为hello vue3。用命令式实现的方法很简单,div.textContent = 'hello vue3',已经没有比这句代码性能更好的代码了。理论上命令式代码可以做到极致的性能优化,因为我们知道明确发生了哪些改变,只做必要的修改就好了。声明式代码就不是这样,对于框架来说,为了实现最优的更新性能,他需要找到前后的差异并只更新变化的地方,但底层实现原理仍然是通过命令式代码来完成。

因此,声明式代码的更新性能消耗会比命令式代码多出找出差异的性能消耗,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式,但是声明式代码帮助我们省去了实现目标的过程,包括要手动完成DOM元素的创建、更新、删除等工作。因此Vue.js是在保持可维护性的同时让性能损失最小化。

3.虚拟DOM的性能如何

声明式代码的更新性能消耗会比命令式代码多出找出差异的性能消耗,那么如果能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码了,所谓的虚拟DOM,就是为了最小化找出这一步的性能消耗而出现的

对创建元素的性能差距

我们使用innerHTML创建元素时,需要构建一段HTML字符串const html = <div></div>,在将这段字符串赋值给DOM元素div.innerHTML = html。这个过程没有那么简单,为了渲染页面,首先要把字符串解析成DOM树,这是DOM层面的运算,而DOM层的运算比JavaScript层面的计算大得多,甚至不是一个量级的。可以用一个公式来表达通过innerHTML创建页面的性能:HTML字符串拼接的计算量+innerHTML的DOM计算量

而虚拟DOM创建页面的过程分为两步:第一步创建JavaScript对象,这个对象可以看成是真实DOM的描述;第二步是递归遍历虚拟DOM树并创建真实DOM。用公式来表达:创建JavaScript对象的计算量+创建真实DOM的计算量

可以看到,无论是哪方面,虚拟DOM和纯JavaScript都需要新建所有元素,两者额的性能都不大。

更新DOM元素的性能差距

使用innerHTML更新页面的过程是重新构建HTML字符串,在重新设置DOM元素的innerHTML属性,这其实是在说,哪怕只更改了一个文字,也要重新设置innerHTML属性。而重新设置innerHTML属性就等价于销毁所有旧的DOM元素,再全量创建新的DOM元素。

而虚拟DOM则需要重新创建JavaScript对象(虚拟对象树),然后比较新旧虚拟DOM,找到变化的元素并更新他。

可以看到,虚拟DOM在JavaScript层面会多一个diff算法来比较新旧虚拟DOM,但这只是JavaScript层面的运算,并不会产生量级的差异。而在DOM运算方面,虚拟DOM在更新页面时只会更新必要的元素,但innerHTML需要全量更新。对于虚拟DOM来说,无论页面多大,都只会更新变化的内容,对于innerHTML来说,页面越大,性能的消耗越大

总结

我们总结一下innerHTML、虚拟DOM以及原生JavaScript(指createElement等方法更新页面的性能):原生JavaScript操作方法性能最高,因为它直接操作DOM元素,但是需要手动创建、删除、修改大量DOM元素,对程序员负担过重,并且可维护性差。对于innerHTML来说,更新性能最差,尤其是更新页面很大的时候。而虚拟DOM是声明式的(你可能还不知道什么是虚拟DOM,但没有关系,后面的文章会讲到),对程序员负担小,可维护性强,性能虽然比不上极致优化的JavaScript代码,但是在保证心智负担和可维护性的前提下相当不错

4.运行时和编译时

设计一个框架我们有三种选择:纯运行时、运行时+编译时、纯编译时

纯运行时

假设我们在设计一个纯运行时的框架,它提供一个Render函数,用户需要为该函数提供一个树形结构的数据对象,然后render函数会根据该对象递归地将数据渲染成DOM元素。

用户在使用Render函数时,需要直接为Render函数提供一个树形结构的数据对象。这里面不涉及任何额外的步骤,如果有一天用户想使用类似HTML标签的方式描述树形结构的数据对象,那我们不支持,有点类似于命令式。实际上,我们刚刚编写的框架就是一个纯运行时的框架

运行时+编译时

为了满足用户的需求,我们开始思考能不能引入编译的手段,将HTML标签编译成树形结构的数据对象,这样不就可以继续用Render函数了吗?

当我们实现了这个功能并命名为Compiler(),向Compiler传入一段HTML的字符串,我们就可以将它转化为树形结构的数据对象,然后使用Render。此时我们的框架就变成了一个运行时+编译时的框架。它即支持运行时,用户可以直接提供数据对象从而无需编译;又支持编译时,用户可以提供HTML字符串,将其编译为数据对象再交给运行时处理。

准确来说,上面的代码是运行时编译的,这会产生一定的性能开销,因此我们也可以在构建的时候就将用户的内容编译好,这对性能是非常友好的

image.png

编译时

那么如果编译器Compiler()可以将HTML转化为数据对象,那么为什么不能直接编译成命令式代码呢?即我们的目的是通过HTML代码转化成DOM元素,因为我们底层必然是通过JavaScript代码来执行创建DOM元素的(document.createElement('div'))。

image.png

优缺点

对纯运行的框架来说,由于没有编译过程,因此我们没有办法分析用户提供的内容。但是如果加入了编译过程,我们就可以分析用户提供的内容,看看哪些内容可以改变哪些内容不可以改变,这样我们就可以在编译的时候提取这些信息,然后传递给Render函数,Render函数得到这些信息之后就可以进一步优化了。

而如果是纯编译时,则没有了运行过程,而是直接编译成可执行的JavaScript代码,因此性能可能更好,但是有损灵活性,即用户提供的内容必须编译后才能用

对于这三种模式,个人可以理解为:纯运行可以理解为用户提供了什么,那框架就直接运行,不提供任何帮助。加上编译过程,那么就可以的到用户的输入,然后框架可以进行编译,将用户的输入进行分析,然后进行优化或者帮用户省去一些过程(比如自己创建DOM),但有时候用户本身的内容就不需要编译的话,那么框架也不会帮忙编译。纯编译就是无论用户提供什么内容都会进行编译,有损灵活性