大佬总结,知乎转载
https://zhuanlan.zhihu.com/p/150732926
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
- 稳定的
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 Tree
和 PatchFlags
是 Vue3
充分利用编译信息并在 Diff
阶段所做的优化。尤大已经不止一次在公开场合聊过思路,我们深入细节的目的是为了更好的理解,并试图手写出高性能的 VNode
。
传统 Diff 算法的问题
“传统 vdom
”的 Diff
算法总归要按照 vdom
树的层级结构一层一层的遍历(如果你对各种传统 diff
算法不了解,可以看我之前写《渲染器》这套文章,里面总结了三种传统 Diff
方式),举个例子如下模板所示:
<div>
<p class="foo">bar</p>
</div>
对于传统 diff
算法来说,它在 diff
这段 vnode
(模板编译后的 vnode
)时会经历:
Div 标签的属性 + children
<p> 标签的属性(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
树中,我们在运行时得不到任何有用信息,但是 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
的角色呢?
来看下面这段模板:
<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>
标签,所以这里就出问题了。实际上问题的本质在于 dynamicChildren
的 diff
是忽略 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
仍然面临结构不稳定的情况,所谓结构不稳定从结果上看指的是更新前后一个 block
的 dynamicChildren
中收集的动态节点数量或顺序的不一致。这种不一致会导致我们没有办法直接进行靶向 Diff
,怎么办呢?其实对于这种情况是没有办法的,我们只能抛弃 dynamicChildren
的 Diff
,并回退到传统 Diff
:即 Diff
Fragment
的 children
而非 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 Tree
的 Diff
模式。
稳定的 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-for
的 template
元素本身来说,它是一个不稳定的 Fragment
,因为 list
不是常量。除此之外,由于 <template>
元素本身不渲染任何真实 DOM
,因此如果它含有多个元素节点,那么这些元素节点也将作为 Fragment
存在,但这个 Fragment
是稳定的,因为它不会随着 list
的变化而变化。
以上内容差不多就是 Block Tree
配合 PatchFlags
是如何做到靶向更新以及一些具体的思路细节了。
静态提升
提升静态节点树
Vue3
的 Compiler
如果开启了 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
也会被收集到父 Block
的 dynamicChildren
中。
但由于 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
就不是静态的,也就不会被提升。但这个 VNode
的 props
却可能是静态的,这使我们可以将它的 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: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
或 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>
这段节点的数量虽然没有达到 20 个,但是满足 5 个节点有属性绑定。
这些节点不一定是兄弟关系,父子关系也是可以的,只要阈值满足条件即可,例如:
<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>')
可见 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
组件的更新,因为 Vue
在 patch
阶段比对 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])
}
}
}
手写高性能渲染函数
接下来我们将进入重头戏环节,我们尝试手写优化模式的渲染函数。
几个需要记住的小点:
- 一个
Block
就是一个特殊的VNode
,可以理解为它只是比普通VNode
多了一个dynamicChildren
属性 -
createBlock()
函数和createVNode()
函数的调用签名几乎相同,实际上createBlock()
函数内部就是封装了createVNode()
,这再次证明Block
就是VNode
。 - 在调用
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
- 当有除了class
和style
之外的其他动态绑定属性时,例如:
createVNode('p', { foo: refVal.value }, 'hello', PatchFlags.PROPS, ['foo'])
这里需要注意的是,除了要使用 PatchFlags.PROPS
之外,还要提供第五个参数,一个数组,包含了动态属性的名字。
-
PatchFlags.FULL_PROPS
- 当有动态name
的props
时使用,例如:
createVNode('p', { [refKey.value]: 'val' }, 'hello', PatchFlags.FULL_PROPS)
实际上使用 FULL_PROPS
等价于对 props
的 Diff
与传统 Diff
一样。其实,如果觉得心智负担大,我们大可以全部使用 FULL_PROPS
,这么做的好处是:
- 避免误用
PatchFlags
导致的bug
- 减少心智负担的同时,虽然失去了
props diff
的性能优势,但是仍然可以享受Block Tree
的优势。
当同时存在多种更新,需要将 PatchFlags
进行按位或运算,例如:PatchFlags.CLASS | PatchFlags.STYLE
。
NEED_PATCH 标识
为什么单独把这个标志拿出来讲呢,它比较特殊,需要我们额外注意。当我们使用 ref
或 onVNodeXXX
等 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
的角色 - 如果
Fragment
的children
没有指定key
,那么应该为Fragment
打上PatchFlags.UNKEYED_FRAGMENT
。相应的,如果指定了key
就应该打上PatchFlags.KEYED_FRAGMENT
- 注意到在调用
openBlock(true)
时,传递了参数true
,这代表这个Block
不会收集dynamicChildren
,因为无论是KEYED
还是UNKEYED
的Fragment
,在 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 }))
]))
}
}
}
这实际上是必须的,详情查看 https://github.com/vuejs/vue-next/issues/938 。
使用 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...