跳到主要内容

Vue-Compiler

编译优化主要有:

  • diff算法优化
  • 静态提升
  • 事件监听缓存
  • SSR优化

Block Tree 和 PatchFlags

Block TreePatchFlags是vue3利用编译信息在Diff阶段进行的优化.

在tempalte中, 会把模板内容分为静态节点和动态节点. 比如这段模板中:

<div>
<p class="foo">bar</p>
</div>

这这就是一个静态vDom, 它在组件的更新阶段是不会发生变化的. 如果能在diff阶段跳过这部分的比较, 就能避免无效的vdom树的遍历和比对. 这就是早起的优化思路: 跳过静态内容, 只比较动态内容.

静态类型的枚举如下:

export const enum PatchFlags {
TEXT = 1,// 动态的文本节点
CLASS = 1 << 1, // 2 动态的 class
STYLE = 1 << 2, // 4 动态的 style
PROPS = 1 << 3, // 8 动态属性,不包括类名和样式
FULL_PROPS = 1 << 4, // 16 动态 key,当 key 变化时需要完整的 diff 算法做比较
HYDRATE_EVENTS = 1 << 5, // 32 表示带有事件监听器的节点
STABLE_FRAGMENT = 1 << 6, // 64 一个不会改变子节点顺序的 Fragment
KEYED_FRAGMENT = 1 << 7, // 128 带有 key 属性的 Fragment
UNKEYED_FRAGMENT = 1 << 8, // 256 子节点没有 key 的 Fragment
NEED_PATCH = 1 << 9, // 512
DYNAMIC_SLOTS = 1 << 10, // 动态 solt
HOISTED = -1, // 特殊标志是负整数表示永远不会用作 diff
BAIL = -2 // 一个特殊的标志,指代差异算法
}

Block

<div>
<p>foo</p>
<p>{{ bar }}</p>
</div>

在这段代码中, 只有<p>{{ bar }}</p>是动态的, 因此只需要靶向更新该文本节点就可以了, 这在包含大量静态节点内容而只有少量动态内容的场景下能提成很大的性能.

这段代码对应的vDom大概如下:

const vnode = {
tag: 'div',
children: [
{ tag: 'p', children: 'foo' },
{ tag: 'p', children: ctx.bar }, // 这是动态节点
]
}

vue3的compiler可以分析模板并且提取有效信息: 那些节点是动态节点, 以及它为什么是动态的, 有了这些信息, 我们就可以在创建vnode的过程中为动态的节点打标记, 也就是patchFlags.

patchFlags可以简单的理解为一个数字标记, 把这些数字赋予不同含义:

  • 1: 代表节点有动态的textContent
  • 2: 代表元素有动态的class
  • 3: ...

然后把这些标记挂到对应的vdom属性上:

const vnode = {
tag: 'div',
children: [
{ tag: 'p', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: 1 /* 动态的 textContent */ },
]
}

有了这个信息, 我们就可以在vnode的创建阶段把动态节点提取出来, 通过判断节点属性上带有patchFlag, 就把这个节点提取出来放在一个数组中:

const vnode = {
tag: 'div',
children: [
{ tag: 'p', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: 1 /* 动态的 textContent */ },
],
dynamicChildren: [
{ tag: 'p', children: ctx.bar, patchFlag: 1 /* 动态的 textContent */ },
]
}

dynamicChildren就是用来存储一个节点下所有子代动态节点的数组.

比如:

const vnode = {
tag: 'div',
children: [
{ tag: 'section', children: [
{ tag: 'p', children: ctx.bar, patchFlag: 1 /* 动态的 textContent */ },
]},
],
dynamicChildren: [
{ tag: 'p', children: ctx.bar, patchFlag: 1 /* 动态的 textContent */ },
]
}

它收集的是所有子孙节点中的动态节点信息. 这些节点信息会保存在'顶层'的Block中.

实际上, 一个Block就是一个VNode, 只不过它有一些特殊属性.

现在, 我们已经拿到了所有的动态节点数组, 因此在diff的时候就只需要遍历这个数组进行更新即可.

这就是所谓的靶向更新.

节点不稳定: Block-Tree

一个Block是无法构成Tree的, 这就意味着在一颗vdom树种, 会有多个vnode节点充当Block的角色, 进而构成一个Block Tree. 那么什么情况下, 一个vnode节点会充当block的角色呢?

<div>
<section v-if="foo">
<p>{{ a }}</p>
</section>
<div v-else>
<p>{{ a }}</p>
</div>
</div>

假设只要最外层的div标签是Block, 那么footrue的时候, 收集到的节点为:

