阅读 3404

Vue3 Compiler 优化细节,如何手写高性能渲染函数

欢迎关注我的个人微信公众号「HcySunYang」。Let's coding for fun together!

Vue3Compilerruntime 紧密合作,充分利用编译时信息,使得性能得到了极大的提升。本文的目的告诉你 Vue3Compiler 到底做了哪些优化,以及一些你可能希望知道的优化细节,在这个基础上我们试着总结出一套手写优化模式的高性能渲染函数的方法,这些知识也可以用于实现一个 Vue3jsx babel 插件中,让 jsx 也能享受优化模式的运行时收益,这里需要澄清的是,即使在非优化模式下,理论上 Vue3Diff 性能也是要优于 Vue2 的。另外本文不包括 SSR 相关优化,希望在下篇文章总结。

篇幅较大,花费了很大的精力整理,对于对 Vue3 还没有太多了解的同学阅读起来也许会吃力,不妨先收藏,以后也许会用得到。

TOC

  • Block Tree 和 PatchFlags

    • 传统 Diff 算法的问题
    • Block 配合 PatchFlags 做到靶向更新
    • 节点不稳定 - Block Tree
    • v-if 的元素作为 Block
    • v-for 的元素作为 Block
    • 不稳定的 Fragment
    • 稳定的 Fragment
      • v-for 的表达式是常量
      • 多个根元素
      • 插槽出口
      • <template v-for>
  • 静态提升

    • 提升静态节点树
    • 元素不会被提升的情况
    • 元素带有动态的 key 绑定
    • 使用 ref 的元素
    • 使用自定义指令的元素
    • 提升静态 PROPS
  • 预字符串化

  • Cache Event handler

  • v-once

  • 手写高性能渲染函数

    • 几个需要记住的小点
    • Block Tree 是灵活的
    • 正确地使用 PatchFlags
    • NEED_PATCH
    • 该使用 Block 的地方必须用
      • 分支判断使用 Block
      • 列表使用 Block
      • 使用动态 key 的元素应该是 Block
    • 使用 Slot hint
    • 为组件正确地使用 DYNAMIC_SLOTS
    • 使用 $stable hint

Block Tree 和 PatchFlags

Block TreePatchFlagsVue3 充分利用编译信息并在 Diff 阶段所做的优化。尤大已经不止一次在公开场合聊过思路,我们深入细节的目的是为了更好的理解,并试图手写出高性能的 VNode

传统 Diff 算法的问题

“传统 vdom” 的 Diff 算法总归要按照 vdom 树的层级结构一层一层的遍历(如果你对各种传统 diff 算法不了解,可以看我之前写《渲染器》这套文章,里面总结了三种传统 Diff 方式),举个例子如下模板所示:

<div>
    <p>bar</p>
</div>
复制代码

对于传统 diff 算法来说,它在 diff 这段 vnode(模板编译后的 vnode) 时会经历:

  • Div 标签的属性 + children

  • 标签的属性 (class) + children

  • 文本节点:bar

但是很明显,这明明就是一段静态 vdom,它在组件更新阶段是不可能发生变化的。如果能在 diff 阶段跳过静态内容,那就会避免无用的 vdom 树的遍历和比对,这应该就是最早的优化思路来源 ---- 跳过静态内容,只对比动态内容

Block 配合 PatchFlags 做到靶向更新

咱们先说 Block 再聊 Block Tree。现在思路有了,我们只希望对比非静态的内容,例如:

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

复制代码

在这段模板中,只有 <p>{{ bar }}</p> 中的文本节点是动态的,因此只需要靶向更新该文本节点即可,这在包含大量静态内容而只有少量动态内容的场景下,性能优势尤其明显。可问题是怎么做呢?我们需要拿到整颗 vdom 树中动态节点的能力,其实可能没有大家想像的复杂,来看下这段模板对应的传统 vdom 树大概长什么样:

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

在传统的 vdom 树中,我们在运行时得不到任何有用信息,但是 Vue3compiler 能够分析模板并提取有用信息,最终体现在 vdom 树上。例如它能够清楚的知道:哪些节点是动态节点,以及为什么它是动态的 (是绑定了动态的 class?还是绑定了动态的 style?亦或是其它动态的属性?),总之编译器能够提取我们想要的信息,有了这些信息我们就可以在创建 vnode 的过程中为动态的节点打上标记:也就是传说中的 PatchFlags

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

  • 数字 1:代表节点有动态的 textContent(例如上面模板中的 p 标签)
  • 数字 2:代表元素有动态的 class 绑定
  • 数字 3:代表 xxxxx

