Vue3 的 Compiler 与 runtime 紧密合作,充分利用编译是信息,使得性能得到了极大的提升。本文的目的告诉你 Vue3 的 Compiler 到底做了哪些优化,以及一些你可能希望知道的优化细节,在这个基础上我们试着总结出一套手写优化模式的高性能渲染函数的方法,这些知识也可以用于实现一个 Vue3 的 jsx babel 插件中,让 jsx 也能享受优化模式的运行时收益,这里需要澄清的是,即使在非优化模式下,理论上 Vue3 的 Diff 性能也是要优于 Vue2 的。另外本文不包括 SSR 相关优化,希望在下篇文章总结。
篇幅较大,花费了很大的精力整理,对于对 Vue3 还没有太多了解的同学阅读起来也许会吃力,不妨先收藏,以后也许会用得到。
按照惯例 TOC:
Block Tree 和 PatchFlags传统 Diff 算法的问题
Block 配合 PatchFlags 做到靶向更新
节点不稳定 - Block Tree
v-if 的元素作为 Block
v-for 的元素作为 Block
不稳定的 Fragment
稳定的 Fragmentv-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 Tree 和 PatchFlags 是 Vue3 充分利用编译信息并在 Diff 阶段所做的优化。尤大已经不止一次在公开场合聊过思路,我们深入细节的目的是为了更好的理解,并试图手写出高性能的 VNode。
传统 Diff 算法的问题
“传统 vdom”的 Diff 算法总归要按照 vdom 树的层级结构一层一层的遍历(如果你对各种传统 diff 算法不了解,可以看我之前写《渲染器》这套文章,里面总结了三种传统 Diff 方式),举个例子如下模板所示:
bar
对于传统 diff 算法来说,它在 diff 这段 vnode(模板编译后的 vnode)时会经历:Div 标签的属性 + children
标签的属性(class) + children
文本节点:bar
但是很明显,这明明就是一段静态 vdom,它在组件更新阶段是不可能发生变化的。如果能在 diff 阶段跳过静态内容,那就会避免无用的 vdom 树的遍历和比对,这应该就是最早的优化思路来源----跳过静态内容,只对比动态内容。
Block 配合 PatchFlags 做到靶向更新
咱们先说 Block 再聊 Block Tree。现在思路有了,我们只希望对比非静态的内容,例如:
foo
{ { bar }}
在这段模板中,只有
{ { bar }}
中的文本节点是动态的,因此只需要靶向更新该文本节点即可,这在包含大量静态内容而只有少量动态内容的场景下,性能优势尤其明显。可问题是怎么做呢?我们需要拿到整颗 vdom 树中动态节点的能力,其实可能没有大家想像的复杂,来看下这段模板对应的传统 vdom 树大概长什么样:const vnode = { tag: 'div', children: [ { tag: 'p', children: 'foo' }, { tag: 'p', children: ctx.bar }, // 这是动态节点 ]}
在传统的 vdom 树中,我们在运行时得不到任何有用信息,但是 Vue3 的 compiler 能够分析模板并提取有用信息,最终体现在 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 的角色呢?
来看下面这段模板:
{ { a }}
{ { a }}
假设只要最外层的 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 的是一个 标签,v-else 的是一个
{ { a }}
{ { a }}
即使 v-else 的也是一个 标签,但由于前后 DOM 树的不稳定,也会导致问题。这时我们就思考,如何让 DOM 树的结构变稳定呢?
v-if 的元素作为 Block
如果让使用了 v-if/v-else-if/v-else 等指令的元素也作为 Block 会怎么样呢?我们拿如下模板为例:
{ { a }}
{ { a }}
如果我们让这两个 section 标签都作为 block,那么将构成一颗 block tree:
Block(Div) - Block(Section v-if) - Block(Section v-else)
父级 Block 除了会收集子代动态节点之外,也会收集子 Block,因此两个 Block(section) 将作为 Block(div) 的 dynamic