Vue3 是如何通过编译优化提升框架性能的?

我心飞翔 分类:vue

Vue3 通过编译优化,极大的提升了它的性能。本文将深入探讨 Vue3 的编译优化的细节,了解它是如何提升框架性能的。

编译优化

编译优化指的是:编译器将模板编译为渲染函数的过程中,尽可能多地提取关键信息,用于指导生成最优代码的过程

编译优化的策略和具体实现,是由框架的设计思路所决定的,不同框架有不同思路,因此优化策略也是不同的

但优化方向基本一致,尽可能的区分动态内容和静态内容,针对不同的内容,采用不同的优化策略。

优化策略

Vue 作为组件级的数据驱动框架,当数据变化时,Vue 只能知道具体的某个组件发生了变化,但不知道具体是哪个元素需要更新。因此还需要对比新旧两棵 VNode 树,一层层地遍历,找出变化的部分,并进行更新。

但其实使用模板描述的 UI,结构是非常稳定的,例如以下代码:

<template>
  <div class="container">
      <h1>hello</h1>
      <h2>{{ msg }}</h2>
  </div>
</template>

在这段代码中,唯一会发生变化的,就只有 h2 元素,且只会是内容发生变化,它的 attr 也是不会变化的。

如果对比新旧两颗 VNode 树,会有以下步骤:

  1. 比对 div
  2. 比对 div 的 children,使用 Diff 算法,找出 key 相同的元素,并一一进行比对
    1. 比对 h1 元素
    2. 比对 h2 元素

在对比完之后,发现 h2 元素的文本内容改变了,然后 Vue 会对 h2 的文本内容进行更新操作。

但实际上,只有 h2 元素会改变,我们如果可以只比对 h2 元素,然后找到它变化的内容,进行更新。

更进一步,其实 h2 只有文本会改变,只比对 h2 元素的文本内容,然后进行更新,这样就可以极大提升性能。

标记元素变化的部分

为了对每个动态元素的变化内容进行记录,需要引入 patchFlag 的概念

patchFlag

patchFlag 用于标记一个元素中动态的内容,它是 VNode 中的一个属性。

还是这个例子:

<template>
  <div>
      <h1>hello</h1>
      <h2>{{ msg }}</h2>
  </div>
</template>

加入 patchFlag 后的 h2 VNode 为:

{ type: 'h2', children: ctx.msg, patchFlag: 1 }

patchFlag 为 1,代表这个元素的 Text 部分,会发生变化。

注意:patchFlag 是一个 number 类型值,记录当前元素的变化的部分

PatchFlag 是 Typescript 的 Enum 枚举类型

下面是 PatchFlag 的部分枚举定义

export const enum PatchFlags {
  // 代表元素的 text 会变化
  TEXT = 1,
  // 代表元素的 class 会变化
  CLASS = 1 << 1,
  // 代表元素的 style 会变化
  STYLE = 1 << 2,
  // 代表元素的 props 会变化
  PROPS = 1 << 3,
  // ...
}

patchFlag === PatchFlags.TEXT,即 patchFlag === 1 时,代表元素的 Text 会变化。

patchFlag 使用二进制进行存储,每一位存储一个信息。如果 PatchFlag 第一位为 1,就说明 Text 是动态的,如果第二位为 1,就说明 Class 是动态的。

如果一个元素既有 Text 变化,又有 Class 变化,patchFlag 就为 3

PatchFlag.TEXT | PatchFlagCLASS1 | 2 ,1 二进制是 01,2 的二进制是 10,按位或的结果为 11,即十进制的 3。

计算过程如下:

Vue3 是如何通过编译优化提升框架性能的?

有了这样的设计,我们可以根据每一位是否为 1,决定是否决定执行对应内容的更新

使用按位与 & 进行判断,具体过程如下:

Vue3 是如何通过编译优化提升框架性能的?
image-20230209201142552

伪代码如下:

function patchElement(n1, n2){
    if(n2.patchFlag > 0){
        // 有 PatchFlag,只需要更新动态部分
        if (patchFlag & PatchFlags.TEXT) {
            // 更新 class
        }
        
        if (patchFlag & PatchFlags.CLASS) {
            // 更新 class
        }
        
        if (patchFlag & PatchFlags.PROPS) {
            // 更新 class
        }
        
        ...
        
    } else {
        // 没有 PatchFlag,全量比对并更新
    }
}
  • 当元素有 patchFlag 时,就只更新 patchFlag 对应的部分即可。
  • 如果没有 patchFlag,则将新老 VNode 全量的属性进行比对,找出差异并更新

