编译器:编译过程中的优化细节
前言
在开启本篇章之前,我们先来思考一个问题,假设有以下模板:
<template>
<p>hello world</p>
<p>{{ msg }}</p>
</template>>
其中一个 p
标签的节点是一个静态的节点,第二个 p
标签的节点是一个动态的节点,如果当 msg
的值发生了变化,那么理论上肉眼可见最优的更新方案应该是只做第二个动态节点的 diff
,而无需进行第一个 p
标签节点的 diff
。
如果熟悉 Vue 2.x
的小伙伴可能会知道,在 Vue 2.x
版本中在编译过程中有一个叫做 optimize
的阶段,会进行标记静态根节点的操作,被标记为静态根节点的节点,一方面会生成一个 staticRenderFns
,首次渲染会以这个静态根节点 vnode
进行缓存,后续渲染会直接取缓存中的,从而避免重复渲染;另一方面生成的 vnode
会带有 isStatic = true
的属性,将会在 diff
过程中被跳过。但 Vue 2.x
对静态节点进行缓存就是一种空间换时间的优化策略,为了避免过度优化,在 Vue 2.x
中,识别静态根节点是需要满足:
- 子节点是静态节点;
- 子节点不是只有一个静态文本节点的节点。
所以,上面的示例第一个 p
标签在 Vue 2.x
中不会被判定位静态根节点,也就无法进行优化。
关于 Vue 2.x
如何做的编译时优化,这里只是简单进行了介绍,想了解更多的小伙伴可以参考这里:入口开始,解读 Vue2 源码(七)—— $mount 内部实现 --- compile optimize标记节点。
那么 Vue 3
呢?还是和 Vue 2
一样吗?答案显然是否定的,首先我们前面介绍了对于静态的节点,Vue 3
首先会进行静态提升,也就是相当于缓存了静态节点的 vnode
,那 diff
过程呢?会跳过吗?本小节我们来一探究竟。
PatchFlags
是什么?
首先,我们需要认识一个 PatchFlags
这个属性,它是一个枚举类型,里面是一些二进制操作的值,用来标记在节点的 patch
类型。具体的枚举内容如下:
export const enum PatchFlags {
// 动态文本的元素
TEXT = 1,
// 动态 class 的元素
CLASS = 1 << 1,
// 动态 style 的元素
STYLE = 1 << 2,
// 动态 props 的元素
PROPS = 1 << 3,
// 动态 props 和有 key 值绑定的元素
FULL_PROPS = 1 << 4,
// 有事件绑定的元素
HYDRATE_EVENTS = 1 << 5,
// children 顺序确定的 fragment
STABLE_FRAGMENT = 1 << 6,
// children 中有带有 key 的节点的 fragment
KEYED_FRAGMENT = 1 << 7,
// 没有 key 的 children 的 fragment
UNKEYED_FRAGMENT = 1 << 8,
// 带有 ref、指令的元素
NEED_PATCH = 1 << 9,
// 动态的插槽
DYNAMIC_SLOTS = 1 << 10,
// 静态节点
HOISTED = -1,
// 不是 render 函数生成的元素,如 renderSlot
BAIL = -2,
}
这些二进制的值是通过左移操作符 <<
生成的,关于左移操作符,我们在《响应式原理:副作用函数探秘》篇章中已经介绍过了,在这里也是一种二进制操作的体现:
TEXT = 0000000001;
CLASS = 0000000010;
STYLE = 0000000100;
这里通过二进制来表示 PatchFlags
可以方便我们做很多属性的判断,比如 TEXT | STYLE
来得到 0000000101
,表示 patchFlag
既有 TEXT
属性也有 STYLE
属性,当需要进行判断有没有 STYLE
属性时,只需要 FLAG & STYLE > 0
就行。
什么时候生成的?
在搞清楚 patchFlags
的一些定义和使用基础后,那它是什么时候被赋值到 vnode
节点上的呢?前言中的模板字符串在 compiler
阶段会被转成一个 render
函数的字符串代码:
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "hello world", -1 /* HOISTED */)
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock(_Fragment, null, [
_hoisted_1,
_createElementVNode("p", null, _toDisplayString(msg), 1 /* TEXT */)
], 64 /* STABLE_FRAGMENT */))
}
这里可以看出,render
函数内是通过 createElementVNode
方法来创建 vnode
的,该函数的第四个参数就代表着 patchFlag
。对于我们上面的示例,其中 <p>hello world</p>
是 hoisted
,对应的 patchFlag = -1
,<p></p>
是动态文字节点,对应的 patchFlag = 1
。
有什么用?
接下来看看其实际使用案例,还是拿之前的 patchElement
函数来说:
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
let { patchFlag, dynamicChildren, dirs } = n2
// 如果 patchFlag 不存在,那么就设置成 FULL_PROPS,意味着要全量 props 比对
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
if (dynamicChildren) {
patchBlockChildren(
n1.dynamicChildren!,
dynamicChildren,
el,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds
)
} else if (!optimized) {
// full diff
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
)
}
if (patchFlag > 0) {
if (patchFlag & PatchFlags.FULL_PROPS) {
// 如果元素的 props 中含有动态的 key,则需要全量比较
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
)
} else {
// class
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, isSVG)
}
}
// style
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
}
// props
if (patchFlag & PatchFlags.PROPS) {
const propsToUpdate = n2.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
// #1471 force patch value
if (next !== prev || key === 'value') {
hostPatchProp(
el,
key,
prev,
next,
isSVG,
n1.children as VNode[],
parentComponent,
parentSuspense,
unmountChildren
)
}
}
}
}
// text
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
} else if (!optimized && dynamicChildren == null) {
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
)
}
}
这里涉及到两个比较重点的事儿,一个是和 dynamicChildren
相关,另一个是和动态 props
相关。我们先看和动态 props
相关的内容。
之前的章节我们跳过了对 PatchFlags
内容的理解,到了这里,我们通过代码可以知道 Vue
在更新子节点时,首先也是利用 patchFlag
的能力,对子节点进行分类做出不同的处理,比如针对以下例子:
<template>
<div :class="classNames" id='test'>
hello world
</div>
</template>
得到的编译结果:
import { normalizeClass as _normalizeClass, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", {
class: _normalizeClass(classNames),
id: "test"
}, " hello world ", 2 /* CLASS */))
}
此时 patchFlag & PatchFlags.CLASS > 0
则在 diff
过程中,需要进行 class
属性的 diff
, 从而减少了对 id
属性的不必要 diff
,提升了 props diff
过程中的性能。
dynamicChildren
另外,我们注意到,在编译后的 render
函数中会有一个 _openBlock()
函数的执行,我们来一起看一下其实现:
export const blockStack = []
export let currentBlock = null
export function openBlock(disableTracking = false) {
blockStack.push((currentBlock = disableTracking ? null : []))
}
openBlock
实现比较通俗易懂,就是向 blockStack
中 push
currentBlock
。其中 currentBlock
是一个数组,用于存储动态节点。blockStack
则是存储 currentBlock
的一个 Block tree
。
然后我们接着看 createElementBlock
的实现:
export function createElementBlock(type, props, children, patchFlag, dynamicProps, shapeFlag) {
return setupBlock(
createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
true /* isBlock */
)
)
}
function createBaseVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT, isBlockNode = false, needFullChildrenNormalization = false) {
// ...
// 添加动态 vnode 节点到 currentBlock 中
if (
isBlockTreeEnabled > 0 &&
!isBlockNode &&
currentBlock &&
(vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
vnode.patchFlag !== PatchFlags.HYDRATE_EVENTS
) {
currentBlock.push(vnode)
}
return vnode
}
function setupBlock(vnode) {
// 在 vnode 上保留当前 Block 收集的动态子节点
vnode.dynamicChildren =
isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR) : null
// 当前 Block 恢复到父 Block
closeBlock()
// 节点本身作为父 Block 收集的子节点
if (isBlockTreeEnabled > 0 && currentBlock) {
currentBlock.push(vnode)
}
return vnode
}
createElementBlock
内部首先通过 createBaseVNode
创建 vnode
节点,在创建的过程中,会根据 patchFlag
的值进行判断是否是动态节点,如果发现 vnode
是一个动态节点,那么会被添加到 currentBlock
当中,然后在执行 setupBlock
函数的时候,将 currentBlock
赋值给 vnode.dynamicChildren
属性。
我们前面看 patchElement
的时候,有注意到函数体内部会进行是否有 dynamicChildren
属性进行不同的逻辑执行,前面的章节,我们只介绍了 patchChildren
完整的子节点 diff
算法,当 dynamicChildren
存在时,这里只会进行 patchBlockChildren
的动态节点 diff
:
const patchBlockChildren = (oldChildren, newChildren, fallbackContainer, parentComponent, parentSuspense, isSVG) => {
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i]
const newVNode = newChildren[i]
// 确定待更新节点的容器
const container =
// 对于 Fragment,我们需要提供正确的父容器
oldVNode.type === Fragment ||
// 在不同节点的情况下,将有一个替换节点,我们也需要正确的父容器
!isSameVNodeType(oldVNode, newVNode) ||
// 组件的情况,我们也需要提供一个父容器
oldVNode.shapeFlag & 6 /* COMPONENT */
? hostParentNode(oldVNode.el)
:
// 在其他情况下,父容器实际上并没有被使用,所以这里只传递 Block 元素即可
fallbackContainer
patch(oldVNode, newVNode, container, null, parentComponent, parentSuspense, isSVG, true)
}
}
patchBlockChildren
的实现很简单,遍历新的动态子节点数组,拿到对应的新旧动态子节点,并执行 patch
更新子节点即可。
这样一来,更新的复杂度就变成和动态节点的数量正相关,而不与模板大小正相关。这也是 Vue 3
做的一个重要的编译时优化的一部分。
总结
有了上面的一些介绍,我们还是回到前言的例子中:
<template>
<p>hello world</p>
<p>{{ msg }}</p>
</template>
转成 vnode
后的结果大致为:
const vnode = {
type: Symbol(Fragment),
children: [
{ type: 'p', children: 'hello world' },
{ type: 'p', children: ctx.msg, patchFlag: 1 /* 动态的 text */ },
],
dynamicChildren: [
{ type: 'p', children: ctx.msg, patchFlag: 1 /* 动态的 text */ },
]
}
此时组件内存在了一个静态的节点 <p>hello world</p>
,在传统的 diff
算法里,还是需要对该静态节点进行不必要的 diff
。所以 Vue3
先通过 patchFlag
来标记动态节点 <p></p>
, 然后配合 dynamicChildren
将动态节点进行收集,从而完成在 diff
阶段只做靶向更新的目的。