18.编译优化

发布于:2025-08-01 ⋅ 阅读:(16) ⋅ 点赞:(0)

1.动态节点收集与补丁标志

1.传统diff算法的问题

模板

 <div id="foo">
   <p class="bar">{{ text }}</p>
 </div>

这段节点中,可能发生变化的就是p标签,也就是说,当text值发生变化时,最高效的是直接设置p的标签内容。但传统算法需要一层一层的向下对比。

所以,如果能直接跳过这些操作,就可以大大提升性能。关键在于区分动态内容和静态内容,根据不同的内容采用不同的策略。

vue3的编译器会将编译时得到的关键信息"附着"在它生成的虚拟DOM上,这些信息都会通过虚拟DOM传递给浏览器。最终,渲染器会根据这些关键信息执行"快捷路径",从而提升运行时的性能。

2.Block 与 PatchFlags

模板

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

传统的虚拟 DOM 中没有任何标志能够体现出节点的动态性。但经过编译优化之后,编译器会将它提取到的关键信息“附着”到虚拟 DOM 节点上

vnode = {
   tag: 'div',
   children: [
     { tag: 'div', children: 'foo' },
     { tag: 'p', children: ctx.bar, patchFlag: 1 },  // 这是动态节点
   ]
 }

描述p标签的虚拟节点拥有一个额外的属性patchFlag,值是一个数字,只要该值存在,就代表是动态节点。

定义标识的映射:

const PatchFlags = {
   TEXT: 1, // 代表节点有动态的 textContent
   CLASS: 2, // 代表元素有动态的 class 绑定
   STYLE: 3
   // 其他……
 }

上述模板创建虚拟节点:

 vnode = {
   tag: 'div',
   children: [
     { tag: 'div', children: 'foo' },
     { tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }  // 这是动态节点
   ],
   // 将 children 中的动态节点提取到 dynamicChildren 数组中
   dynamicChildren: [
     // p 标签具有 patchFlag 属性,因此它是动态节点
     { tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }
   ]
 }

与普通虚拟节点相比,多出了一个dynamicChildren属性,把带有该属性的虚拟节点称为"块"–Block。

Block本质是一个虚拟节点,不过比普通虚拟节点多出一个用来存储动态子节点的dynamicChildren属性,一个Block不仅能够收集它的直接动态子节点,还能收集所有的动态子代节点。

模板:

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

对应的块:

vnode = {
   tag: 'div',
   children: [
     {
       tag: 'div',
       children: [
         { tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }  // 这是动态节点
       ]
      },
   ],
   dynamicChildren: [
     // Block 可以收集所有动态子代节点
     { tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }
   ]
 }

有了Block这个概念后,渲染器的更新也会以Block为维度。当渲染器在更新一个Block 时,会忽略虚拟节点的 children 数组,而是直接找到该虚拟节点的 dynamicChildren 数组,并只更新该数组中的动态节点。这样,在更新时就实现了跳过静态内容,只更新动态内容。同时,由于动态节点中存在对应的补丁标志,所以在更新动态节点的时候,也能够做到靶向更新。

3.收集动态节点

如何将根节点变成一个Block,以及如何将动态子代节点收集到该Block的dynamicChildren数组中。

在渲染函数内,对createVnode函数的调用是层级的嵌套结构,该函数执行的顺序是"内层先执行,外层后执行"

1732094729342

为了让外层的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,
     patchFlags: flags
   }

   if (typeof flags !== 'undefined' && currentDynamicChildren) {
     // 动态节点,将其添加到当前动态节点集合中
     currentDynamicChildren.push(vnode)
   }

   return vnode
 }
4.渲染器的运行时支持

有了 dynamicChildren 之后,patchElement函数内可以直接对比动态节点

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])
   }
 }

在修改后的patchElement函数中,优先检测虚拟DOM是否存在动态节点集合,即dynamicChildren数组。如果存在,直接调用patchBlockChildren函数完成更新。这样,渲染器只会更新动态节点,而跳过所有静态节点。

动态节点集合能够使得渲染器在执行更新时跳过静态节点,但对于单个动态节点的更新来说,由于它存在对应的补丁标志,因此我们可以针对性地完成靶向更新。

 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)
 }