cosnt block = {
tag: 'div',
dynamicChildren: [
{ tag: 'p', children: ctx.a, patchFlag: 1 }
]
}

foofalse的时候, block为:

cosnt block = {
tag: 'div',
dynamicChildren: [
{ tag: 'p', children: ctx.a, patchFlag: 1 }
]
}

可以发现, 无论foo为真还是假, block的内容是不变的, 这意味着在diff阶段不会进行任何的更新.

这个问题的本质在于dynamaicChildrendiff是忽略层级的.

v-if block

那么, 如果让使用了v-if/v-else-if/v-else等指令的元素也作为Block呢?

<div>
<section v-if="foo">
<p>{{ a }}</p>
</section>
<section v-else> <!-- 即使这里是 section -->
<div> <!-- 这个 div 标签在 diff 过程中被忽略 -->
<p>{{ a }}</p>
</div>
</section >
</div>

以这段模板为例, 将两个section作为block:

Block(Div)
- Block(Section v-if)
- Block(Section v-else)

父级的Block, 会收集子代动态节点之外, 也会收集子Block.

cosnt block = {
tag: 'div',
dynamicChildren: [
{ tag: 'section', { key: 0 }, dynamicChildren: [...]}, /* Block(Section v-if) */
{ tag: 'section', { key: 1 }, dynamicChildren: [...]} /* Block(Section v-else) */
]
}

这样当v-if判断变化的时候, 渲染器就会知道这是两个不同的block, 进行替换操作, 这样就解决了DOM结构不稳定引起的问题. 这就是Block Tree.

v-for block

同理, v-for也会引起DOM结构的不稳定, 但是它的情况就稍微复杂一些.

<div>
<p v-for="item in list">{{ item }}</p>
<i>{{ foo }}</i>
<i>{{ bar }}</i>
</div>

假如list[1, 2]变为了[1], 按照之前的思路, 最外层的div作为一个block, 那么它更新前后对应的Block Tree应该是:

// 前
const prevBlock = {
tag: 'div',
dynamicChildren: [
{ tag: 'p', children: 1, 1 /* TEXT */ },
{ tag: 'p', children: 2, 1 /* TEXT */ },
{ tag: 'i', children: ctx.foo, 1 /* TEXT */ },
{ tag: 'i', children: ctx.bar, 1 /* TEXT */ },
]
}

// 后
const nextBlock = {
tag: 'div',
dynamicChildren: [
{ tag: 'p', children: item, 1 /* TEXT */ },
{ tag: 'i', children: ctx.foo, 1 /* TEXT */ },
{ tag: 'i', children: ctx.bar, 1 /* TEXT */ },
]
}

其中, prevBlock有四个动态节点, nextBlock中有三个动态节点, 这时候如何进行Diff. 这里是无法使用传统的Diff操作的, 因为传统的Diff的前置条件是同层节点之间的Diff, 然dynamicChildren内的节点未必是同层级的.

实际上, 我们只需要让v-for作为一个单独的block就可以了:

const block = {
tag: 'div',
dynamicChildren: [
// 这是一个 Block 哦,它有 dynamicChildren
{ tag: Fragment, dynamicChildren: [/*.. v-for 的节点 ..*/] }
{ tag: 'i', children: ctx.foo, 1 /* TEXT */ },
{ tag: 'i', children: ctx.bar, 1 /* TEXT */ },
]
}

这里我们使用了一个Fragment, 并让他充当了Block的角色, 解决了v-for元素所在层级的结构稳定性问题.

不稳定的Fragment

我们来看下Fragment这个元素本身:

{ tag: Fragment, dynamicChildren: [/*.. v-for 的节点 ..*/] }

对于这样的模板:

<p v-for="item in list">{{ item }}</p>

list[1,2]变为[1]的前后, block如下:

// 前
const prevBlock = {
tag: Fragment,
dynamicChildren: [
{ tag: 'p', children: item, 1 /* TEXT */ },
{ tag: 'p', children: item, 2 /* TEXT */ }
]
}

// 后
const prevBlock = {
tag: Fragment,
dynamicChildren: [
{ tag: 'p', children: item, 1 /* TEXT */ }
]
}

在这种情况下, 我们发现结构仍然是不稳定的(结构不稳定从结果上看指的是更新前后一个 block 的 dynamicChildren 中收集的动态节点数量或顺序的不一致). 这种不稳定会导致我们无法直接进行靶向Diff, 所以只能退回到传统的Diff, 也就是Diff Fragment的children而不是dynamicChildren.

注意, Fragment的子节点还可以是Block.

