Vue框架设计:性能权衡的艺术

“框架设计里到处都体现了权衡的艺术。”

  当我们设计一个框架的时候,框架本身的各个模块之间并不是相互独立的,而是相互关联、相互制约的。因此作为框架设计者,一定要对框架的定位和方向拥有全局的把控,这样才能做好后续的模块设计和拆分。同样,作为学习者,我们在学习框架的时候,也应该从全局的角度对框架的设计拥有清晰的认知,否则很容易被细节困住,看不清全貌。

  另外,从范式的角度来看,我们的框架应该设计成命令式的还是声明式的呢?这两种范式有何优缺点?我们能否汲取两者的优点?除此之外,我们的框架要设计成纯运行时的还是纯编译时的,甚至是运行时+编译时的呢?它们之间又有何差异?优缺点分别是什么?这里面都体现了“权衡”的艺术,下面我们一一“权衡”。

 


命令式和声明式

  从范式上来看,视图层框架通常分为命令式和声明式,它们各有优缺点。作为框架设计者,应该对两种范式都有足够的认知,这样才能做出正确的选择,甚至想办法汲取两者的优点并将其捏合。接下来,我们先来看看命令式框架和声明式框架的概念。

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

1 获取 id 为 app 的 div 标签
2 它的文本内容为 hello world
3 为其绑定点击事件
4 当点击时弹出提示: ok

  对应的jQuery代码为:

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

  考虑到有些人可能没有用过jQuery,因此我们再用原生JavaScript来实现同样的功能:

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

  可以看到,自然语言描述能够与代码产生一一对应的关系,代码本身描述的是“做事的过程”,这符合我们的逻辑直觉。

  那么,什么是声明式框架呢?命令式框架更加关注过程不同,声明式框架更加关注结果。结合Vue.js,我们来看看它如何实现上面自然语言描述的功能:

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

  这段类HTML的代码就是Vue.js实现上述功能的方式。可以看到,我们提供的是一个“结果”,至于如何实现这个“结果”,我们并不关心,这就像我们在告诉Vue.js:“嘿,Vue.js,我需要一个文本内容是hello world的div,它有个事件绑定,你帮我搞定吧。”至于实现该“结果”的过程,则是由Vue.js帮我们完成的。换句话说,Vue.js帮我们封装了过程

  因此,我们能够猜到Vue.js的内部实现一定是命令式的,而暴露给用户的却更加声明式

 


性能与可维护性的权衡

  命令式和声明式各有优缺点,在框架设计方面,则体现在性能与可维护性之间的权衡。这里我们先抛出一个结论:声明式代码的性能不优于命令式代码的性能。还是拿上面的例子来说,假设现在我们要将div标签的文本内容修改为 hello vue3,那么如何用命令式代码实现呢?很简单,因为我们明确知道要修改的是什么,所以直接调用相关命令操作即可:

div.textContent = 'hello vue3'    // 直接修改

  现在思考一下,还有没有其他办法比上面这句代码的性能更好?答案是“没有”。可以看到,理论上命令式代码可以做到极致的性能优化,因为我们明确知道哪些发生了变更,只做必要的修改就行了。但是声明式代码不一定能做到这一点,因为它描述的是结果:

<!-- 之前: -->
<div @click=" () => alert('ok') ">hello world</div>

<!-- 更改后: -->
<div @click=" () => alert('ok') ">hello vue3</div>

  对于框架来说,为了实现最优的更新性能,它需要找到前后的差异并只更新变化的地方,但是最终完成这次更新的代码仍然是:

div.textContent = 'hello vue3'    // 直接修改

  如果我们把直接修改的性能消耗定义为A,把找出差异的性能消耗定义为B,那么有:

    ● 命令式代码的更新性能消耗 = A

    ● 声明式代码的更新性能消耗 = B + A

  可以看到,声明式代码会比命令式代码多出找出差异的性能消耗。因此,最理想的情况是,当找出差异的性能消耗为0时,声明式代码与命令式代码的性能相同,但是无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。这符合前文中给出的性能结论:声明式代码的性能不优于命令式代码的性能。

  既然在性能层面命令式代码是更好的选择,那么为什么Vue.js要选择声明式的设计方案呢?原因就在于声明式代码的可维护性更强。从上面例子的代码中我们也可以感受到,在采用命令式代码开发的时候,我们需要维护实现目标的整个过程,包括要手动完成DOM元素的创建、更新、删除等工作。而声明式代码展示的就是我们要的结果,看上去更加直观,至于做事儿的过程,并不需要我们关心,Vue.js都为我们封装好了。

  这就体现了我们在框架设计上要做出的关于可维护性与性能之间的权衡。在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。如何做到最小呢?Vue.js用到了一个技术:虚拟DOM,我们接着往下深入研究。

 