在 patchElement 函数内,我们通过检测补丁标志实现了 props 的靶向更新。这样就避免了全量的 props 更新,从而最大化地提升性能。

2.Block树

1.带有v-if指令的节点

模板

 <div>
   <section v-if="foo">
     <p>{{ a }}</p>
   </section>
   <div v-else>
     <p>{{ a }}</p>
   </div>
 </div>

block收集到的动态节点

const block = {
   tag: 'div',
   dynamicChildren: [
     { tag: 'p', children: ctx.a, patchFlags: 1 }
   ]
   // ...
 }

在模板中,v-if的是section标签,v-else是div标签。前后标签不同,如果不做任何更新,将产生严重的bug:

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

即使带有 v-if 指令的标签与带有 v-else 指令的标签都是 <section> 标签,但由于两个分支的虚拟 DOM 树的结构不同,仍然会导致更新失败。

原因在于,dynamicChildren 数组中收集的动态节点是忽略虚拟 DOM 树层级的。换句话说,结构化指令会导致更新前后模板的结构发生变化,即模板结构不稳定。

解决方法:只需要让带有 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 树:

Block(Div)

- Block(Section v-if)

- Block(Section v-else)

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

 block = {
     tag: 'div',
     dynamicChildren: [
       /* Block(Section v-if) 或者 Block(Section v-else) */
       { tag: 'section', { key: 0 /* key 值会根据不同的 Block 而发生变化 */ }, dynamicChildren: [...]},
     ]
 }

这样,当 v-if 条件为真时,父级 Block 的 dynamicChildren 数组中包含的是 Block(section v-if);当v-if 的条件为假时,父级 Block 的 dynamicChildren 数组中包含的将是 Block(section v-else)。在 Diff 过程中,渲染器能够根据 Block 的 key 值区分出更新前后的两个 Block 是不同的,并使用新的 Block 替换旧的 Block。这样就解决了 DOM 结构不稳定引起的更新问题。

2.带有v-for指令的节点

模板

 <div>
   <p v-for="item in list">{{ item }}</p>
   <i>{{ foo }}</i>
   <i>{{ bar }}</i>
 </div>

假设list本身为[1,2],更新为[1]

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

进行 Diff 操作的节点必须是同层级节点。但是 dynamicChildren 数组内的节点未必是同层级的

将带有v-for指令的标签也作为block角色即可:

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 的节点来表达 v-for 指令的渲染结果,并作为 Block 角色。

3.Fragment的稳定性

一个Fragment节点模板

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

当list由[1,2]更改为[1]后,对应的block为

 // 更新前
 const prevBlock = {
   tag: Fragment,
   dynamicChildren: [
     { tag: 'p', children: item, 1 /* TEXT */ },
     { tag: 'p', children: item, 2 /* TEXT */ }
   ]
 }

 // 更新后
 prevBlock = {
   tag: Fragment,
   dynamicChildren: [
     { tag: 'p', children: item, 1 /* TEXT */ }
   ]
 }

Fragment 本身收集的动态节点仍然面临结构不稳定的情况:从结果看,指的是更新前后一个block的dynamicChildren数组中收集的动态节点的数量或顺序不一致。这样就无法精准判断更新的结果,从而无法进行靶向更新。

解决方法:放弃根据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 */ }
   ]
 }
4.静态提升

模板

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

一个静态的文本,一个动态的文本。当修改时,静态的文本又会被重新创建一遍,造成了不必要的性能开销,应该将纯静态的节点提升到渲染函数之外,就是静态提升:

把静态节点提升到渲染函数之外

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

 function render() {
   return (openBlock(), createBlock('div', null, [
     hoist1, // 静态节点引用
     createVNode('p', null, ctx.title, 1 /* TEXT */)
   ]))
 }

把纯静态的节点提升到渲染函数之外后,在渲染函数内只会持有对静态节点的引用。当响应式数据变化,并使得渲染函数重新执行时,并不会重新创建静态的虚拟节点,从而避免了额外的性能开销。

 //静态提升是以树为单位
 //模板:
<div>
   <section>
     <p>
       <span>abc</span>
     </p>
   </section >
 </div>

除了div作为Block角色不可被提升之外,整个section元素即子节点都会被提升。如果span的内容是动态内容,则都不会提升。

模板:

<div>
   <p foo="bar" a=b>{{ text }}</p>
 </div>

p 标签存在动态绑定的文本内容,因此整个节点都不会被静态提升。但该节点的所有 props 都是静态的,因此在最终生成渲染函数时,我们可以将纯静态的 props 提升到渲染函数之外。

静态提升的 props 对象

 const hoistProp = { foo: 'bar', a: 'b' }

 function render(ctx) {
   return (openBlock(), createBlock('div', null, [
     createVNode('p', hoistProp, ctx.text)
   ]))
 }
5.预字符串化

静态的虚拟节点或虚拟节点树本身都是静态的

如下模板:

 <div>
   <p></p>
   <p></p>
   // ... 20 个 p 标签
   <p></p>
 </div>

假设模板中包含大量纯静态节点,采用静态提升后,编译代码如下:

 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
   ]))
 }

预字符串化能让静态节点序列化为字符串,生成一个静态类型的VNode

 const hoistStatic = createStaticVNode('<p></p><p></p><p></p>...20 个...<p></p>')

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

优势:

● 大块的静态内容可以通过 innerHTML 进行设置,在性能上具有一定优势。

● 减少创建虚拟节点产生的性能开销。

● 减少内存占用。

6.缓存内联事件处理函数

模板:

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

编译器会创建一个内联事件处理函数:

function render(ctx) {
   return h(Comp, {
     // 内联事件处理函数
     onChange: () => (ctx.a + ctx.b)
   })
 }

不过,每次render重新执行时,都会为Comp组件创建一个全新的props对象。同时,props对象中onChange属性的值也会是全新的函数。这会导致渲染器对Comp组件进行更新,造成新能开销。为了避免这类无用的更新,可以对内联事件处理函数进行缓存

function render(ctx, cache) {
   return h(Comp, {
     // 将内联事件处理函数缓存到 cache 数组中
     onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
   })
 }

渲染函数的第二个参数是一个数组 cache,该数组来自组件实例,我们可以把内联事件处理函数添加到 cache 数组中。这样,当渲染函数重新执行并创建新的虚拟 DOM 树时,会优先读取缓存中的事件处理函数。这样,无论执行多少次渲染函数,props 对象中 onChange 属性的值始终不变,于是就不会触发 Comp 组件更新了。

7.v-once

缓存事件内联还可以配合实现v-once对虚拟DOM的缓存

模板:

 <section>
   <div v-once>{{ foo }}</div>
 </section>

div 标签存在动态绑定的文本内容。但是它被 v-once 指令标记,所以这段模板会被编译为:

 function render(ctx, cache) {
   return (openBlock(), createBlock('div', null, [
     cache[1] || (cache[1] = createVNode("div", null, ctx.foo, 1 /* TEXT */))
   ]))
 }

在上面的编译结果中,div对应的虚拟节点被缓存到了cache数组中。既然虚拟节点已经被缓存了,那么后续更新导致渲染函数重新执行时,会优先读取缓存的内容,而不会重新创建虚拟节点。

同时,如果虚拟节点被缓存,意味着更新前后的虚拟节点不会发生变化,因此也就不需要这些被缓存节点参与Diff操作了。实际编译后的代码如下:

 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] // 整个表达式的值
     )
   ]))
 }

setBlockTracking(-1) 函数调用,它用来暂停动态节点的收集。换句话说,使用 v-once 包裹的动态节点不会被父级 Block 收集。因此,被 v-once 包裹的动态节点在组件更新时,自然不会参与 Diff 操作。

v-once 指令能够从两个方面提升性能:

● 避免组件更新时重新创建虚拟 DOM 带来的性能开销。因为虚拟 DOM 被缓存了,所以更新时无须重新创建。

● 避免无用的 Diff 开销。这是因为被 v-once 标记的虚拟 DOM 树不会被父级 Block 节点收集。

总结

1732497127749

1732497139140


网站公告

今日签到

点亮在社区的每一天
去签到