const block = {
tag: Fragment,
children: [
{ tag: 'p', children: item, dynamicChildren: [/*...*/], 1 /* TEXT */ },
{ tag: 'p', children: item, dynamicChildren: [/*...*/], 1 /* TEXT */ }
]
}

这里就又要回复到Block-treeDiff模式.

稳定的 Fragment

所谓的稳定的Fragment, 指的是:

  1. v-for的表达式是常量
<p v-for="n in 10"></p>
<!-- 或者 -->
<p v-for="s in 'abc'"></p>

由于10abc是常量, 所以这里是稳定的, 不需要回退到传统的diff. 能带来一定的性能优势.

  1. 多个根元素
<template>
<div></div>
<p></p>
<i></i>
</template>

这也是一个稳定的Fragment.

即便是:

<template>
<div v-if="condition"></div>
<p></p>
<i></i>
</template>

它也是稳定的, 因为v-if本身是一个稳定的block.

  1. 插槽出口
<Comp>
<p v-if="ok"></p>
<i v-else></i>
</Comp>

组件内的children将作为插槽的内容, 在经过编译以后, 应该作为Block角色的内容, 自然会是Block, 可以保证结构的稳定.

  1. <template v-for>

如下模板:

<template>
<template v-for="item in list">
<p>{{ item.name }}</P>
<p>{{ item.age }}</P>
</template>
</template>

对于带有v-fortemplate元素本身来说, 它是一个不稳定的Fragment, 因为list不是常量, 除此之外, template元素本身不渲染任何真实的DOM, 因此如果它含有多个元素, 这些元素节点也会作为Fragment存在, 这个Fragment也是稳定的.

静态提升

vue3的compiler是支持hoistStatic的. 如下模板:

<div>
<p>text</p>
</div>

在没有被提升的情况下, 其渲染函数相当于:

function render() {
return (openBlock(), createBlock('div', null, [
createVNode('p', null, 'text')
]))
}

这里我们知道p标签是静态的, 它是不会改变的. 开启静态提升后, 渲染函数变为:

const hoist1 = createVNode('p', null, 'text')

function render() {
return (openBlock(), createBlock('div', null, [
hoist1
]))
}

这里减少的性能的消耗. 静态提升是以树为单位的. 如下模板:

<div>
<section>
<p>
<span>abc</span>
</p>
</section >
</div>

除了根节点的div作为block不可被提升, 整个的section元素以及子孙及诶点都会被提升, 因为他们整个树都是静态的.

元素不会被提升的情况

  1. 元素带有动态的key
<div :key="foo"></div>

实际上一个元素拥有任何动态绑定都不应该被提升, key是作为特殊的一种绑定, 其意义是不同的. 普通的props如果是动态的, 那么只需要体现在patchFlags即可:

<div>
<p :foo="bar"></p>
</div>

转为render:

render(ctx) {
return (openBlock(), createBlock('div', null, [
createVNode('p', { foo: ctx }, null, PatchFlags.PROPS, ['foo'])
]))
}

对于key来说, 本身具有特殊意义, 它作为VNode的唯一标识, 如果两个元素的key不同, 就需要完全的替换.

如果key的值是动态可变的, 对于这样的元素应该始终参与到diff中, 并且不能简单的打patchFlags, 需要把拥有动态key的元素也作为Block:

<div>
<div :key="foo"></div>
</div>

其渲染函数应该为:

render(ctx) {
return (openBlock(), createBlock('div', null, [
(openBlock(), createBlock('div', { key: ctx.foo }))
]))
}
  1. 使用ref的元素
<div ref="domRef"></div>

如果一个元素使用的ref, 无论是否绑定了动态值, 这个元素都不会被静态提升, 这是因为在每一次patch的时候都需要设置ref的值.

为什么呢? 看下面这个场景:

<template>
<div>
<p ref="domRef"></p>
</div>
</template>
<script>
export default {
setup() {
const refP1 = ref(null)
const refP2 = ref(null)
const useP1 = ref(true)

return {
domRef: useP1 ? refP1 : refP2
}
}
}
</script>

如上代码所示, p标签使用了非动态的ref属性, 值为字符串domRef, 然setup返回了同名的domRef属性, 他们之间会建立联系:

  • 当userp1为true, refp1.value引用p元素
  • 反之, refp2.value引用p元素

虽然ref是静态的, 但很显然在更新的过程中由于useP1的变化, 我们不得不更新domRef, 所以只要一个元素使用了ref, 它就不会被静态提升, 并且这个元素对应的VNode也会被收集到父Block的dynamicChildren中.