总之我们可以预设这些含义,最后体现在 vnode 上:

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 */ },
    ]
}
复制代码

如上 vnode 所示,div 节点不仅能收集直接动态子节点,它还能收集所有子代节点中的动态节点。为什么 div 节点这么厉害呢?因为它拥有一个特殊的角色:Block,没错这个 div 节点就是传说中的 Block一个 Block 其实就是一个 VNode,只不过它有特殊的属性 (其中之一就是 dynamicChildren)。

现在我们已经拿到了所有的动态节点,它们存储在 dynamicChildren 中,因此在 diff 过程中就可以避免按照 vdom 树一层一层的遍历,而是直接找到 dynamicChildren 进行更新。除了跳过无用的层级遍历之外,由于我们早早的就为 vnode 打上了 patchFlag,因此在更新 dynamicChildren 中的节点时,可以准确的知道需要为该节点应用哪些更新动作,这基本上就实现了靶向更新。

节点不稳定 - Block Tree

一个 Block 怎么也构不成 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 角色,那么当 foo 为真时,block 收集到的动态节点为:

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


复制代码

foo 为假时,block 的内容如下:

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


复制代码

可以发现无论 foo 为真还是假,block 的内容是不变的,这就意味什么在 diff 阶段不会做任何更新,但是我们也看到了:v-if 的是一个 <section> 标签,v-else 的是一个 <div> 标签,所以这里就出问题了。实际上问题的本质在于 dynamicChildrendiff 是忽略 vdom 树层级的,如下模板也有同样的问题:

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

复制代码

即使 v-else 的也是一个 <section> 标签,但由于前后 DOM 树的不稳定,也会导致问题。这时我们就思考,如何让 DOM 树的结构变稳定呢?

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 tree

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

复制代码

父级 Block 除了会收集子代动态节点之外,也会收集子 Block,因此两个 Block(section) 将作为 Block(div)dynamicChildren

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 条件为真时,dynamicChildren 中包含的是 Block(section v-if),当条件为假时 dynamicChildren 中包含的是 Block(section v-else),在 Diff 过程中,渲染器知道这是两个不同的 Block,因此会做完全的替换,这样就解决了 DOM 结构不稳定引起的问题。而这就是 Block Tree

v-for 的元素作为 Block

不仅 v-if 会让 DOM 结构不稳定,v-for 也会,但是 v-for 的情况稍微复杂一些。思考如下模板:

<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 */ },
    ]
}


复制代码

prevBlcok 中有四个动态节点,nextBlock 中有三个动态节点。这时候要如何进行 Diff?有的同学可能会说拿 dynamicChildren 进行传统 Diff,这是不对的,因为传统 Diff 的一个前置条件是同层级节点间的 Diff,但是 dynamicChildren 内的节点未必是同层级的,这一点我们之前就提到过。

实际上我们只需要让 v-for 的元素也作为一个 Block 就可以了。这样无论 v-for 怎么变化,它始终都是一个 Block,这保证了结构稳定,无论 v-for 怎么变化,这颗 Block Tree 看上去都是:

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

刚刚我们使用一个 Fragment 并让它充当 Block 的角色解决了 v-for 元素所在层级的结构稳定,但我们来看一下这个 Fragment 本身:

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

对于如下这样的模板:

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

复制代码

在 list 由 ​[1, 2]​ 变成 ​[1]​ 的前后,Fragment 这个 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 */ }
    ]
}
复制代码

我们发现,Fragment 这个 Block 仍然面临结构不稳定的情况,所谓结构不稳定从结果上看指的是更新前后一个 blockdynamicChildren 中收集的动态节点数量或顺序的不一致。这种不一致会导致我们没有办法直接进行靶向 Diff,怎么办呢?其实对于这种情况是没有办法的,我们只能抛弃 dynamicChildrenDiff,并回退到传统 Diff:即 Diff Fragmentchildren 而非 dynamicChildren

但需要注意的是 Fragment 的子节点 (children) 仍然可以是 Block

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


复制代码

这样,对于 <p> 标签及其子代节点的 Diff 将恢复 Block TreeDiff 模式。