为了能生成 dynamicChildrenpatchFlag,就需要编译器的配合,在编译时分析出动态的元素和内容

如何生成 patchFlag

由于模板结构非常稳定,很容易判断出模板的元素是否为动态元素,且能够判断出元素哪些内容是动态的

还是这个例子:

<template>
  <div>
      <h1>hello</h1>
      <h2>{{ msg }}</h2>
  </div>
</template>

Vue 编译器会生成如下的代码(并非最终生成的代码):

import { ref, createVNode } from 'vue'

const __sfc__ = {
    __name: 'App',
    setup() {

        const msg = ref('Hello World!')

        // 在 setup 返回编译后渲染函数
        return () => {
            return createVNode("div", { class: "container" }, [
                createVNode("h1", null, "hello"),
                createVNode("h2", null, msg.value, 1 /* TEXT */)
            ])
        }
    }
}

createVNode 函数,其实就是 Vue 提供的渲染函数 h,只不过它比 h 多传了 patchFlag 参数

对于动态的元素,在创建 VNode 的时候,会多传一个 patchFlag 参数,这样生成的 VNode,也就有了 patchFlag 属性,就代表该 VNode 是动态的。

记录动态元素

从上一小节我们可以知道,有 patchFlag 的元素,就是动态的元素,那如何对它们进行收集和记录呢?

为了实现上述目的,我们需要引入 Block(块)的概念

Block

Block 是一种特殊的 VNode,它可以负责收集它内部的所有动态节点

Block 比普通的 VNode 多了 dynamicChildren 属性,用于存储内部所有动态子节点。

还是这个例子:

<template>
  <div>
      <h1>hello</h1>
      <h2>{{ msg }}</h2>
  </div>
</template>

h1 的 VNode 为:

const h1 = { type: 'h1', children: 'hello' }

h2 的 VNode 为:

const h2 = { type: 'h2', children: ctx.msg, patchFlag: 1 }

div 的 VNode 为:

const vnode = {
    type: 'div',
    children: [
        h1,
        h2
    ],
    dynamicChildren: [
        h2	// 动态节点,会被存储在 dynamicChildren
    ],
}

这里的 div 就是 Block,实际上,Vue 会把组件内的第一个元素作为 Block

Block 更新

动态节点的 VNode,会被按顺序存储 Block 的 dynamicChildren

  • 存储在 dynamicChildren,是为了可以只对这些元素进行比对,跳过其他静态元素
  • dynamicChildren 只存储在 Block,不需要所有 VNode 都有 dynamicChildren,因为仅仅通过 Block dynamicChildren 就能找到其内部中所有的动态元素
  • 按顺序,即旧 VNode 的 dynamicChildren 和 新 VNode 的 dynamicChildren元素是一一对应的,这样的设计就不需要使用 Diff 算法,从新旧 VNode 这两个 children 数组中,找到对应(key 相同)的元素

那我们更新组件内元素的算法,可以是这样的:

// 传入两个元素的旧 VNode:n1 和新 VNode n2,
// patch 是打补丁的意思,即对它们进行比较并更新
function patchElement(n1, n2){
    if (n2.dynamicChildren) {
        // 优化的路径
        // 直接比对 dynamicChildren 就行
        patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren)
    } else {
        // 全量比对
        patchChildren(n1, n2)
    }
}

patchBlockChildren 的大概实现如下:

// 对比新旧 children(是一个 VNode 的数组),并进行更新
function patchBlockChildren(oldDynamicChildren, oldDynamicChildren){
    // 按顺序一一比对即可
    for (let i = 0; i < dynamicChildren.length; i++) {
        const oldVNode = oldDynamicChildren[i]
        const newVNode = dynamicChildren[i]
        // patch 传入新旧 VNode,然后进行比对更新
        patch(oldVNode, newVNode)
    }
}

直接按顺序比较 dynamicChildren,好像很厉害,但这样真的没问题吗?

其实是有问题的,但是能解决。