但由于p标签除了需要更新ref外, 并不需要更新其他的props, 所以在真实的渲染函数中, 会为它打上一个特殊的PatchFlag, 叫做: PatchFlags.NEED_PATCH.

render() {
return (openBlock(), createBlock('div', null, [
createVNode('p', { ref: 'domRef' }, null, PatchFlags.NEED_PATCH)
]))
}
  1. 使用自定义指令的元素

实际上一个元素如果使用除v-pre/v-cloak之外的所有Vue原生提供的指令, 都不会被提升, 使用自定义指令也不会被提升, 比如:

<p v-custom></p>

和使用key一样, 会为这段模板对应的VNode打上NEED_PATCH标志.

顺便讲一下手写渲染函数时如何应用自定义指令, 自定义指令是一种运行时指令, 与组件的生命周期类似, 一个VNode对象也有它自己生命周期:

  • beforeMount
  • mounted
  • beforeUpdate
  • updated
  • beforeUnmount
  • unmounted

编写一个自定义指令:

const myDir: Directive = {
beforeMount(el, binds) {
console.log(el)
console.log(binds.value)
console.log(binds.oldValue)
console.log(binds.arg)
console.log(binds.modifiers)
console.log(binds.instance)
}
}

使用该指令:

const App = {
setup() {
return () => {
return h('div', [
// 调用 withDirectives 函数
withDirectives(h('h1', 'hahah'), [
// 四个参数分别是:指令、值、参数、修饰符
[myDir, 10, 'arg', { foo: true }]
])
])
}
}
}

一个元素可以绑定多个指令:

const App = {
setup() {
return () => {
return h('div', [
// 调用 withDirectives 函数
withDirectives(h('h1', 'hahah'), [
// 四个参数分别是:指令、值、参数、修饰符
[myDir, 10, 'arg', { foo: true }],
[myDir2, 10, 'arg', { foo: true }],
[myDir3, 10, 'arg', { foo: true }]
])
])
}
}
}

静态提升Props

静态节点的提升以树为单位, 如果一个VNode存在非静态的子代节点, 那么该VNode就不是静态的, 也就不会被提升. 但这个Vnodeprops却可能是静态的, 这使得我们可以将它的props进行提升, 这同样可以节约VNode对象的创建开销, 内存占用等. 例如:

<div>
<p foo="bar" a=b>{{ text }}</p>
</div>

在这段模板中, p标签有动态的文本内容, 因此不可以被提升, 但p标签的所有属性都是静态的, 因此可以提升它的属性, 经过提升以后其渲染函数如下:

const hoistProp = { foo: 'bar', a: 'b' }

render(ctx) {
return (openBlock(), createBlock('div', null, [
createVNode('p', hoistProp, ctx.text)
]))
}

即使动态绑定的属性值, 但如果值是常量, 那么也会被提升.

<p :foo="10" :bar="'abc' + 'def'">{{ text }}</p>

这里的abc+def就是常量, 也可以被提升.

预字符串化

静态提升的VNode节点或者节点数本身是静态的, 那么能够将其预先字符串化呢? 如下模板所示:

<div>
<p></p>
<p></p>
...20 个 p 标签
<p></p>
</div>

假如标签中有大量连续的静态的p标签, 当采用了hoist优化之后, 结果如下:

cosnt hoist1 = createVNode('p', null, null, PatchFlags.HOISTED)
cosnt hoist2 = createVNode('p', null, null, PatchFlags.HOISTED)
// ... 20 个 hoistx 变量
cosnt hoist20 = createVNode('p', null, null, PatchFlags.HOISTED)

render() {
return (openBlock(), createBlock('div', null, [
hoist1, hoist2, ...20 个变量, hoist20
]))
}

预字符串化会将这些静态节点序列化为字符串并生成一个Static类型的VNode:

const hoistStatic = createStaticVNode('<p></p><p></p><p></p>...20个...<p></p>')

render() {
return (openBlock(), createBlock('div', null, [
hoistStatic
]))
}

这里的优势在于:

  • 生成代码的体积减小了
  • 减少了创建VNode的开销
  • 减少了内存的占用

静态节点在运行时会通过innerHTML来创建真实节点, 因此并非所有静态节点都是可以预字符串化的.

可以预字符串化的静态节点需要满足以下条件:

  • 非表格类标签: caption, thread, tr, th, tbody, td, tfoot, colgroup, col
  • 标签的属性必须是: 标准的html attribute,或者data-/aria-类属性

当一个节点满足这些条件的时候, 说明这个节点是可以预字符串化的, 但如果只有一个节点, 那么也不会将其字符串化, 可字符串化的节点必须连续并且达到一定的数量才行:

  • 如果节点没有属性, 那么必须有连续20个及以上的静态节点存在才行
