Vue3 通过编译优化,极大的提升了它的性能。本文将深入探讨 Vue3 的编译优化的细节,了解它是如何提升框架性能的。
编译优化
编译优化指的是:编译器将模板编译为渲染函数的过程中,尽可能多地提取关键信息,用于指导生成最优代码的过程
编译优化的策略和具体实现,是由框架的设计思路所决定的,不同框架有不同思路,因此优化策略也是不同的
但优化方向基本一致,尽可能的区分动态内容和静态内容,针对不同的内容,采用不同的优化策略。
优化策略
Vue 作为组件级的数据驱动框架,当数据变化时,Vue 只能知道具体的某个组件发生了变化,但不知道具体是哪个元素需要更新。因此还需要对比新旧两棵 VNode 树,一层层地遍历,找出变化的部分,并进行更新。
但其实使用模板描述的 UI,结构是非常稳定的,例如以下代码:
html
复制代码
<template> <div class="container"> <h1>helloh1> <h2>{{ msg }}h2> div> template>
在这段代码中,唯一会发生变化的,就只有 h2
元素,且只会是内容发生变化,它的 attr
也是不会变化的。
如果对比新旧两颗 VNode 树,会有以下步骤:
- 比对
div
- 比对
div
的 children,使用 Diff 算法,找出 key 相同的元素,并一一进行比对
- 比对
h1
元素 - 比对
h2
元素
在对比完之后,发现 h2
元素的文本内容改变了,然后 Vue 会对 h2
的文本内容进行更新操作。
但实际上,只有 h2
元素会改变,我们如果可以只比对 h2
元素,然后找到它变化的内容,进行更新。
更进一步,其实 h2
只有文本会改变,只比对 h2
元素的文本内容,然后进行更新,这样就可以极大提升性能。
标记元素变化的部分
为了对每个动态元素的变化内容进行记录,需要引入 patchFlag
的概念
patchFlag
patchFlag
用于标记一个元素中动态的内容,它是 VNode 中的一个属性。
还是这个例子:
<template> <div> <h1>helloh1> <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 | PatchFlagCLASS
,1 | 2
,1 二进制是 01
,2 的二进制是 10
,按位或的结果为 11
,即十进制的 3。
计算过程如下:
有了这样的设计,我们可以根据每一位是否为 1,决定是否决定执行对应内容的更新
使用按位与 & 进行判断,具体过程如下:
伪代码如下:
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 全量的属性进行比对,找出差异并更新
为了能生成 dynamicChildren
和 patchFlag
,就需要编译器的配合,在编译时分析出动态的元素和内容
如何生成 patchFlag
由于模板结构非常稳定,很容易判断出模板的元素是否为动态元素,且能够判断出元素哪些内容是动态的
还是这个例子:
html
复制代码
<template> <div> <h1>helloh1> <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>helloh1> <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
,因为仅仅通过 BlockdynamicChildren
就能找到其内部中所有的动态元素- 按顺序,即旧 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 的数组),并进行更新dynamicChildren function patchBlockChildren(oldDynamicChildren, dynamicChildren){ // 按顺序一一比对即可 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">helloh1> <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 树。
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
是一个范围,因此需要 openBlock
和 closeBlock
去划定范围,不过我们看不到 closeBlock
,是因为 closeBlock
在 createBlock
函数内被调用了。
处于 openBlock
和 closeBlock
(或者 createBlock
) 之间的元素,都会被收集到当前的 Block 中。
我们来看一下 render
函数的执行顺序:
openBlock
,初始化currentDynamicChildren
数组createVNode
,创建h1
的 VNodecreateVNode
,创建h2
的 VNode,这个是动态元素,将 VNode push 到currentDynamicChildren
createBlock
,创建div
的 VNode,将currentDynamicChildren
设置为dynamicChildren
- 在
createBlock
中调用closeBlock
值得注意的是,内层的
createVNode
是先执行,createBlock
是后执行的,因此能收集openBlock
和closeBlock
之间的动态元素 VNode
其中 openBlock
和 closeBlock
的实现如下:
// 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>
实际上会编译成下图:
与我们前面小节不同的是,编译后的代码,会将静态元素的 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>
如果模板中包含大量连续静态的标签节点,会将这些静态节点序列化为字符串,并生成一个 Static 的 VNode
这样的好处是:
- 大块静态资源可以直接通过 innerHTML 设置,性能更佳
- 减少创建大量的 VNode
- 减少内存消耗
编译优化能用于 JSX 吗
目前 JSX 没有编译优化。
我在《浅谈前端框架原理》中谈到过:
- 模板基于 HTML 语法进行扩展,其灵活性不高,但这也意味着容易分析
- 而 JSX 是一种基于 ECMAScript 的语法糖,扩充的是 ECMAScript 的语法,但 ECMAScript 太灵活了,难以实现静态分析。
例如:js 的对象可以复制、修改、导入导出等,用 js 变量存储的 jsx 内容,无法判断是否为静态内容,因为可能在不知道哪个地方就被修改了,无法做静态标记。
但也并不是完全没有办法,例如可以通过约束 JSX 的灵活性,使其能够被静态分析,例如 SolidJS。
总结
在本文中,我们首先讨论了编译优化的优化方向:尽可能的区分动态内容和静态内容
然后具体到 Vue 中,就是从模板语法中,分离出动态和静态的元素,并标记动态的元素,以及其动态的部分
当我们标记动态的内容后,Vue 就可以配合渲染器,快速找到并更新动态的内容,从而提升性能
接下来介绍如何实现这一目的,即【如何标记元素变化的部分】和【如何记录动态的元素】
最后还稍微介绍一些其他的编译优化手段,以及解释了为什么 JSX 难以做编译优化。
如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)