dynamicChildren 能按顺序进行比较的前提条件,是要新旧 VNode 中, dynamicChildren 的元素必须能够一一对应。那会不会存在不一一对应的情况呢?

答案是会的。

例如 v-if ,我们稍微改一下前面的例子(在线体验地址):

<template>
  <div>
      <h1 v-if="!msg">hello</h1>
      <p v-else>
          <h2 >{{ msg }}</h2>
      </p>
  </div>
</template>

假如 msg 从 undefined 变成了 helloWorld

按我们上一小节所受的,旧的 VNode 的 dynamicChildren 为空(没有动态节点),新的 dynamicChildren 则是为h2

这种情况, v-if/v-else 让模板结构变得不稳定导致 dynamicChildren 不能一一对应。那要怎么办呢?

解决办法也很简单,v-if/v-else 的元素也作为 Block,这样就会得到一颗 Block 树。

Vue3 是如何通过编译优化提升框架性能的?
image-20230213235012149

Block 会作为动态节点,被 dynamicChildren 收集

例如:当 msg 为 undefined,组件内元素的 VNode 如下:

const vnode = {
    type: 'div',
    key: 0,	// 这里新增了 key
    children: [
        h1
    ],
    dynamicChildren: [
        h1	// h1 是 Block(h1 v-if),会被存储在 dynamicChildren
    ],
}

msg 为不为空时,组件内元素的 VNode 如下:

const vnode = {
    type: 'div',
    key: 0,  // 这里新增了 key
    children: [
        h1
    ],
    dynamicChildren: [
        p	// p 是 Block(p v-else),会被存储在 dynamicChildren
    ],
}

对于 Block(div) 来说,它的 dynamicChildren 是稳定的,里面的元素仍然是一一对应,因此可以快速找到对应的 VNode。

v-if/v-else 创建子 Block 的时候,会为子 Block 生成不同 key。在该例子中, Block(h1 v-if)Block(p v-else) 是对应的一组 VNode/Block,它们的 key 不同,因此在更新这两个 Block 时,Vue 会将之前的卸载,然后重新创建元素。

这种解决方法,其核心思想为:将不稳定元素,限制在最小的范围,让外层 Block 变得稳定

这样做有以下好处:

  • 保证稳定外层 Block 能继续使用优化的更新策略,
  • 在不稳定的内层 Block 中实施降级策略,只进行全量更新比对。

同样的,v-for 也会引起模板不稳定的问题,解决思路,也是将 v-for 的内容单独作为一层 Block,以保证外部 dynamicChildren 的稳定性。

如何创建 Block

只需要把有 patchFlag 的元素收集到 dynamicChildren 数组中即可,但如何确定 VNode 收集到哪一个 Block 中呢?

还是这个例子:

<template>
  <div>
      <h1>hello</h1>
      <h2>{{ msg }}</h2>
  </div>
</template>

Vue 编译器会生成如下的代码(并非最终代码):

import { ref, createVNode, openBlock } from 'vue'

const __sfc__ = {
    __name: 'App',
    setup() {

        const msg = ref('Hello World!')

        // 在 setup 返回编译后渲染函数
        return () => {
            return (
                // 新增了 openBlock
                openBlock(),
                // createVNode 改为了 createBlock
                createBlock("div", { class: "container" }, [
                    createVNode("h1", null, "hello"),
                    createVNode("h2", null, msg.value, 1 /* TEXT */)
                ]))
        }
    }
}

与上一小节相比,有以下不同:

  • 新增了 openBlock
  • createVNode 改为了 createBlock

由于 Block 是一个范围,因此需要 openBlockcloseBlock 去划定范围,不过我们看不到 closeBlock ,是因为 closeBlock 直接在 createBlock 函数内被调用了。

处于 openBlockcloseBlock(或者 createBlock) 之间的元素,都会被收集到当前的 Block 中

我们来看一下 render 函数的执行顺序:

  1. openBlock初始化 currentDynamicChildren 数组
  2. createVNode,创建 h1 的 VNode
  3. createVNode,创建 h2 的 VNode,这个是动态元素,将 VNode push 到 currentDynamicChildren
  4. createBlock,创建 div 的 VNode,currentDynamicChildren 设置为 dynamicChildren
    • createBlock 中调用 closeBlock

值得注意的是,内层的 createVNode 是先执行, createBlock 是后执行的,因此能收集 openBlockcloseBlock 之间的动态元素 VNode