<div>
<p></p>
<p></p>
... 20 个 p 标签
<p></p>
</div>
  • 或者在这些连续的节点中有5个及以上的节点是有属性绑定的节点:
<div>
<p id="a"></p>
<p id="b"></p>
<p id="c"></p>
<p id="d"></p>
<p id="e"></p>
</div>

这些节点不一定要兄弟节点, 父子节点也可以:

<div>
<p id="a">
<p id="b">
<p id="c">
<p id="d">
<p id="e"></p>
</p>
</p>
</p>
</p>
</div>

预字符串化会在编译的时候计算属性的值, 比如:

<div>
<p :id="'id-' + 1">
<p :id="'id-' + 2">
<p :id="'id-' + 3">
<p :id="'id-' + 4">
<p :id="'id-' + 5"></p>
</p>
</p>
</p>
</p>
</div>

字符串化之后:

const hoistStatic = createStaticVNode('<p id="id-1"></p><p id="id-2"></p>.....<p id="id-5"></p>'

Cache Event handler 事件监听缓存

如下模板:

<Comp @change="a + b" />

这段模板如果手写渲染函数的话相当于:

render(ctx) {
return h(Comp, {
onChange: () => (ctx.a + ctx.b)
})
}

很显然, 每次render函数执行的时候, Compprops都是新的对象, onChange也是新的函数, 这就会导致Comp组件的更新.

Vue3 Compiler开启prefixIdentifiers以及cacheHandlers的时候, 这段模板会被编译为:

render(ctx, cache) {
return h(Comp, {
onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
})
}

这样即使多次调用渲染函数也不会触发Comp组件的更新, 因为在Vuepatch阶段对比的时候, propsonChange的引用是没有变化的.

如上代码中的render函数的cache对象是Vue内部在调用渲染函数式注入的一个数组, 想下面这种:

render.call(ctx, ctx, [])

当然我们不依赖编译也能写出类似具备cache能力的代码:

const Comp = {
setup() {
// 在 setup 中定义 handler
const handleChange = () => {/* ... */}
return () => {
return h(AnthorComp, {
onChange: handleChange // 引用不变
})
}
}
}

v-once

这在vue2中也有支持, 这是一个很"指令"的指令, 因为它就是给编译器看的, 当编译器遇到v-once的时候, 会利用我们刚刚讲过的cache来缓存全部或者一部分渲染函数的执行结果, 比如下面这个模板:

<div>
<div v-once>{{ foo }}</div>
</div>

会被编译为:

render(ctx, cache) {
return (openBlock(), createBlock('div', null, [
cache[1] || (cache[1] = h("div", null, ctx.foo, 1 /* TEXT */))
]))
}

这样就缓存了这段vnode, 既然vnode已经被缓存了, 后续的更新就都会读取缓存的内容, 而不会重新创建vnode对象了, 同时在Diff的过程中也就不需要这段vnode参与了. 通常看到编译后的代码更接近如下内容:

render(ctx, cache) {
return (openBlock(), createBlock('div', null, [
cache[1] || (
setBlockTracking(-1), // 阻止这段 VNode 被 Block 收集
cache[1] = h("div", null, ctx.foo, 1 /* TEXT */),
setBlockTracking(1), // 恢复
cache[1] // 整个表达式的值
)
]))
}

openBlock()createBlock()函数用来创建一个 Block。而 setBlockTracking(-1) 则用来暂停收集的动作.

所以在v-once编译生成代码中你会看到它, 这样使用v-once包裹的内容就不会被收集到父Block中, 也就不参与Diff了.

所以, v-once的性能提升来自两个方面:

  1. VNode的创建开销
  2. 无用的Diff开销.

SSR优化

当静态内容达到一定量级的时候, 会用createStaticVnode方法在客户端去生成一个static node, 这些静态的node, 会被直接innerHtml, 就不需要创建对象, 然后根据对象渲染.

<div>
<div>
<span>你好</span>
</div>
... // 很多个静态属性
<div>
<span>{{ message }}</span>
</div>
</div>

编译后:

import { mergeProps as _mergeProps } from "vue"
import { ssrRenderAttrs as _ssrRenderAttrs, ssrInterpolate as _ssrInterpolate } from "@vue/server-renderer"

export function ssrRender(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
const _cssVars = { style: { color: _ctx.color }}
_push(`<div${
_ssrRenderAttrs(_mergeProps(_attrs, _cssVars))
}><div><span>你好</span>...<div><span>你好</span><div><span>${
_ssrInterpolate(_ctx.message)
}</span></div></div>`)
}