《Vue.js设计与实现》day02:第一章 权衡的艺术

1,154 阅读15分钟

一、前言

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

1. 为什么要讨论视图层框架设计?

当我们设计一个框架的时候, 框架本身的各个模块之间并不是相互独立的,而是相互关联、相互制约的。

作为框架设计者,一定要对框架对定位和方向拥有全局的把控, 这样才能做好后续的模块设计和拆分。

做为学习者,我们在学习框架的时候, 也应该从全局的角度对框架的设计拥有清晰的认知, 否则容易被细节困住,看不清全貌。

2. 体现“权衡”的艺术

  • 从范式角度来看, 我们的框架应该设计成命令式还是声明式?
  • 「命令式」和「声明式」这两种范式有何优缺点?我们能否汲取两者的优点?
  • 框架要设计成「纯运行时」还是「纯编译时」的,甚至是「运行时+编译时」的呢?
  • 「纯运行时」、「纯编译时」、「运行时+编译时」之间有何差异?有何优缺点?

二、本章内容

1.1 命令式和声明式

作为框架设计这, 应该对两种范式都有足够的认知,这样才能做出正确的选择, 甚至想办法汲取两者的优点并将其捏合。

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

1.1.1 命令式框架的概念

早年流行的jQuery 就是典型的命令是框架。特点:命令式框架的一大特点就是 关注过程。

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

以上jQuery和 原生JavaScript当实现中可以看到, 自然语言描述能够与代码产生一一对应的关系,代码本身描述的就是“做事儿的过程”。

1.1.2 声明式框架的概念

与命令式框架更加关注过程不同,声明式框架更加 关注结果。

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

以上类似HMTL的模版就是Vue.js 实现案例功能的方式。可以看到,提供一个“结果”,至于如何实现这个“结果”,并不关心。实现该“结果”的过程, 则是由Vue.js帮我们完成的。

换句话说:Vue.js是帮我们封装了过程。

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

1.2 性能与可维护性的权衡

结论先行:声明式代码的性能不优于命令式代码的性能。

1.2.1 性能比较

举例:假设我们要将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的时候, 声明式代码与命令式代码的性能相同,但无法做到超越。

毕竟框架本身就是封装来命令式代码才实现面向用户的声明式。 得出一开始的性能结论:声明式代码的性能不优于命令式代码的性能。

1.2.2 维护性比较

既然在性能层面命令式代码是更好的选择, 那么为什么Vue.js要选择声明式的设计方案呢?

  • 声明式代码的可维护性更强。

  • 在采用命令式代码开发的时候, 需要维护实现目标的整个过程

  • 包括要手动完成DOM元素的创建、更新、删除等工作

  • 采用声明式代码展示的就是我们要的结果

    • 看上去更加直观
    • 做事儿的过程,并不需要我们关心
    • 因为Vuejs都封装好了

以上就体现了我们在框架设计上要做的关于可维护性与性能之间的权衡。 在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。

1.3 虚拟DOM的性能到底如何

前文提到:声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗, 因此,如果能够最小化找出差异的性能消耗, 就可以让声明式代码的性能无限接近于命令式代码的性能。

所谓的虚拟DOM,就是为了最小化找出差异这一步的性能消耗而出现的。

采用虚拟DOM的更新技术的性能理论上 不可能比原生JavaScript操作DOM更高。为什么是理论上?

在大部分情况下, 我们很难写出绝对优化的命令式代码, 尤其当应用程序的规模很大的时候,即使写出啦极致优化的代码, 也一定耗费了巨大的精力,投入产出比其实并不高。

1.3.1 最佳选择是什么?

有什么办法可以看让我们不需要付出太多的努力(写声明式代码),还能够保证程序的性能下限,让应用程序的性能不至于太差,甚至想办法逼近命令式代码的性能?

  • 其实以上问题,就是虚拟DOM要解决的问题。

思考:

  • 使用innerHTML操作页面和虚拟DOM相比性能如何?
  • innerHTML和document.createElement等DOM操作方法有何差异?

第一个问题:

为了比较性能,需要了解innerHTML和虚拟DOM的创建、更新页面的过程。

1、innerHTML创建页面的过程

const html = `<div><span>...</span></div>` //构造一段HTML字符串

div.innerHTML = html //将HTML字符串赋值给DOM元素的innerHTML属性

渲染页面的过程是:先把字符串解析成DOM树(这是一个DOM层面的计算)。

涉及DOM的运算要远比JavaScript层面的计算性能差。

案例如图:



第一行是纯JavaScript层面的计算,循环10000次,每次创建一个JavaScript对象并将其结果添加到数组中

第二行是DOM操作,每次创建一个DOM元素并将其添加到页面中。

通过跑分结果显示,纯javascript层面的操作要比DOM操作快的多, 它们不再一个数量级上。

基于这个背景,我们可以用一个公式来表达通过innerHTML创建页面的性能:

innerHTML创建页面的性能: HTML字符串拼接的计算量+ innerHTML的DOM计算量。

2、虚拟DOM创建页面的过程

虚拟DOM创建页面的过程分为两步:

  • 创建JavaScript对象,这个对象可以理解为真实DOM的描述
  • 第二步是递归地遍历虚拟DOM树并且、创建真实DOM。

用一个公式来表达:虚拟DOM创建页面时的性能= 创建JavaScript对象的计算量+创建真实DOM的计算量。

直观比较innerHTML和虚拟DOM在创建页面时的性能。



无论是纯JavaScript层面的计算,还是DOM层面的计算, 其实两者差距并不大。 这里我们从宏观角度只看数量级上的差异。如果在同一个数量级,则认为没有差异。在创建页面的时候,都需要新建所有DOM元素。