稳定的 Fragment

既然有不稳定的 Fragment,那就有稳定的 Fragment,什么样的 Fragment 是稳定的呢?

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

复制代码

由于 ​10​ 和 ​'abc'​ 是常量,所有这两个 Fragment 是不会变化的,因此它是稳定的,对于稳定的 Fragment 是不需要回退到传统 Diff 的,这在性能上会有一定的优势。

  • 多个根元素

Vue3 不再限制组件的模板必须有一个根节点,对于多个根节点的模板,例如:

<template>
    <div></div>
    <p></p>
    <i></i>
</template>

复制代码

如上,这也是一个稳定的 Fragment,有的同学或许会想如下模板也是稳定的 Fragment 吗:

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

复制代码

这其实也是稳定的,因为带有 v-if 指令的元素本身作为 Block 存在,所以这段模板的 Block Tree 结构总是:

Block(Fragment)
    - Block(div v-if)
    - VNode(p)
    - VNode(i)

复制代码

对应到 VNode 应该类似于:

const block = {
    tag: Fragment,
    dynamicChildren: [
        { tag: 'div', dynamicChildren: [...] },
        { tag: 'p' },
        { tag: 'i' },
    ],
    PatchFlags.STABLE_FRAGMENT
}


复制代码

无论如何,它的结构都是稳定的。需要注意的是这里的 ​PatchFlags.STABLE_FRAGMENT​,该标志必须存在,否则会回退传统 ​Diff​ 模式。

  • 插槽出口

如下模板所示:

<Comp>
    <p v-if="ok"></p>
    <i v-else></i>
</Comp>

复制代码

组件 <Comp> 内的 children 将作为插槽内容,在经过编译后,应该作为 Block 角色的内容自然会是 Block,已经能够保证结构的稳定了,例如如上代码相当于:

render(ctx) {
    return createVNode(Comp, null, {
        default: () => ([
            ctx.ok
                // 这里已经是 Block 了
                ? (openBlock(), createBlock('p', { key: 0 }))
                : (openBlock(), createBlock('i', { key: 1 }))
        ]),
        _: 1 // 注意这里哦
    })
}


复制代码

既然结构已经稳定了,那么在渲染出口处 Comp.vue

<template>
    <slot/>
</template>

复制代码

相当于:

render() {
    return (openBlock(), createBlock(Fragment, null,
        this.$slots.default() || []
    ), PatchFlags.STABLE_FRAGMENT)
}


复制代码

这自然就是 STABLE_FRAGMENT,大家注意前面代码中 _: 1 这是一个编译的 slot hint,当我们手写优化模式的渲染函数时必须要使用这个标志才能让 runtime 知道 slot 是稳定的,否则会退出非优化模式。另外还有一个 $stable hint,在下篇文章会讲解。

<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 是稳定的,因为它不会随着 list 的变化而变化。

以上内容差不多就是 Block Tree 配合 PatchFlags 是如何做到靶向更新以及一些具体的思路细节了。

静态提升

提升静态节点树

Vue3Compiler 如果开启了 hoistStatic 选项则会提升静态节点,或静态的属性,这可以减少创建 VNode 的消耗,如下模板所示:

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

复制代码

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

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


复制代码

很明显,p 标签是静态的,它不会改变。但是如上渲染函数的问题也很明显,如果组件内存在动态的内容,当渲染函数重新执行时,即使 p 标签是静态的,那么它对应的 VNode 也会重新创建。当开启静态提升后,其渲染函数如下:

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

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


复制代码

这就实现了减少 VNode 创建的性能消耗。需要了解的是,静态提升是以树为单位的,如下模板所示:

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

复制代码

除了根节点的 div 作为 block 不可被提升之外,整个 <section> 元素及其子代节点都会被提升,因为他们是整棵树都是静态的。如果我们把上面代码中的 abc 换成 {{ abc }},那么整棵树都不会被提升。再看如下代码:

<div>
  <section>
    {{ dynamicText }}
    <p>
      <span>abc</span>
    </p>
  </section >
</div>
复制代码

由于 section 标签内包含动态插值,因此以 section 为根节点的子树就不会被提升,但是 p 标签以及其子代节点都是静态的,是可以被提升的。

元素不会被提升的情况

  • 元素带有动态的 key 绑定

