Vue2 vs Vue3:Diff算法优化核心解析
Vue2:勤恳打工人,双端狂卷(Diff到秃头👨🦲)
Vue3:摸鱼大师,静态躺平(复用!勿cue💤)
结论:算法优化的尽头是——带薪摸鱼。
前言
通过这篇文章你可以了解到这些内容
- 虚拟dom
- vue3patch的整个流程
- vue3和vue2的diff的核心算法详解
- vue2和vue3的diff有哪些不同
前要了解
先介绍一下虚拟dom
虚拟dom,其实就是一个对象,里面存储了真实dom的结构和属性。
在vue中当一个dom变更时,会对比新的虚拟dom和旧的虚拟dom,计算出最小化的dom操作,从而提升渲染性能。
而如何来对比新旧虚拟dom来获取最小更新的这个算法,就是diff算法。
虚拟dom
看一下我们平时编写的vue代码生成的虚拟dom是什么样的
<div id="app">
<h1 class="title">Hello</h1>
<p>Initial Text</p>
<button @click="handleClick">Click Me</button>
</div>
生成的虚拟dom
const vnode = {
tag: 'div',
props: { id: 'app' },
children: [
{
tag: 'h1',
props: { class: 'title' },
children: 'Hello',
shapeFlag: 1 | 8, // ELEMENT + TEXT_CHILDREN
patchFlag: -1, // HOISTED(静态提升节点)
dynamicProps: null
},
{
tag: 'p',
props: {},
children: 'Initial Text',
shapeFlag: 1 | 8, // ELEMENT + TEXT_CHILDREN
patchFlag: 0, // 无动态内容
dynamicProps: null
},
{
tag: 'button',
props: {
onClick: handleClick,
_vei: { onClick: true } // 事件监听器标识
},
children: 'Click Me',
shapeFlag: 1 | 8, // ELEMENT + TEXT_CHILDREN
patchFlag: 8, // PROPS(需要检查事件绑定)
dynamicProps: ['onClick'] // 需要对比的动态属性
}
],
shapeFlag: 1 | 16, // ELEMENT + ARRAY_CHILDREN
patchFlag: 0, // 父容器无动态标记
dynamicChildren: [ // 动态子节点快速访问路径
null, // h1 是静态节点
null, // p 是静态节点
{ /* button 的动态引用 */ } // 仅保留动态节点引用
]
}
patchFlag & shapeFlag
patchFlag
PatchFlag 是 Vue 3 中用于优化更新性能的一种标记机制。
它用数字表示一个 VNode 中哪些属性是动态的,在更新时只需对这些动态属性进行对比和更新,而无需比较所有属性。它的几种主要类型是:
// TEXT = 1 表示节点的文本内容是动态的
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
// CLASS = 2 表示节点的 class 是动态的
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, isSVG)
}
}
// STYLE = 4 表示节点的 style 是动态的
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
}
// PROPS = 8 表示节点有动态的 props
if (patchFlag & PatchFlags.PROPS) {
const propsToUpdate = n2.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
// 只更新动态的 props
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
if (next !== prev) {
hostPatchProp(el, key, prev, next, isSVG)
}
}
}
ShapeFlag
ShapeFlag 用于标记 VNode 的类型结构,它描述一个节点的大致"形状",比如是元素、组件、文本或者它的子节点是什么类型的。ShapeFlag 的主要类型有:
// ELEMENT = 1 表示一个普通 DOM 元素
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(...)
}
// COMPONENT = 4 表示一个组件
if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(...)
}
// TEXT_CHILDREN = 8 表示子节点是纯文本
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
}
if (c2 !== c1) {
hostSetElementText(container, c2 as string)
}
}
// ARRAY_CHILDREN = 16 表示子节点是数组
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 处理子节点数组
}
相关方法
先简单了解一下文本会说到的一些方法的逻辑和作用
processElement
- 检查是否是SVG元素
- 如果是首次渲染(n1为null),调用mountElement挂载元素
- 如果是更新(n1不为null),调用patchElement更新元素
processFragment
- 创建或复用fragment的首尾锚点
- 处理slotScopeIds
- 首次渲染时,插入锚点并挂载子节点
- 更新时,根据patchFlag和是否有dynamicChildren决定如何更新:
- 有dynamicChildren时调用patchBlockChildren
- 否则调用patchChildren进行全量对比
patchElement
- 复用DOM元素:el = n2.el = n1.el
- 触发更新前的钩子函数
- 根据patchFlag优化属性更新:
- PatchFlags.FULL_PROPS:全量更新属性
- PatchFlags.CLASS:只更新class
- PatchFlags.STYLE:只更新style
- PatchFlags.PROPS:更新动态属性
- PatchFlags.TEXT:更新文本内容
- 如果有dynamicChildren,调用patchBlockChildren快速更新
- 否则调用patchChildren全量更新子节点
- 触发更新后的钩子函数
patchBlockChildren
- 遍历新的子节点数组
- 确定每个节点的挂载容器
- 针对每对新旧节点调用patch进行更新
- 不需要进行DOM位置移动的判断,因为Block中节点位置是固定的
patchChildren
- 通过patchFlag判断子节点类型和优化路径
- 对于有key的子节点(KEYED_FRAGMENT)调用
patchKeyedChildren
- 对于无key的子节点(UNKEYED_FRAGMENT)调用
patchUnkeyedChildren
- 处理文本子节点(TEXT_CHILDREN)
- 处理数组子节点到文本/空的转换
- 处理文本/空到数组子节点的转换
patchKeyedChildren
主要逻辑(Vue3的快速Diff算法):
- 从头部开始比较相同节点(节点类型和key相同)
- 从尾部开始比较相同节点
- 处理公共序列:
- 新的比旧的多 => 挂载多出来的节点
- 旧的比新的多 => 卸载多余的节点
- 处理乱序情况:
- 构建新节点key到索引的映射
- 遍历旧节点,尝试在新节点中找到对应节点
- 构建最长递增子序列,避免不必要的DOM移动
- 从后往前遍历,移动节点或挂载新节点
vue3的diff
Vue3 的 diff 算法 在源码中主要通过 patch
方法实现,但具体逻辑分散在 patch 的多个子流程中(如 patchElement
、patchChildren
和 patchKeyedChildren(核心算法))
流程图
先简单看下patch的大致逻辑,对于不同节点的逻辑,这里只摘取元素节点和frament节点进行说明。
针对dom节点,在patch整个方法链上,执行顺序如下:
patch ➡️ processElement ➡️ patchElement ➡️ patchBlockChildren ➡️ patch
针对frament节点,在patch整个方法链上,执行顺序如下:
patch ➡️ processFragment ➡️ patchElement ➡️ patchChildren ➡️ patchKeyedChildren ➡️ patch
首先先简单了解下有关diff算法的几个方法的主要逻辑和作用
patch方法逻辑
- 判断新旧节点是否类型相同,如不同则卸载旧节点
- 根据节点类型进行不同处理:
- 文本节点:调用processText
- 注释节点:调用processCommentNode
- 静态节点:调用mountStaticNode或patchStaticNode
- Fragment:调用processFragment
- 普通元素:调用processElement
- 组件:调用processComponent
其中的processElement和processFragment为本文的主要研究对象。
本文将通过两个示例来展示vue3的patch方法的整个逻辑执行流程。
示例一
当我们在编写vue代码的时候,如果是下面的代码,在vue源码中的执行过程是什么呢?
<div class="container">
<h1>计时器</h1>
<div class="counter">{{ count }}</div>
<button @click="increment">增加</button>
</div>
首先看这段代码生成的虚拟dom
const vnode = {
tag: 'div',
props: { class: 'container' },
children: [
{
tag: 'h1',
props: null,
children: '计时器',
shapeFlag: 1 | 8, // ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
patchFlag: 0 // 静态节点无更新标记
},
{
tag: 'div',
props: { class: 'counter' },
children: [
{
type: Symbol.for('v-txt'), // 文本节点类型标识
children: _ctx.count,
shapeFlag: 8, // ShapeFlags.TEXT_CHILDREN
patchFlag: 1 // PatchFlags.TEXT (动态文本)
}
],
shapeFlag: 1 | 16, // ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
patchFlag: 1 // 表示仅文本内容动态变化
},
{
tag: 'button',
props: {
onClick: _ctx.increment,
_vei: { onClick: true } // 事件监听器标识
},
children: '增加',
shapeFlag: 1 | 8, // ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
patchFlag: 8 // PatchFlags.PROPS (需要检查props变化)
}
],
shapeFlag: 1 | 16, // ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
patchFlag: 0 // 父容器节点无更新标记
}
众所周知,diff算法是用来比较新旧的虚拟节点
但是在第一次渲染的时候,根本就不存在旧节点,所以在初次渲染的时候是直接将新的节点挂在在空白页面
我们这里主要研究在点击【增加】按钮patch执行的整个过程
A节点的patch过程
-
在
patch
方法中根据节点类型判断,因为属于元素节点,所以执行processElement
方法 -
在
processElement
方法中,A节点属于更新dom而不是新增节点,所以直接执行patchElement
方法 -
在
patchElement
方法中,因为A有动态子节点,所以执行patchBlockChildren
方法 -
patchBlockChildren
,就是对所有的动态子节点遍历进行patch,而A的动态子节点就是a2和a3。
a1节点的patch
a1节点,对应的代码是 <h1>计时器</h1>
实际上在vue的编译阶段,a1节点已经被打上了静态节点的标签
这意味着a1节点并不会发生变化,所以在进行diff处理的时候,直接跳过了a1
这也是vue3的优化之一
a2节点的patch
a2节点,对应的代码是 <div class="counter">{{ count }}</div>
不难看出,a2节点的是文本内容 {{ count }}
是动态的
a3节点的patch
a2节点,对应的代码是 <button @click="increment">增加</button>
这段代码中有一个点击事件,具有动态属性onclick,所以a3也被判断动态节点
对于a3来说,他的动态属性是一个方法,这个方法没有变量变更,方法也没有变更,所以diff认为他的这个动态属性暂时没有变化,所以最终处理为不做变更。
示例二
vue代码
<div>
<button @click="insertMiddle">Insert</button>
<div v-for="item in state.items" :key="item.id"> {{ item.text }} </div>
</div>
上面的vue代码生成的虚拟dom是这样的
const vnode = {
tag: 'div',
props: null,
children: [
{
tag: 'button',
props: {
onClick: insert,
_vei: { onClick: true } // 事件监听器缓存标识
},
children: 'Insert',
shapeFlag: 9, // ELEMENT (1) + TEXT_CHILDREN (8)
patchFlag: 8, // PROPS (需要检查事件绑定)
dynamicProps: ['onClick'],
dynamicChildren: null
},
{
tag: null, // 虚拟 Fragment 节点
children: state.items.map(item => ({
tag: 'div',
key: item.id, // 关键 key 值
props: {},
children: item.text,
shapeFlag: 9, // ELEMENT (1) + TEXT_CHILDREN (8)
patchFlag: 1, // TEXT (动态文本内容)
dynamicProps: null,
dynamicChildren: null
})),
shapeFlag: 17, // ARRAY_CHILDREN (16) + ELEMENT (1)
patchFlag: 0,
dynamicChildren: null
}
],
shapeFlag: 17, // ELEMENT (1) + ARRAY_CHILDREN (16)
patchFlag: 0,
dynamicChildren: [
// 动态子节点快速访问路径
{ /* button 的动态引用 */ },
{ /* v-for 块跟踪的动态节点 */ }
]
}
对于这段原始模板代码<div v-for="item in items" :key="item.id">{{ item.text }}</div>
:
- 需要支持多个同级节点的动态渲染
- 无法用单个容器元素包裹(不像
template
那样需要真实 DOM 的容器)
所以vue在编译的时候会将其编译为Fragment
节点
对于这个示例,BC节点的过程可以参考上一个示例解释,这里主要了解D以及其子节点的过程。
processFragment的执行逻辑
在这之前,我们了解下processFragment
函数的执行逻辑,其中的伪代码如下:
if n1 不存在(旧节点不存在)
执行挂载操作
else
if n2节点是STABLE_FRAGMENT类型 && 有动态子节点
执行patchBlockChildren方法
else
执行patchChildren方法
这里稍微解释一下STABLE_FRAGMENT是什么
STABLE_FRAGMENT是指稳定的子节点顺序,触发条件如下
- 列表项的顺序不会改变(没有 unshift/push 等顺序变更操作)
- 仅通过 :key 绑定进行内容更新
- 没有 v-if 等导致结构变化的指令
D节点
在patch
方法中,根据节点类型来判断要执行什么函数,在上面的例子中就了解到,元素节点是执行processElement
函数。
那么在这个示例中,D节点会被判定为虚拟 Fragment 节点,所以会执行processFragment
方法
所以D节点的整个patch逻辑过程应该是:
patch ---> processFragment ---> patchElement---> patchChildren ---> patchKeyedChildren---> patch
patchChildren:主要判断子节点是否有key,有key执行patchKeyedChildren方法,没有key执行patchUnkeyedChildren方法
其中的patchKeyedChildren方法就是我们的diff算法的核心
vue3的diff核心算法
核心算法是在patchKeyedChildren
方法里面,假设我们现在有以下新旧节点,通过这个简单示例来了解diff核心算法的过程
旧节点:[A, B, C, D, E]
新节点:[A, F, B, E, D, G]
其中这个方法的步骤如下:
- 从头部遍历比较,直到找到不同的节点
- 从尾部遍历比较,直到找到不同的节点
- 处理新增节点,旧节点已全部处理但新节点还有剩余,挂载剩余新节点
- 处理删除节点,新节点已全部处理但旧节点还有剩余,卸载剩余旧节点
- 处理中间部分节点,包括更新、移动、删除和新增节点
从头部遍历
从头开始对新旧节点进行比较
i = 0时,旧节点A = 新节点A
继续遍历
i = 1时,旧节点B ≠ 新节点F,停止遍历
从尾部遍历
旧节点最后一个:E
新节点最后一个:G
E ≠ G
尾部遍历结束
此时:
{
"i": 1,
"e1": 4,
"e2": 5,
"oldNode": { "key": "E", "text": "E" },
"newNode": { "key": "G", "text": "G" }
}
处理新增节点
这里需要注意的是,处理的新增节点是单纯的只有新增节点的情况!
像下图的情况就会走到这个逻辑,新节点的其他节点位置都和旧节点一样,只是在后面新增了节点。
vue3源码中,需要 i > e1,且 i <= e2 才会走到这个逻辑
在我们的例子中,经过第二步处理后,当前的i,e1,e2值是:
"i": 1,
"e1": 4,
"e2": 5
并不满足 i > e1,且 i <= e2
,所以不会走到这里
处理删除节点
同上面的新增节点处理,这里的删除节点是指:只有需要删除的节点,且删除最末尾
(参考下图情况)
所以我们本文中的例子也不会走到这里
处理中间部分
此时,新旧节点都有剩余,需要处理未知序列。
旧节点剩余索引范围: 1 到 4
新节点剩余索引范围: 1 到 5
旧节点序列: B C D E
新节点序列: F B E D G
这时对旧节点进行循环遍历并处理下面的三个逻辑:
旧节点进行循环遍历
构建key映射
构建key到新索引的映射是为了可以快速查找旧节点在新节点中的位置。
所以这时的映射为:newIndexToOldIndexMap=[0, 2, 5, 4, 0]
处理删除节点
在构建映射的同时会判断出旧节点中是否有节点被删除掉,在本文的例子中,C节点被判断为需要删除的节点。
也就是在这个逻辑中对C进行删除操作
判断是否有节点需要移动
这个逻辑也是在对旧节点的遍历里中进行,主要是判断是否需要移动节点。
-
如果需要移动节点:使用最小递增子序列来优化出最少的移动节点
-
如果不需要移动节点:在后面的逻辑就不需要执行节点移动
但是如何判断需不需要移动呢?
在vue3源码中会维护一个变量maxNewIndexSoFar
,它记录遍历旧节点过程中,在新序列里找到的最大位置索引。如果后续找到的新位置索引比之前的小,就意味着有节点需要移动。
简单理解就是新节点在旧节点的位置相对发生了变化,这个就标记为移动。在我们的这个例子中,旧节点的顺序是DE,但是在新节点中是ED,顺序变化就认为需要进行节点的移动。
循环遍历旧节点过程
上面说了,在循环遍历旧节点时,会有三个逻辑:构建key映射,处理删除节点和判断是否有节点需要移动
具体遍历的过程再描述一下。
遍历旧节点也就是遍历:BCDE
B节点:在新序列中位置为2,maxNewIndexSoFar
= 2
C节点:在新序列中不存在,将被移除
D节点:在新序列中位置为4,maxNewIndexSoFar
= 4
E节点:新序列中位置为3,比maxNewIndexSoFar
小,设置moved = true
总结:
- 需要对节点进行移动
- 旧节点删除了C节点
- 得到了索引到旧索引的映射数组:
[0, 2, 5, 4, 0]
其中1和3操作都是为了对节点移动优化做准备
循环遍历新节点
在这个循环中主要进行了两个操作:新增节点和移动节点
新增节点
在上面得到的映射数组中,如果值为0,代表该节点没有在旧节点出现,即视为新增节点。
本文例子中,F和G对应的节点映射值是0,被判断为新增节点,进行挂载操作
移动节点
这个逻辑可以说是核心的核心。
在移动之前,我们会获取一个最少移动节点的方案来减少dom的操作。
这个时候最小递增子序列就派上用场了
具体移动的操作步骤如下:
- 拿到映射数组对应的最小递增子序列
- 在循环时对比当前节点是否在最小递增子序列里面
- 如果在,则不移动;
- 如果不在,则移动。
最小递增子序列
在这里不详细描述最小递增子序列了,想要了解的看官可以移步这篇文章:最小递增子序列。
对于本文的示例,得到的最小递增子序列应该是:[0, 1, 3]
其中,F和D对应的节点映射值是0,被判定为新增节点,所以需要判断是否移动的节点只有BED
而B和D都在最小递增子序列中,所以最终需要移动的节点只有节点E
E: 小丑竟是我自己!!
循环遍历新节点过程
上面了解了在遍历新节点之后执行的操作有新增节点和移动节点,但是这个过程具体是什么呢?
前提了解
遍历顺序是倒序遍历,所以从新节点的最后一个节点开始遍历,所以遍历的顺序应该是GDEBF。
且在遍历之前我们已经做了两个操作:
- 删除了C节点
- 得到了最小递增子序列(下图中浅紫色标记表示为在最小递增子序列中)
所以这时的新旧节点是这样对应的
G节点
G节点的映射值是0,在旧节点不存在,判断为新增节点,挂载到旧节点数组索引值为5的位置。
D节点
D节点属于移动节点,但是D在最小递增子序列(图中浅紫色为最小递增子序列)中,所以无需移动。
E节点
E节点属于移动节点,且不在最小递增子序列中,所以需要移动E节点:将E节点移动到D节点前面。
移动后如下图所示
B节点
B节点属于移动节点,但是在最小递增子序列中,所以无需移动。
F节点
F节点的映射值是0,属于新增节点,需要将F节点新增到旧节点索引值为1的位置。
移动之后变为:
到这里就遍历结束了,最终我们对旧节点执行了FG两个节点的新增操作,和E节点的移动操作。
vue2的diff算法
流程图
先看下vue2的patch逻辑是什么样子(这里只摘取了比较重要的逻辑分支)
相比vue3来说,vue2的逻辑比较简单暴力一些,主要函数就是:
patch ---> patchVnode ---> updateChildren
而updateChildren也就是vue2-diff算法的核心
核心算法
Vue2采用双端比较算法,同时从新旧子节点的两端开始比较,需要四次比对(newStart/oldStart, newEnd/oldEnd, newStart/oldEnd, newEnd/oldStart)
- 采用双指针(首尾指针)算法,通过四种优化手段比较新旧子节点:
- 旧头与新头比较:oldStartVnode vs newStartVnode
- 旧尾与新尾比较:oldEndVnode vs newEndVnode
- 旧头与新尾比较:oldStartVnode vs newEndVnode
- 旧尾与新头比较:oldEndVnode vs newStartVnode 2 如果以上四种情况都不匹配,则通过key值在旧节点中查找新节点
- 如果找到,则复用并移动节点,否则创建新节点
- 循环结束后,可能有多余的旧节点(需要删除)或新节点(需要添加)
有关该核心算法,我们还是使用vue3的例子来解释一下
旧节点:[A, B, C, D, E]
新节点:[A, F, B, E, D, G]
初始化
- 旧节点(oldCh):[A, B, C, D, E]
- 新节点(newCh):[A, F, B, E, D, G]
- 指针初始化:
- oldStartIdx = 0,指向A
- oldEndIdx = 4,指向E
- newStartIdx = 0,指向A
- newEndIdx = 5,指向G
第一次对比
旧节点指向的开始节点:A
新节点指向的开始节点:A
此时对比结果是相同的,无需更新
第二次对比
- 旧节点指向的开始节点:B;新节点指向的开始节点:F ;不相同,继续对比
- 旧节点指向的结束节点:E;新节点指向的结束节点:G ;不相同,继续对比
- 旧节点指向的开始节点:B;新节点指向的结束节点:G ;不相同,继续对比
- 旧节点指向的结束节点:E;新节点指向的开始节点:F ;不相同,对比结束
- 在旧节点找key为F的节点,未找到,认为F节点是新增节点
- 创建F节点,插入到旧节点B之前
此时旧节点指针不变,新节点往前移动一次
第三次对比
此时的新旧开始节点都是B,相同,不需要执行操作
新旧节点指针同时往前移动一次
第四次对比
- 旧节点指向的开始节点:C;新节点指向的开始节点:E ;不相同,继续对比
- 旧节点指向的结束节点E;新节点指向的结束节点:G ;不相同,继续对比
- 旧节点指向的开始节点:C;新节点指向的结束节点:G ;不相同,继续对比
- 旧节点指向的结束节点:E;新节点指向的开始节点:E ;相同,执行移动操作
- 将E移动到C前面
旧节点开始指针保持不变,结束指针向前移动
新节点开始指针+1
第五次对比
- 旧节点指向的开始节点:C 新节点指向的开始节点:D ;不相同,继续对比
- 旧节点指向的结束节点;D 新节点指向的结束节点:G ;不相同,继续对比
- 旧节点指向的开始节点:C 新节点指向的结束节点:G ;不相同,继续对比
- 旧节点指向的结束节点:D 新节点指向的开始节点:D ;相同,执行移动操作
- 将D移动到C前面
旧节点开始指针保持不变,结束指针向前移动
新节点开始指针+1
第六次对比
- 旧节点指向的开始节点:C 新节点指向的开始节点:G ;不相同,继续对比
- 旧节点指向的结束节点;C 新节点指向的结束节点:G ;不相同,继续对比
- 旧节点指向的开始节点:C 新节点指向的结束节点:G ;不相同,继续对比
- 旧节点指向的结束节点:C 新节点指向的开始节点:G ;不相同,结束对比
- 旧节点中查找key为G的节点,未找到,认为G是新增节点
- 创建新节点G,插入到当前旧开始节点C之前
- 旧节点指针不动,新节点指针继续往前移动,此时指针超出,结束循环。
循环结束处理
上面循环到后面,新节点开始指针 > 新节点结束指针,表示着新节点已经处理完毕了
下面开始处理旧节点,也就是删除剩余的旧节点,即C节点
最终结果
- 原始旧节点:[A, B, C, D, E]
- 原始新节点:[A, F, B, E, D, G]
- 最终DOM:[A, F, B, E, D, G]
- 节点变化总结:
- 保留节点:A, B
- 移动节点:E(从末尾移到前面), D(从末尾移到前面)
- 新增节点:F, G
- 删除节点:C
vue3相比vue2优化了什么
静态标记系统 (PatchFlag)
Vue3在编译时会为动态内容打上补丁标记(PatchFlag),用数字表示不同类型的动态内容。
// Vue3编译后的渲染函数示例
export function render(_ctx, _cache) {
return (_openBlock(), _createBlock("div", null, [
_createTextVNode("静态文本"),
_createVNode("span", null, _toDisplayString(_ctx.message), 1 /* TEXT */),
_createVNode("button", { onClick: _ctx.handleClick }, "点击", 8 /* PROPS */)
]))
}
标记值 | 含义 | 作用 |
---|---|---|
1 | TEXT | 仅文本内容为动态 |
2 | CLASS | 仅class属性为动态 |
4 | STYLE | 仅style属性为动态 |
8 | PROPS | 有动态属性 |
16 | FULL_PROPS | 有动态key属性 |
32 | HYDRATE_EVENTS | 需要绑定事件 |
64 | STABLE_FRAGMENT | Fragment子元素顺序不变 |
128 | KEYED_FRAGMENT | Fragment子元素有key |
256 | UNKEYED_FRAGMENT | Fragment子元素无key |
512 | NEED_PATCH | 需要被patch |
-1 | HOISTED | 静态提升 |
-2 | BAIL | 含有动态节点,需要全量对比 |
vue3的静态标记优化可以选择性更新,只对标记部分进行diff,根据标记类型执行精确更新,例如只有文本发生变化时,直接更新textContent而不涉及其他属性。
静态提升
vue2的静态优化
Vue2主要通过以下方式处理静态内容:
- 静态节点标记:Vue2通过optimizer.js中的markStatic和markStaticRoots函数识别模板中的静态节点。
- 静态树渲染函数:识别后的静态子树会被提取到staticRenderFns中,通过_m方法调用。
- 缓存机制:renderStatic函数允许在首次渲染后缓存静态树。
// Vue2的静态树渲染
export function renderStatic (
index: number,
isInFor: boolean
): VNode | Array<VNode> {
const cached = this._staticTrees || (this._staticTrees = [])
let tree = cached[index]
// 如果已渲染过且不在v-for中,可重用同一棵树
if (tree && !isInFor) {
return tree
}
// 否则,渲染新树
tree = cached[index] = this.$options.staticRenderFns[index].call(...)
}
然而,Vue2的这种机制仍然在每次组件重新渲染时都要执行函数调用,只是避免了重新创建VNode节点,性能提升有限。
vue3的静态优化
- 真正的静态提升(Static Hoisting)
Vue3的静态提升将不会改变的静态节点提升到渲染函数之外,使它们只在应用启动时被创建一次,而非每次重新渲染都创建
// Vue3编译输出示例
// 静态节点直接提升到render函数外部
const _hoisted_1 = /*#__PURE__*/createElementVNode("div", { class: "static" }, "静态内容", -1)
function render() {
return (_ctx, _cache) => {
return (_openBlock(), _createElementBlock("div", null, [
_hoisted_1, // 直接使用已创建的静态节点
createElementVNode("div", null, toDisplayString(_ctx.dynamic), 1)
]))
}
}
- 跳过静态节点的Patch
在本文的vue3的核心算法中能很清晰看到,Vue3在diff算法中可以完全跳过静态内容的比较,只遍历动态节点。 而Vue2则需要遍历所有节点
// Vue3 patch优化
if (patchFlag > 0) {
if (patchFlag & PatchFlags.FULL_PROPS) {
// 处理动态属性
} else if (patchFlag & PatchFlags.TEXT) {
// 仅处理文本内容变化
}
// ...其他标志处理
}
// 静态节点不进入任何分支,直接跳过比较
- 颗粒度更细
在VUE2中,只能标记整个节点是否为静态节点。
而vue3中,Vue3引入了PatchFlag等概念,可以精确跟踪模板中哪些部分是动态的
Vue3静态提升的实际作用
- 内存占用优化:静态内容只被创建一次,减少了内存分配和垃圾回收压力
- 初始渲染速度提升:通过减少虚拟节点创建数量,加快了初始渲染速度
- 更新性能大幅提升:
- 跳过静态内容对比
- 减少了虚拟DOM比较的范围
- 降低了渲染函数执行成本
- 优化编译输出:生成更高效的渲染代码,特别是对于包含大量静态内容的组件
- 体积优化:通过更有效的代码生成,可以产生更小的编译后代码
核心算法优化:最小递增子序列
文本上面已经详细表述了vue2和vue3的核心算法以及其运行的过程。
相对比来说,vue3的最小递增子序列减少了dom的移动操作,
假设我们有以下场景:
- 旧节点序列:[A, B, C, D, E]
- 新节点序列:[A, C, E, B, D] 对于vue2的双端对比算法来说,它可能需要移动BCDE四个节点来保证节点更新。
而对于vue3,长递增子序列算法会识别最小移动的节点,只需要移动两个节点就能保证节点更新。
总结来说,vue3的核心算法优化有下面这些优势:
- 在大量元素重排的场景下,可以显著减少DOM操作次数。
- 复杂列表更新或者频繁重排的场景下,Vue3的diff算法表现出更好的性能。
Fragment 支持
Vue 2中的Fragment限制
在Vue 2中,组件模板必须有一个单一的根元素,如果没有单一根元素,vue2会发出警告。
因为在Vue2的diff算法中,使用的是双端比较算法(双指针法),只能处理单根节点组件。当需要处理列表时,只能通过包装在一个根元素内来进行。
这个限制迫使开发者在不需要额外DOM元素的情况下也必须添加一个包裹层,例如使用<div>
包裹实际内容。
<template>
<div> <!-- 必须的包裹元素 -->
<header>页面标题</header>
<main>页面内容</main>
<footer>页面底部</footer>
</div>
</template>
Vue3的Fragment相关优化
- 多根节点支持
<template>
<!-- 没有包裹元素 -->
<header>页面标题</header>
<main>页面内容</main>
<footer>页面底部</footer>
</template>
- 扁平化处理
Vue 3将多根节点视为一种特殊的"Fragment"节点类型,这个Fragment本身不会渲染为DOM元素,只渲染其子节点
- 更高效的DOM操作
由于不需要额外的包裹元素,减少了DOM节点的数量,提高了性能和内存效率。
- diff优化
Vue 3将Fragment作为一种特殊的VNode类型,让包含多个根元素的组件可以被表示为单一的VNode树结构。具体体现在processFragment
方法中:
总结
- 更简洁的模板结构:不再需要不必要的包裹元素
- 更高效的DOM操作:减少DOM节点数量,提高性能
- 更优化的diff算法:更智能地识别和更新变化的内容
- 更好的开发体验:代码更清晰,不需要处理多余的DOM层级