虚拟DOM的性能到底如何?

  前文说到,声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗,因此,如果我们能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而所谓的虚拟DOM,就是为了最小化找出差异这一步的性能消耗而出现的。

  至此,相信你也应该清楚一件事了,那就是采用虚拟DOM的更新技术的性能理论上不可能比原生JavaScript操作DOM更高。这里我们强调了理论上三个字,因为这很关键,为什么呢?因为在大部分情况下,我们很难写出绝对优化的命令式代码,尤其是当应用程序的规模很大的时候,即使你写出了极致优化的代码,也一定耗费了巨大的精力,这时的投入产出比其实并不高。那么,有没有什么办法能够让我们不用付出太多的努力(写声明式代码),还能够保证应用程序的性能下限,让应用程序的性能不至于太差,甚至想办法逼近命令式代码的性能呢?这其实就是虚拟 DOM 要解决的问题。

  不过前文中所说的原生JavaScript实际上指的是像document.createElement之类的DOM操作方法,并不包含innerHTML,因为它比较特殊,需要单独讨论。在早年使用jQuery或者直接使用JavaScript编写页面的时候,使用innerHTML来操作页面非常常见。其实我们可以思考一下:使用innerHTML操作页面和虚拟DOM相比性能如何?

  innerHTML和document.createElement等DOM操作方法有何差异?先来看第一个问题,为了比较innerHTML和虚拟DOM的性能,我们需要了解它们创建、更新页面的过程。

  先讨论【创建页面】的过程。

  对于innerHTML来说,为了创建页面,我们需要构造一段HTML字符串:

const html = "
    <div>
        <span>. ..</span>
    </div>
"

  接着将该字符串赋值给 DOM 元素的 innerHTML 属性:

div.innerHTML = html

  然而这句话远没有看上去那么简单。为了渲染出页面,首先要把字符串解析成DOM树,这是一个DOM层面的计算。我们知道,涉及DOM的运算要远比JavaScript层面的计算性能差,这有一个使用jsbench测试的跑分结果可供参考,如下图所示。

  在上图中,上边是纯JavaScript层面的计算,循环10000次,每次创建一个JavaScript对象并将其添加到数组中;下边是DOM操作,每次创建一个DOM元素并将其添加到页面中。跑分结果显示,纯JavaScript层面的操作要比DOM操作快得多,它们不在一个数量级上。基于这个背景,我们可以用一个公式来表达通过innerHTML创建页面的性能:HTML字符串拼接的计算量 + innerHTML的DOM计算量。

  接下来,我们讨论虚拟DOM在创建页面时的性能。虚拟DOM创建页面的过程分为两步:第一步是创建JavaScript对象,这个对象可以理解为真实DOM的描述;第二步是递归地遍历虚拟DOM树并创建真实DOM。我们同样可以用一个公式来表达通过DOM创建页面的性能:创建JavaScript对象的计算量 + 创建真实DOM的计算量。

  上图直观地对比了innerHTML和虚拟DOM在创建页面时的性能,可以看到,无论是纯JavaScript层面的计算,还是DOM层面的计算,其实两者差距不大。这里我们从宏观的角度只看数量级上的差异。如果在同一个数量级,则认为没有差异。在创建页面的时候,都需要新建所有DOM元素。

-----------------------------------------------------------------------------------------

【总结一下】:

  纯JavaScript层面的操作要比DOM操作快得多,它们不在一个数量级上。基于这个背景,我们主要关注DOM层面的计算,可以发现,无论是纯JavaScript层面的计算,还是DOM层面的计算,其实innerHTML和虚拟DOM在创建页面时的性能差距不大,在创建页面的时候,都需要新建所有DOM元素。