除了刚刚讲到的元素的所有子代节点必须都是静态的才会被提升之外还有哪些情况下会阻止提升呢?

如果一个元素有动态的 key 绑定那么它是不会被提升的,例如:

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

复制代码

实际上一个元素拥有任何动态绑定都不应该被提升,那么为什么 key 会被单独拿出来?实际上 key 和普通的 props 相比,它对于 VNode 的意义是不一样的,普通的 props 如果它是动态的,那么只需要体现在 PatchFlags 上就可以了,例如:

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

复制代码

我们可以为 p 标签打上 PatchFlags

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


复制代码

注意到在创建 VNode 时,为其打上了 PatchFlags.PROPS,代表这个元素需要更新 PROPS,并且需要更新的 PROPS 的名字叫 foo

h 但是 key 本身具有特殊意 hi 义,它是 VNode(或元素) 的唯一标识,即使两个元素除了 key 以外一切都相同,但这两个元素仍然是不同的元素,对于不同的元素需要做完全的替换处理才行,而 PatchFlags 用于在同一个元素上的属性补丁,因此 key 是不同于其它 props 的。

正因为 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 }))
    ]))
}


复制代码

Tips:手写优化模式的渲染函数时,如果使用动态的 key,记得要使用 Block 哦,我们在后文还会总结。

  • 使用 ref 的元素

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

<div ref="domRef"></div>

复制代码

乍一看觉得这完全就是一个静态元素,没错,元素本身不会发生变化,但由于 ref 的特性,导致我们必须在每次 Diff 的过程中重新设置 ref 的值,为什么呢?来看一个使用 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,同时我们注意到 setupContext(我们把 setup 函数返回的对象叫做 setupContext) 中也包含了同名的 domRef 属性,这不是偶然,他们之间会建立联系,最终结果就是:

  • useP1 为真时,refP1.value 引用 p 元素
  • useP1 为假时,refP2.value 引用 p 元素

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

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

render() {
    return (openBlock(), createBlock('div', null, [
        createVNode('p', { ref: 'domRef' }, null, PatchFlags.NEED_PATCH)
    ]))
}


复制代码
  • 使用自定义指令的元素

实际上一个元素如果使用除 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 、thead、tr、th、tbody、td、tfoot、colgroup、col

  • 标签的属性必须是:

  • 标准 HTML attribute: developer.mozilla.org/en-US/docs/…

  • 或 data-/aria- 类属性

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

  • 如果节点没有属性,那么必须有连续 20 个及以上的静态节点存在才行,例如:
<div>
    <p></p>
    <p></p>
    ... 20 个 p 标签
    <p></p>
</div>

复制代码

或者在这些连续的节点中有 5 个及以上的节点是有属性绑定的节点:

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

复制代码

这段节点的数量虽然没有达到 20 个,但是满足 5 个节点有属性绑定。

这些节点不一定是兄弟关系,父子关系也是可以的,只要阈值满足条件即可,例如:

<div>
    <p>
        <p>
            <p>
                <p>
                    <p></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></p><p></p>.....<p></p>')

复制代码

可见 id 属性值时计算后的。

Cache Event handler

如下组件的模板所示:

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

复制代码

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

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


复制代码

很显然,每次 render 函数执行的时候,Comp 组件的 props 都是新的对象,onChange 也会是全新的函数。这会导致触发 Comp 组件的更新。

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

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


复制代码

这样即使多次调用渲染函数也不会触发 Comp 组件的更新,因为 Vuepatch 阶段比对 props 时就会发现 onChange 的引用没变。

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

render.call(ctx, ctx, [])
复制代码

实际上,我们即使不依赖编译也能手写出具备 cache 能力的代码:

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

因此我们最好不要写出如下这样的代码:

const Comp = {
    setup() {
        return () => {
            return h(AnthorComp, {
                onChang(){/*...*/}  // 每次渲染函数执行,都是全新的函数
            })
        }
    }
}


复制代码

v-once

这是 Vue2 就支持的功能,v-once 是一个 “很指令” 的指令,因为它就是给编译器看的,当编译器遇到 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] // 整个表达式的值
        )
    ]))
}
复制代码

稍微解释一下这段代码,我们已经讲解过何为 “Block Tree”,而 openBlock()createBlock() 函数用来创建一个 Block。而 setBlockTracking(-1) 则用来暂停收集的动作,所以在 v-once 编译生成的代码中你会看到它,这样使用 v-once 包裹的内容就不会被收集到父 Block 中,也就不参与 Diff 了。

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

  • 1、VNode 的创建开销
  • 2、无用的 Diff 开销

