solomonguo
前端开发工程师
solomonguo 2022-05-30
比对新旧两棵虚拟DOM树的时候,总是要按照虚拟DOM的层级结构“一层一层”地遍历
<div id="foo">
<p class="bar">{{ text }}</p>
</div>
上面这段代码中,当响应式数据text值发生变化的时候,最高效的更新方式是直接设置p标签的文本内容
传统Diff算法做不到如此高效,当text值发生变化的时候,会产生一颗新的虚拟DOM树,对比新旧虚拟DOM过程如下:
可以发现,有很多无意义的对比操作。
总结:
传统diff算法的问题: 无法利用编译时提取到的任何关键信息,导致渲染器在运行时不会去做相关的优化。
vue3的编译器会将编译得到的关键信息“附着”在它生成的虚拟DOM上,传递给渲染器,执行“快捷路径”。
传统Diff算法无法避免新旧虚拟DOM树间无用的比较操作,是因为运行时得不到足够的关键信息,从而无法区分动态内容和静态内容。 换句话说,只要运行时能够区分动态内容和静态内容,就可以实现极简的优化策略
举个例子:
<div>
<div>foo</div>
<p>{{ bar }}</p>
</div>
只有 {{ bar }}是动态的内容。理想情况下,当数据bar的值变化时,只需要更新p标签的文本节点即可。为了实现这个目标,需要提供信息给运行时
// 传统虚拟DOM描述
const vnode = {
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar },
]
}
// 编译优化后
const vnode = {
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: 1 }, // 这是动态节点
]
}
可以发现,虚拟节点多了一个额外的属性,即 patchFlag(补丁标志),存在该属性,就认为是动态节点
patchFlag(补丁标志)可以理解为一系列的数字标记,含义如下
const PatchFlags = {
TEXT: 1, // 代表节点有动态的 textContent
CLASS: 2, // 代表元素有动态的 class 绑定
STYLE: 3
// 其他。。。
}
可以在虚拟节点的创建阶段,把它的动态子节点提取出来,并存储到该虚拟节点的 dynamicChildren 数组中
const vnode = {
tag: 'div',
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: 1 }, // 这是动态节点
],
// 将children 中的动态节点提取到 dynamicChildren 数组中
dynamicChildren: [
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }
]
}
渲染器的更新操作会以Block为维度。当渲染器在更新一个Block时,会忽略虚拟节点的children数组,直接找到dynamicChildren数组,并只更新该数组中的动态节点。跳过了静态内容,只更新动态内容。同时,由于存在对应的补丁标志,也能够做到靶向更新。
Block节点有哪些: 模版根节点、 带有v-for、v-if/v-else-if/v-else等指令的节点
编译器生成的渲染函数代码中, 不会直接包含用来描述虚拟节点的数据结构,而是包含着用来创建虚拟DOM节点的辅助函数,如下
render() {
return createVNode('div', { id: 'foo' }, [
createVNode('p', null, 'text')
])
}
function createVNode(tag, props, children) {
const key = props && props.key
props && delete props.key
// 省略部分代码
return {
tag,
props,
children,
key
}
}
createVNode的返回值是一个虚拟DOM节点
举个例子:
<div id="foo">
<p class="bar">{{ bar }}</p>
</div>
上面模版生成带有补丁标志的渲染函数如下:
render() {
return createVNode('div', { id: 'foo' }, [
createVNode('p', { class: 'bar' }, text, PatchFlags.TEXT)
])
}
怎么将根节点变成一个Block, 如何将动态子代节点收集到该Block的dynamicChildren数组中?
可以发现,在渲染函数内,对createVNode函数的调用是层层嵌套结构,执行顺序是 内层先执行,外层再执行
,
当外层createVNode函数执行时,内层的createVNode函数已经执行完毕了。因此,为了让外层Block节点能够收集到内层动态节点,需要一个栈结构
的数据来临时存储内层的动态节点。 代码实现如下:
// 动态节点
const dynamicChildrenStack = []
// 当前动态节点集合
let currentDynamicChildren = null
// openBlock 用来创建一个新的动态节点集合,并将该集合压入栈中
function openBlock() {
dynamicChildrenStack.push((currentDynamicChildren = []))
}
// closeBlock 用来通过openBlock创建的动态节点集合从栈中弹出
function closeBlock() {
currentDynamicChildren = dynamicChildrenStack.pop()
}
然后调整createVNode函数
function createVNode(tag, props, children, flags) {
const key = props && props.key
props && delete props.key
const vnode = {
tag,
props,
children,
key
}
if (typeof flags !== 'undefined' && currentDynamicChildren) {
// 动态节点添加到当前动态集合节点中
currentDynamicChildren.push(vnode)
}
return vnode
}
接着调整
render() {
// 1. 使用 createBlock 代替 createVNode 来创建 block
// 2. 每当调用 createBlock 之前, 先调用 openBlock
return (openBlock(), createBlock('div', null, [
createVNode('p', { class: 'foo' }, null, 1),
createVNode('p', { class: 'bar' }, null),
])) // 利用逗号运算符的性质来保证渲染函数的返回值仍然是VNode对象
}
function createBlock(tag, props, children) {
// block 本质上也是一个 vnode
const block = createVNode(tag, props, children)
// 将当前动态节点集合作为 block.dynamicChildren
block.dynamicChildren = currentDynamicChildren
closeBlock()
return block
}
传统的节点更新方式如下:
function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}
// 在处理 children 时,调用 patchChildren 函数
patchChildren(n1, n2, el)
}
优化后的更新方式,直接对比动态节点
function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// 省略部分代码
if (n2.dynamicChildren) {
// 调用 patchBlockChildren 函数,只更新动态节点
patchBlockChildren(n1, n2)
} else {
patchChildren(n1, n2, el)
}
}
function patchBlockChildren(n1, n2) {
// 只更新动态节点
for(let i=0; i<n2.dynamicChildren.length; i++) {
patchElement(n1.dynamicChildren[i], n2.dynamicChildren[i])
}
}
存在对应的补丁标志,可以针对性地完成靶向更新
function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
if (n2.patchFlags) {
// 靶向更新
if (n2.patchFlags === 1) {
// 只需要更新class
} else if (n2.patchFlags === 2) {
// 只需要更新style
} else if (...) {
// ...
}
} else {
// 全量更新
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}
}
// 在处理 children 时,调用 patchChildren 函数
patchChildren(n1, n2, el)
}
除了模版的根节点是Block外, 带有结构化指令的节点,如:v-if、v-for,也都应该是Block
<div>
<section v-if="foo">
<p>{{ a }}</p>
</section>
<div v-else>
<p>{{ a }}</p>
</div>
</div>
假设只有最外层的div标签会作为Block, 那么变量foo的值为true还是false, block收集到的动态节点都是一样的,如下:
const block = {
tag: 'div',
dynamicChildren: [
{
tag: 'p', children: ctx.a, patchFlags: 1 }
}
]
}
这意味着,在Diff阶段不会更新。显然,foo 不同值下,一个是 section, 一个是 div, 是不同标签,是需要更新的。
再举个例子:
<div>
<section v-if="foo">
<p>{{ a }}</p>
</section>
<section v-else> // 即使这里是section
<div> // 这个div标签在diff过程中会被忽略
<p>{{ a }}</p>
<div>
</section>
</div>
一样会导致更新失败
问题在于:dynamicChildren收集的动态节点是忽略虚拟DOM树层级的,结构化指令会导致更新前后模版的结构发生变化,即模版结构不稳定
解决方法: 让带有v-if/v-else-if/v-else等结构化指令的节点也作为Block即可
,如下所示
Block(Div)
- Block(Section v-if)
- Block(Section v-else)
const block = {
tag: 'div',
dynamicChildren: [
{ tag: 'section', { key: 0 /* 不同的block, key不同 */ },dynamicChildren:[...]},
]
}
在Diff过程中, 渲染器根据key值区分, 使用新的 Block 替换旧的 Block
带有 v-for 指令的节点也会让虚拟DOM树变得不稳定
例子:
<div>
<p v-for="item in list"> {{ item }}</p>
<i>{{ foo }}</i>
<i>{{ bar }}</i>
</div>
list 的值 由 [1, 2] 变成 [1]
更新前后对应的 Block 树如下:
// 更新前
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 prevBlock = {
tag: 'div',
dynamicChildren: [
{ tag: 'p', children: 1, 1 /* TEXT */ },
{ tag: 'i', children: ctx.foo, 1 /* TEXT */ },
{ tag: 'i', children: ctx.bar, 1 /* TEXT */ },
]
}
更新前后,动态节点数量不一致,无法进行 diff 操作(diff操作的前提是:操作的节点必须是同层级节点, dynamicChildren不一定是同层级的
)
解决方法: 让 v-for指令的标签也作为Block角色,保证虚拟DOM树具有稳定的结构,无论 v-for 在运行时怎样变化。如下:
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 */ },
]
}
由于 v-for指令渲染的是一个片段,所以类型用 Fragment
<p v-for="item in list">{{ item }}</p>
// list 的值 由 [1, 2] 变成 [1]
// 更新前
const prevBlock = {
tag: Fragment,
dynamicChildren: [
{ tag: 'p', children: 1, 1 /* TEXT */ },
{ tag: 'p', children: 2, 1 /* TEXT */ },
]
}
// 更新后
const prevBlock = {
tag: Fragment,
dynamicChildren: [
{ tag: 'p', children: 1, 1 /* TEXT */ },
]
}
发现 Fragment 本身收集的动态节点存在结构是不稳定的情况
结构不稳定: 指更新前后一个block的dynamicChildren数组中收集的动态节点的数量或顺序不一致
这种情况无法直接进行靶向更新
解决方法: 回退到传统虚拟DOM的Diff手段,即直接使用Fragment的children而非 dynamicChildren来进行Diff操作
Fragment 的子节点仍然可以是由 Block 组成的数组
const block = {
tag: Fragment,
children: [
{ tag: 'p', children: item, dynamicChildren: [/*...*/, 1 /* TEXT */] },
{ tag: 'p', children: item, dynamicChildren: [/*...*/, 1 /* TEXT */] },
]
}
当Fragment 的子节点更新时,就可以恢复优化模式
有稳定的Fragment吗? 如下:
// v-for指令的表达式是常量
<p v-for="n in 10"></p>
<p v-for="n in 'abc'"></p>
稳定的Fragment, 可以使用优化模式
vue3模版中的多个根节点,也是稳定的Fragment
<template>
<div></div>
<p></p>
<i></i>
</template>
减少更新时创建虚拟DOM带来的性能开销和内存占用
如:
<div>
<p>static text</p>
<p>{{ title }}</p>
</div>
没有静态提升时,渲染函数是:
function render(){
return (openBlock(), createBlock('div', null, {
createVNode('p', null, 'static text'),
createVNode('p', null, ctx.title, 1 /* TEXT */),
}))
}
响应式数据 title 变化后,整个渲染函数会重新执行
把纯静态的节点提升到渲染函数之外
const hoist1 = createVNode('p', null, 'static text')
function render(){
return (openBlock(), createBlock('div', null, {
hoist1,
createVNode('p', null, ctx.title, 1 /* TEXT */),
}))
}
响应式数据 title 变化后,不会重新创建静态的虚拟节点
注: 静态提升是以树为单位的
包含动态绑定的节点本身不会被提升,但是该节点上的静态属性是可以被提升的
<div>
<p foo="bar" a=b>{{ text }}</p>
</div>
// 静态提升的props对象
const hoistprop = { foo: 'bar', a: 'b'}
function render(ctx) {
return (openBlock(), createBlock('div', null, [
createVNode('p', hoistprop, ctx.text)
]))
}
可以减少创建虚拟DOM产生的开销以及内存占用
基于静态提升,进一步采用预字符串化优化。
<div>
<p></p>
<p></p>
// ... 20个 p 标签
<p></p>
</div>
采用静态提升优化策略后
const hoist1 = createVNode('p', null, null, PatchFlags.HOISTED)
const hoist2 = createVNode('p', null, null, PatchFlags.HOISTED)
// ...20个 hoistx 变量
const hoist20 = createVNode('p', null, null, PatchFlags.HOISTED)
render(){
return (openBlock(), createBlock('div', null, [
hoist1, hoist2, /* ...20个变量 */, hoist20
]))
}
采用预字符串化将这些静态节点序列化为字符串, 并生成一个Static类型的VNode
const hoistStatic = createStatticVNode('<p></p><p></p>...20个...<p></p>')
render() {
return (openBlock(), createBlock('div', null, [hoistStatic]))
}
优势:
<Com @change="a+b"/>
function render(ctx) {
return h(Com, {
// 内联事件处理函数
onChange: () => (ctx.a + ctx.b)
})
}
每次重新渲染时,都会为Com组件创建一个全新的props对象。同时,props对象中onChange属性的值也会是全新的函数。造成额外的性能开销
function render(ctx, cache) { // 数组cache来自组件实例
return h(Com, {
// 将内联事件处理函数缓存到 cache中
onChange: cache[0] || cache[0] = ($event) => (ctx.a + ctx.b)
})
}
v-once 可以对虚拟DOM进行缓存
<section>
<div v-once>{{ foo }}</div>
</section>
function render(ctx, cache) {
return (openBlock(), createBlock('div', null, [
cache[1] || (cache[1] = createVNode("div", null, ctx.foo, 1 /* TEXT */))
]))
}
由于节点被缓存,意味着更新前后的虚拟节点不会发生变化,因此也就不需要这些被缓存的虚拟节点参与Diff操作了。编译后的结果如下:
render(ctx, cache) {
return (openBlock(), createBlock('div', null, [
setBlockTracking(-1), //阻止这段 VNode 被 Block缓存
cache[1] || (cache[1] = createVNode("div", null, ctx.foo, 1 /* TEXT */)),
setBlockTracking(1), // 恢复
cache[1] // 整个表达式的值
]))
}
v-once包裹的动态节点不会被父级Block收集,因此不会参与Diff操作
v-once指令通常用于不会发生改变的动态绑定中,例如绑定一个常量
<div v-once>{{ SOME_CONSTANT }}</div>
v-once带来的性能提升
1. vue3提出了 Block 的概念, 利用 Block树及补丁标志
2. 静态提升:可以减少更新时创建虚拟DOM产生的性能开销和内存占用
3. 预字符串化: 在静态提升的基础上,对静态节点进行字符串化。这样做能够减少创建虚拟节点产生的性能开销以及内存占用
4. 缓存内联事件处理函数:避免造成不必要的组件更新
5. v-once指令: 缓存全部或部分虚拟节点,能够避免组件更新时重新创建虚拟DOM带来的性能开销, 也可以避免无用的Diff操作