-----------------------------------------------------------------------------------------

  我们讨论了创建页面时的性能情况,大家可能会觉得虚拟DOM相比innerHTML 没有优势可言,甚至细究的话性能可能会更差。

  别着急,接下来我们讨论【更新页面】的过程。

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

  再来看虚拟DOM是如何更新页面的:它需要重新创建JavaScript对象(虚拟DOM树),然后比较新旧虚拟DOM,找到变化的元素并更新它。

  下图可作为二者跟新页面时的对照:

  对比虚拟DOM和innerHTML在更新页面时的性能,可以发现,在更新页面时,虚拟DOM在JavaScript层面的运算要比创建页面时多出一个Diff的性能消耗,然而它毕竟也是JavaScript层面的运算,所以不会产生数量级的差异。

  再观察DOM层面的运算,可以发现虚拟DOM在更新页面时只会更新必要的元素,但innerHTML需要全量更新。这时虚拟DOM的优势就体现出来了。

  另外,我们发现,当更新页面时,影响虚拟DOM的性能因素与影响innerHTML的性能因素不同对于虚拟DOM来说,无论页面多大,都只会更新变化的内容,而对于innerHTML来说,页面越大,就意味着更新时的性能消耗越大。如果加上性能因素,那么最终它们在更新页面时的性能如下图所示:

-----------------------------------------------------------------------------------------

【总结一下】:

  JavaScript层面的运算:在更新页面时,虚拟DOM要比创建页面时多出一个Diff的性能消耗,然而它毕竟是JavaScript层面的运算,所以不会产生数量级的差异。

  DOM层面的运算:虚拟DOM在更新页面时只会更新必要的元素,但innerHTML需要全量更新。对于虚拟DOM来说,无论页面多大,都只会更新变化的内容,而对于innerHTML来说,页面越大,就意味着更新时的性能消耗越大。

-----------------------------------------------------------------------------------------

 


  

  至此,我们可以总结一下innerHTML、虚拟DOM以及原生JavaScript(指createElement等方法)在更新页面时的性能,如下图所示:

  我们分了几个维度:心智负担、可维护性和性能。

  原生DOM操作方法的心智负担最大,因为你要手动创建、删除、修改大量的DOM元素。但它的性能是最高的,不过为了使其性能最佳,我们同样要承受巨大的心智负担。另外,以这种方式编写的代码,可维护性也极差

  而对于innerHTML来说,由于我们编写页面的过程有一部分是通过拼接HTML字符串来实现的,这有点儿接近声明式的意思,但是拼接字符串总归也是有一定心智负担的,而且对于事件绑定之类的事情,我们还是要使用原生JavaScript来处理。如果innerHTML模板很大,则其更新页面的性能最差,尤其是在只有少量更新时。

  最后,我们来看看虚拟DOM,它是声明式的,因此心智负担小可维护性强性能虽然比不上极致优化的原生JavaScript,但是在保证心智负担和可维护性的前提下相当不错

  至此,我们有必要思考一下:有没有办法做到,既声明式地描述UI,又具备原生JavaScript的性能呢?看上去有点儿鱼与熊掌兼得的意思,我们下面继续讨论。

 


运行时和编译时

  当设计一个框架的时候,我们有三种选择:纯运行时的、运行时 + 编译时的或纯编译时的。这需要我们根据目标框架的特征,以及对框架的期望,做出合适的决策。另外,为了做出合适的决策,我们需要清楚地知道什么是运行时,什么是编译时,它们各自有什么特征,它们对框架有哪些影响,我们将逐步讨论这些内容。

  我们先聊聊纯运行时的框架。

  假设我们设计了一个框架,它提供一个Render函数,用户可以为该函数提供一个树型结构的数据对象,然后Render函数会根据该对象递归地将数据渲染成DOM元素。我们规定树型结构的数据对象如下:

const obj = {
    tag: 'div',
    children: [
        {tag: 'span', children: 'hello world'}
    ]
}

  每个对象都有两个属性:tag代表标签名称,children既可以是一个数组(代表子节点),也可以直接是一段文本(代表文本子节点)。接着,我们来实现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函数的。可以发现,用户在使用它渲染内容时,直接为Render函数提供了一个树型结构的数据对象。这里面不涉及任何额外的步骤,用户也不需要学习额外的知识。但是有一天,你的用户抱怨说:“手写树型结构的数据对象太麻烦了,而且不直观,能不能支持用类似于HTML标签的方式描述树型结构的数据对象呢?”你看了看现在的Render函数,然后回答:“抱歉,暂不支持。”实际上,我们刚刚编写的框架就是一个纯运行时的框架。

  为了满足用户的需求,你开始思考,能不能引入编译的手段,把HTML标签编译成树型结构的数据对象,这样不就可以继续使用Render函数了吗?思路下图所示。

 

  要把HTML标签编译成树型结构的数据对象,为此,你编写了一个叫作Compiler的程序,它的作用就是把HTML字符串编译成树型结构的数据对象,于是交付给用户去用了。那么用户该怎么用呢?其实这也是我们要思考的问题,最简单的方式就是让用户分别调用Compiler函数和Render函数:

const html = “
    <div>
        <span>hello world</span>
    </div>
// 调用Compiler编译得到树型结构的数据对象
const obj = Compiler(html)
// 再调用Render进行渲染
Render (obj, document.body)

  上面这段代码能够很好地工作,这时我们的框架就变成了一个运行时 + 编译时的框架。它既支持运行时,用户可以直接提供数据对象从而无须编译;又支持编译时,用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。准确地说,上面的代码其实是运行时编译,意思是代码运行的时候才开始编译而这会产生一定的性能开销,因此我们也可以在构建的时候就执行Compiler程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。

  不过,聪明的你一定意识到了另外一个问题:既然编译器可以把HTML字符串编译成数据对象,那么能不能直接编译成命令式代码呢?下图展示了将HTML字符串编译为命令式代码的过程。

 

  这样我们只需要一个Compiler函数就可以了,连Render都不需要了。其实这就变成了一个纯编译时的框架,因为我们不支持任何运行时内容,用户的代码通过编译器编译后才能运行。

  我们用简单的例子讲解了框架设计层面的运行时、编译时以及运行时 + 编译时。我们发现,一个框架既可以是纯运行时的,也可以是纯编译时的,还可以是既支持运行时又支持编译时的。那么,它们都有哪些优缺点呢?是不是既支持运行时又支持编译时的框架最好呢?为了搞清楚这个问题,我们逐个分析。

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

  然而,假如我们设计的框架是纯编译时的,那么它也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的JavaScript代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。

  实际上,在这三个方向上业内都有探索,其中Svelte就是纯编译时的框架,但是它的真实性能可能达不到理论高度。Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化,Vue.js 3 在保留运行时的情况下,其性能甚至不输纯编译时的框架。

 


总结

  首先,我们讨论了命令式和声明式这两种范式的差异,其中命令式更加关注过程,而声明式更加关注结果命令式在理论上可以做到极致优化,但是用户要承受巨大的心智负担;而声明式能够有效减轻用户的心智负担,但是性能上有一定的牺牲,框架设计者要想办法尽量使性能损耗最小化

  接着,我们讨论了虚拟DOM的性能,并给出了一个公式:声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。虚拟DOM的意义就在于使找出差异的性能消耗最小化。我们发现,用原生JavaScript操作DOM的方法(如 document.createElement)、虚拟DOM 和 innerHTML 三者操作页面的性能,不可以简单地下定论,这与页面大小、变更部分的大小都有关系,除此之外,与创建页面还是更新页面也有关系,选择哪种更新策略,需要我们结合心智负担、可维护性等因素综合考虑。一番权衡之后,我们发现虚拟DOM是个还不错的选择。

  最后,我们介绍了运行时和编译时的相关知识,了解纯运行时、纯编译时以及两者都支持的框架各有什么特点,并总结出 Vue.js 3 是一个编译时 + 运行时的框架,它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。

 

 

 

 

参考资料:《Vue.js设计与实现》霍春阳 

使用工具: JSBench

 

 

posted @ 2024-02-26 21:22  城北有个混子  阅读(111)  评论(0编辑  收藏  举报