但其实我们不通过模板编译,一样可以通过缓存 VNode 来减少 VNode 的创建开销:

const Comp = {
    setup() {
        // 缓存 content
        const content = h('div', 'xxxx')
        return () => {
            return h('section', content)
        }
    }
}


复制代码

但这样避免不了无用的 Diff 开销,因为我们没有使用 Block Tree 优化模式。

这里有必要提及的一点是:在 Vue2.5.18+ 以及 Vue3 中 VNode 是可重用的,例如我们可以在不同的地方多次使用同一个 VNode 节点:

const Comp = {
    setup() {
        const content = h('div', 'xxxx')
        return () => {
            // 多次渲染 content
            return h('section', [content, content, content])
        }
    }
}
复制代码

手写高性能渲染函数

接下来我们将进入重头戏环节,我们尝试手写优化模式的渲染函数。

几个需要记住的小点:

  1. 一个 Block 就是一个特殊的 VNode,可以理解为它只是比普通 VNode 多了一个 dynamicChildren 属性
  2. createBlock() 函数和 createVNode() 函数的调用签名几乎相同,实际上 createBlock() 函数内部就是封装了 createVNode(),这再次证明 Block 就是 VNode
  3. 在调用 createBlock() 创建 Block 前要先调用 openBlock() 函数,通常这两个函数配合逗号运算符一同出现:
render() {
    return (openBlock(), createBlock('div'))
}


复制代码

Block Tree 是灵活的:

在之前的介绍中根节点以 Block 的角色存在的,但是根节点并不必须是 Block,我们可以在任意节点开启 Block

setup() {
    return () => {
        return h('div', [
            (openBlock(), createBlock('p', null, [/*...*/]))
        ])
    }
}


复制代码

这也是可以的,因为渲染器在 Diff 的过程中如果 VNode 带有 dynamicChildren 属性,会自动进入优化模式。但是我们通常会让根节点充当 Block 角色。

正确地使用 PatchFlags:

PatchFlags 用来标记一个元素需要更新的内容,例如当元素有动态的 class 绑定时,我们需要使用 PatchFlags.CLASS 标记:

const App = {
  setup() {
    const refOk = ref(true)
    
    return () => {
      return (openBlock(), createBlock('div', null, [
        createVNode('p', { class: { foo: refOk.value } }, 'hello', PatchFlags.CLASS) // 使用 CLASS 标记
      ]))
    }
  }
}
复制代码

如果使用了错误的标记则可能导致更新失败,下面列出详细的标记使用方式:

  • PatchFlags.CLASS - 当有动态的 class 绑定时使用
  • PatchFlags.STYLE - 当有动态的 style 绑定时使用,例如:
createVNode('p', { style: { color: refColor.value } }, 'hello', PatchFlags.STYLE)
复制代码
  • PatchFlags.TEXT - 当有动态的文本节点是使用,例如:
createVNode('p', null, refText.value, PatchFlags.TEXT)
复制代码
  • PatchFlags.PROPS - 当有除了 classstyle 之外的其他动态绑定属性时,例如:
createVNode('p', { foo: refVal.value }, 'hello', PatchFlags.PROPS, ['foo'])
复制代码

这里需要注意的是,除了要使用 PatchFlags.PROPS 之外,还要提供第五个参数,一个数组,包含了动态属性的名字。

  • PatchFlags.FULL_PROPS - 当有动态 nameprops 时使用,例如:
createVNode('p', { [refKey.value]: 'val' }, 'hello', PatchFlags.FULL_PROPS)
复制代码

实际上使用 FULL_PROPS 等价于对 propsDiff 与传统 Diff 一样。其实,如果觉得心智负担大,我们大可以全部使用 FULL_PROPS,这么做的好处是:

  • 避免误用 PatchFlags 导致的 bug
  • 减少心智负担的同时,虽然失去了 props diff 的性能优势,但是仍然可以享受 Block Tree 的优势。

当同时存在多种更新,需要将 PatchFlags 进行按位或运算,例如:​PatchFlags.CLASS | PatchFlags.STYLE

NEED_PATCH 标识