3、innerHTML更新页面

使用innerHTML更新页面的过程是 重新构建HTML字符串,再重新设置DOM元素的innerHTML属性。 哪怕只更改了一个文字, 也要重新设置innerHTML属性。

重新设置innerHTML属性就等价于销毁所有旧的DOM元素,再全力量创建新的DOM元素。

4、虚拟DOM更新页面

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

在更新页面时

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

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

  • 影响虚拟DOM的性能因素与影响innerHTML的性能因素不同:

    • 虚拟DOM无论页面多大,都只会更新变化的内容
    • innerHTML页面越大,就意味着更新时的性能消耗越大。


粗略总结innerHTML、虚拟DOM以及原生JavaScript在更新页面时的性能,如下:



从三个维度出发分析:心智负担、可维护性和性能。

  • 原生DOM操作的方法

    • 心智负担最大,因为需要手动创建、删除、修改大量的DOM元素
    • 但它的性能最高,不过为了使其性能最佳,需要曾受巨大的心智负担。
    • 这种方式编写代码,可维护性也极差。
  • innerHMTL方法

    • 编写页面的过程有一部分是通过拼接HTML字符串来实现的(一点点接近声明式的意思),但是拼接字符串总归也有一定的心智负担
    • 对于事件绑定之类的事情, 还需要通过使用原生JavaScript来处理。
    • innerHTML模版越大,则更新页面的性能最差, 尤其是在只有少量更新的时候。
  • 虚拟DOM方法

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

思考:有没有办法做到,既声明式的描述UI,又具备原生JavaScirpt的性能?

1.4 运行时和编译时

1、什么是运行时

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

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

每个对象都有两个属性:tag代表标签名称,children即可以是一个数组(代表子节点)、也可以直接是一段文本(代表文本子节点)。

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{
    //数组,递归调用Render函数,使用el作为root参数
    obj.children.forEach((child)=>Render(child,el))
  }
  
  //将元素添加到root
  root.appendChild(el)
}

有了Render函数,用户就可以如此使用它:

const obj = {
  tag:'div',
  children:[
    { tag:'span',children:'hello world'}
  ]
}
//渲染到body下
Render(obj,document.body)

思考:用户是如何使用Render函数的?

  • 用户在使用它渲染内容时,直接为Render函数提供了一个树型结构的数据对象。
  • 痛点:需要用户手写树型结构的数据对象,会很麻烦,而且不止观念。而且不能支持用类似于HTML标签的方式描述树型结构的数据对象。

这个Render函数,实际上这个框架就是一个纯运行时的框架。

2、什么是运行时+编译时

为了满足:支持用类似于HTML标签的方式描述树型结构的数据对象。思考,能不能引入编译的手段。 把HTML标签编译成树型结构的数据对象。这样就不可以继续使用Render函数了



因此,需要编写一个叫做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字符串编译成数据对象, 那么可不可以直接编译成命令式代码呢?

3、什么是编译时



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

4、优缺点对比

  • 纯运行时框架
    • 因为没有编译的过程, 因此我们没办法分析用户提供的内容(缺点)
  • 运行时+编译时

    • 纯运行时框架加入了编译步骤, 我们就可以分析用户提供的内容。
    • 看看那些内容未来可能会改变, 哪些内容可能永远不会改变,这样我们就可以在编译的时候提取这些信息,然后将其传递给Render函数
    • Render函数得到这些信息之后,就可以进一步做优化了。
  • 纯编译时框架

  • 他可以分析用户提供的内容(优点)

    • 由于不需要任何运行时,而是直接编译成可执行的JavaScript代码, 因此性能可能会更好(优点)
    • 但是这种做法有损灵活性,即用户的内容必须编译后才能用。(缺点)
    • 纯编译时框架:Svelte,其中真实的性能可能达不到理论的高度

Vue.js3仍然保持了运行时+编译时的框架,在保证灵活的基础上能够尽可能地去优化。当你了解到在对Vue3对编译优化相关内容的时候,你会看到Vue.js3在保留运行时的情况下,其性能甚至不输纯编译时的框架。

总结

  • 讨论命令式和声明式两种范式的差异。(框架设计者要想办法尽量使性能损耗最小化

  • 命令式更加关注过程

    • 声明式更加关注结果
    • 命令式在理论上可以做到极致优化,但是用户要承受巨大的心智负担
    • 声明式能够有效减轻用户的心智负担,但性能上有一定的牺牲
  • 讨论虚拟DOM的性能。(选择哪种更新策略,需要结合心智负担、可维护性等因素综合考虑)

    • 公式: 声明式的更新性能消耗 = 找出差异的性能消耗+直接修改的性能消耗

    • 虚拟DOM的意义:使找出差异的性能消耗最小化

    • 原生JavaScript操作DOM的方法(document.createElement)、虚拟DOM和innerHTML三者操作页面的性能,不可以简单的下定论。

      • 页面大小、变更部分的大小都有关系
      • 创建页面还是更新页面也有关系
  • 运行行时和编译时相关知识

    • 纯运行时支持的框架的特点

      • 没有编译的过程
      • 没办法分析用户提供的内容
    • 纯编译时支持的框架的特点

      • 可以分析用户提供的内容
      • 不需要任何运行时,直接编译成可执行的JavaScript代码,因此性能可能更好
      • 缺点:有损灵活性,即用户提供的内容必须编译后才能用
      • Svelte是纯编译时的框架
    • 运行时+编译时框架的特点

      • Vue.js3仍然保持 运行时+编译时的架构
      • 在保持灵活性的基础上能够尽可能地去优化
  • Vue.js3是一个 编译时+运行时的框架

    • 它在保持灵活性能的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。