其中 openBlockcloseBlock 的实现如下:

// block 可能会嵌套,当发生嵌套时,用栈保存上一层收集的内容
// 然后 closeBlock 时恢复上一层的内容
const dynamicChildrenStack = []

// 用于存储当前范围中的动态元素的 VNode
let currentDynamicChildren = null

function openBlock(){
    currentDynamicChildren = []
    dynamicChildrenStack.push(currentDynamicChildren)
}

// 在 createBlock 中被调用
function closeBlock(){``
    currentDynamicChildren = dynamicChildrenStack.pop()
}

因为 Block 可以发生嵌套,因此要用栈存起来。openBlock 的时候初始化并推入栈,closeBlock 的时候恢复上一层的 dynamicChildren

createVnode 的代码大致如下:

function createVnode(tag, props, children, patchFlags){
    const key = props && props.key
    props && delete props.key
    
    const vnode = {
        tag,
        props,
        children,
        key,
        patchFlags
    }
    
    // 如果有 patchFlags,那就记录该动态元素的 Vnode
    if(patchFlags){
        currentDynamicChildren.push(vnode)
    }
    
    return vnode
}

createBlock 的代码大致如下:

function createBlock(tag, props, children){
    // block 本质也是一个 VNode
    const vnode = createVNode(tag, props, children)
   
    vnode.dynamicChildren = currentDynamicChildren
    
    closeBlock()
    // 当前 block 也会收集到上一层 block 的 dynamicChildren 中
    currentDynamicChildren.push(vnode)
    return vnode
}

其他编译优化手段

静态提升

仍然是这个例子(在线预览):

<template>
  <div>
      <h1>hello</h1>
      <h2>{{ msg }}</h2>
  </div>
</template>

实际上会编译成下图:

Vue3 是如何通过编译优化提升框架性能的?
image-20230214211912433

与我们前面小节不同的是,编译后的代码,会将静态元素的 createVNode 提升,这样每次更新组件的时候,就不会重新创建 VNode,因此每次拿到的 VNode 的引用相同,Vue 渲染器就会直接跳过其渲染

预字符串化

在线例子预览

<template>
  <div>
      <h1>hello</h1>
    <h1>hello</h1>
    <h1>hello</h1>
    <h1>hello</h1>
    <h1>hello</h1>
    <h1>hello</h1>
    <h1>hello</h1>
    <h1>hello</h1>
    <h1>hello</h1>
    <h1>hello</h1>
  </div>
</template>
Vue3 是如何通过编译优化提升框架性能的?
image-20230214213405027

如果模板中包含大量连续静态的标签节点,会将这些静态节点序列化为字符串,并生成一个 Static 的 VNode

这样的好处是:

  • 大块静态资源可以直接通过 innerHTML 设置,性能更佳
  • 减少创建大量的 VNode
  • 减少内存消耗

编译优化能用于 JSX 吗

目前 JSX 没有编译优化。

我在《浅谈前端框架原理》中谈到过:

  • 模板基于 HTML 语法进行扩展,其灵活性不高,但这也意味着容易分析
  • 而 JSX 是一种基于 ECMAScript 的语法糖,扩充的是 ECMAScript 的语法,但 ECMAScript 太灵活了,难以实现静态分析

例如:js 的对象可以复制、修改、导入导出等,用 js 变量存储的 jsx 内容,无法判断是否为静态内容,因为可能在不知道哪个地方就被修改了,无法做静态标记。

但也并不是完全没有办法,例如可以通过约束 JSX 的灵活性,使其能够被静态分析,例如 SolidJS。

总结

在本文中,我们首先讨论了编译优化的优化方向:尽可能的区分动态内容和静态内容

然后具体到 Vue 中,就是从模板语法中,分离出动态和静态的元素,并标记动态的元素,以及其动态的部分

当我们标记动态的内容后,Vue 就可以配合渲染器,快速找到并更新动态的内容,从而提升性能

接下来介绍如何实现这一目的,即【如何标记元素变化的部分】和【如何记录动态的元素

最后还稍微介绍一些其他的编译优化手段,以及解释了为什么 JSX 难以做编译优化。

如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)

Vue3 是如何通过编译优化提升框架性能的?

回复

我来回复
  • 暂无回复内容