为什么单独把这个标志拿出来讲呢,它比较特殊,需要我们额外注意。当我们使用 refonVNodeXXX 等 hook 时 (包括自定义指令),需要使用该标志,以至于它可以被父级 Block 收集,详细原因我们在静态提升一节里面讲解过了:

const App = {
  setup() {
    const refDom = ref(null)
    return () => {
      return (openBlock(), createBlock('div', null,[
        createVNode('p',
          {
            ref: refDom,
            onVnodeBeforeMount() {/* ... */}
          },
          null,
          PatchFlags.NEED_PATCH
        )
      ]))
    }
  }
}


复制代码

该使用 Block 的地方必须用

在最开始的时候,我们讲解了有些指令会导致 DOM 结构不稳定,从而必须使用 Block 来解决问题。手写渲染函数也是一样:

  • 分支判断使用 Block:
const App = {
  setup() {
    const refOk = ref(true)
    return () => {
      return (openBlock(), createBlock('div', null, [
        refOk.value
          // 这里使用 Block
          ? (openBlock(), createBlock('div', { key: 0 }, [/* ... */]))
          : (openBlock(), createBlock('div', { key: 1 }, [/* ... */]))
      ]))
    }
  }
}


复制代码

这里使用 Block 的原因我们在前文已经讲解过了,但这里需要强调的是,除了分支判断要使用 Block 之外,还需要为 Block 指定不同的 key 才行。

  • 列表使用 Block:

当我们渲染列表时,我们常常写出如下代码:

const App = {
  setup() {
    const obj = reactive({ list: [ { val: 1 }, { val: 2 } ] })

    return () => {
      return (openBlock(), createBlock('div', null,
        // 渲染列表
        obj.list.map(item => {
          return createVNode('p', null, item.val, PatchFlags.TEXT)
        })
      ))
    }
  }
}
复制代码

这么写在非优化模式下是没问题的,但我们现在使用了 Block,前文已经讲过为什么 v-for 需要使用 Block 的原因,试想当我们执行如下语句修改数据:

obj.list.splice(0, 1)
复制代码

这就会导致 Block 中收集的动态节点不一致,最终 Diff 出现问题。解决方案就是让整个列表作为一个 Block,这时我们需要使用 Fragment

const App = {
  setup() {
    const obj = reactive({ list: [ { val: 1 }, { val: 2 } ] })

    return () => {
      return (openBlock(), createBlock('div', null, [
        // 创建一个 Fragment,并作为 Block 角色
        (openBlock(true), createBlock(Fragment, null,
          // 在这里渲染列表
          obj.list.map(item => {
            return createVNode('p', null, item.val, PatchFlags.TEXT)
          }),
          // 记得要指定正确的 PatchFlags
          PatchFlags.UNKEYED_FRAGMENT
        ))
      ]))
    }
  }
}


复制代码

总结一下:

  • 对于列表我们应该始终使用 Fragment,并作为 Block 的角色
  • 如果 Fragmentchildren 没有指定 key,那么应该为 Fragment 打上 PatchFlags.UNKEYED_FRAGMENT。相应的,如果指定了 key 就应该打上 PatchFlags.KEYED_FRAGMENT
  • 注意到在调用 openBlock(true) 时,传递了参数 true,这代表这个 Block 不会收集 dynamicChildren,因为无论是 KEYED 还是 UNKEYEDFragment,在 Diff 它的 children 时都会回退传统 Diff 模式,因此不需要收集 dynamicChildren

这里还有一点需要注意,在 Diff Fragment 时,由于回退了传统 Diff,我们希望尽快恢复优化模式,同时保证后续收集的可控性,因此通常会让 Fragment 的每一个子节点都作为 Block 的角色:

const App = {
  setup() {
    const obj = reactive({ list: [ { val: 1 }, { val: 2 } ] })

    return () => {
      return (openBlock(), createBlock('div', null, [
        (openBlock(true), createBlock(Fragment, null,
          obj.list.map(item => {
            // 修改了这里
            return (openBlock(), createBlock('p', null, item.val, PatchFlags.TEXT))
          }),
          PatchFlags.UNKEYED_FRAGMENT
        ))
      ]))
    }
  }
}


复制代码

最后再说一下稳定的 Fragment,如果你能确定列表永远不会变化,例如你能确定 obj.list 是不会变化的,那么你应该使用:PatchFlags.STABLE_FRAGMENT 标志,并且调用 openBlcok() 去掉参数,代表收集 dynamicChildren

const App = {
  setup() {
    const obj = reactive({ list: [ { val: 1 }, { val: 2 } ] })

    return () => {
      return (openBlock(), createBlock('div', null, [
        // 调用 openBlock() 不要传参
        (openBlock(), createBlock(Fragment, null,
          obj.list.map(item => {
            // 列表中的任何节点都不需要是 Block 角色
            return createVNode('p', null, item.val, PatchFlags.TEXT)
          }),
          // 稳定的片段
          PatchFlags.STABLE_FRAGMENT
        ))
      ]))
    }
  }
}


复制代码

如上注释所述。

  • 使用动态 key 的元素应该是 Block

正如在静态提升一节中所讲的,当元素使用动态 key 的时候,即使两个元素的其他方面完全一样,那也是两个不同的元素,需要做替换处理,在 Block Tree 中应该以 Block 的角色存在,因此如果一个元素使用了动态 key,它应该是一个 Block

const App = {
  setup() {
    const refKey = ref('foo')

    return () => {
      return (openBlock(), createBlock('div', null,[
        // 这里应该是 Block
        (openBlock(), createBlock('p', { key: refKey.value }))
      ]))
    }
  }
}


复制代码

这实际上是必须的,详情查看 github.com/vuejs/vue-n…

使用 Slot hint

我们在 “稳定的 Fragment” 一节中提到了 slot hint,当我们为组件编写插槽内容时,为了告诉 runtime:“我们已经能够保证插槽内容的结构稳定”,则需要使用 slot hint

render() {
    return (openBlock(), createBlock(Comp, null, {
        default: () => [
            refVal.value
               ? (openBlock(), createBlock('p', ...)) 
               ? (openBlock(), createBlock('div', ...)) 
        ],
        // slot hint
        _: 1
    }))
}


复制代码

当然如果你不能保证这一点,或者觉得心智负担大,那么就不要写 hint 了。

使用 $stable hint

$stable hint 和之前讲的优化策略不同,前文中的策略都是假设渲染器在优化模式下工作的,而 $stable 用于非优化模式,也就是我们平时写的渲染函数。那么它有什么用呢?如下代码所示 (使用 tsx 演示):

export const App = defineComponent({
  name: 'App',
  setup() {
    const refVal = ref(true)

    return () => {
      refVal.value

      return (
        <Hello>
          {
            { default: () => [<p>hello</p>] }
          }
        </Hello>
      )
    }
  }
})


复制代码

如上代码所示,渲染函数中读取了 refVal.value 的值,建立了依赖收集关系,当修改 refVal 的值时,会触发 <Hello> 组件的更新,但是我们发现 Hello 组件一来没有 props 变化,二来它的插槽内容是静态的,因此不应该更新才对,这时我们可以使用 $stable hint

export const App = defineComponent({
  name: 'App',
  setup() {
    const refVal = ref(true)

    return () => {
      refVal.value

      return (
        <Hello>
          {
            { default: () => [<p>hello</p>], $stable: true } // 修改了这里
          }
        </Hello>
      )
    }
  }
})
复制代码

为组件正确地使用 DYNAMIC_SLOTS

当我们动态构建 slots 时,需要为组件的 VNode 指定 PatchFlags.DYNAMIC_SLOTS,否则将导致更新失败。什么是动态构建 slots 呢?通常情况下是指:依赖当前 scope 变量构建的 slots,例如:

render() {
    // 使用当前组件作用域的变量
    const slots ={}
    // 常见的场景
    // 情况一:条件判断
    if (refVal.value) {
        slots.header = () => [h('p', 'hello')]
    }
    // 情况二:循环
    refList.value.forEach(item => {
        slots[item.name] = () => [...]
    })
    // 情况三:动态 slot 名称,情况二包含情况三
    slots[refName.value] = () => [...]

    return (openBlock(), createBlock('div', null, [
        // 这里要使用 PatchFlags.DYNAMIC_SLOTS
        createVNode(Comp, null, slots, PatchFlags.DYNAMIC_SLOTS)
    ]))
}


复制代码

如上注释所述。

以上,不知道到达这里的同学有多少,Don't stop learning...

欢迎关注我的个人微信公众号「HcySunYang」。Let's coding for fun together!

文章分类
前端
文章标签