普通视图

发现新文章,点击刷新页面。
昨天 — 2025年4月2日首页

Vue2 vs Vue3:Diff算法优化核心解析

作者 紫色风铃
2025年4月1日 19:49

Vue2:勤恳打工人,双端狂卷(Diff到秃头👨🦲)
Vue3:摸鱼大师,静态躺平(复用!勿cue💤)
结论:算法优化的尽头是——带薪摸鱼。

前言

通过这篇文章你可以了解到这些内容

  1. 虚拟dom
  2. vue3patch的整个流程
  3. vue3和vue2的diff的核心算法详解
  4. 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

  1. 检查是否是SVG元素
  2. 如果是首次渲染(n1为null),调用mountElement挂载元素
  3. 如果是更新(n1不为null),调用patchElement更新元素

processFragment

  1. 创建或复用fragment的首尾锚点
  2. 处理slotScopeIds
  3. 首次渲染时,插入锚点并挂载子节点
  4. 更新时,根据patchFlag和是否有dynamicChildren决定如何更新:
    • 有dynamicChildren时调用patchBlockChildren
    • 否则调用patchChildren进行全量对比

patchElement

  1. 复用DOM元素:el = n2.el = n1.el
  2. 触发更新前的钩子函数
  3. 根据patchFlag优化属性更新:
    • PatchFlags.FULL_PROPS:全量更新属性
    • PatchFlags.CLASS:只更新class
    • PatchFlags.STYLE:只更新style
    • PatchFlags.PROPS:更新动态属性
    • PatchFlags.TEXT:更新文本内容
  4. 如果有dynamicChildren,调用patchBlockChildren快速更新
    • 否则调用patchChildren全量更新子节点
    • 触发更新后的钩子函数

patchBlockChildren

  1. 遍历新的子节点数组
  2. 确定每个节点的挂载容器
  3. 针对每对新旧节点调用patch进行更新
  4. 不需要进行DOM位置移动的判断,因为Block中节点位置是固定的

patchChildren

  1. 通过patchFlag判断子节点类型和优化路径
  2. 对于有key的子节点(KEYED_FRAGMENT)调用patchKeyedChildren
  3. 对于无key的子节点(UNKEYED_FRAGMENT)调用patchUnkeyedChildren
  4. 处理文本子节点(TEXT_CHILDREN)
  5. 处理数组子节点到文本/空的转换
  6. 处理文本/空到数组子节点的转换

patchKeyedChildren

主要逻辑(Vue3的快速Diff算法):

  1. 从头部开始比较相同节点(节点类型和key相同)
  2. 从尾部开始比较相同节点
  3. 处理公共序列:
    • 新的比旧的多 => 挂载多出来的节点
    • 旧的比新的多 => 卸载多余的节点
  4. 处理乱序情况:
    • 构建新节点key到索引的映射
    • 遍历旧节点,尝试在新节点中找到对应节点
    • 构建最长递增子序列,避免不必要的DOM移动
    • 从后往前遍历,移动节点或挂载新节点

vue3的diff

Vue3 的 ‌diff 算法‌ 在源码中主要通过 patch 方法实现,但具体逻辑分散在 patch 的多个子流程中(如 patchElementpatchChildrenpatchKeyedChildren核心算法))

流程图

先简单看下patch的大致逻辑,对于不同节点的逻辑,这里只摘取元素节点frament节点进行说明。

image.png

针对dom节点,在patch整个方法链上,执行顺序如下:

patch ➡️ processElement ➡️ patchElement ➡️ patchBlockChildren ➡️ patch

针对frament节点,在patch整个方法链上,执行顺序如下:

patch ➡️ processFragment ➡️ patchElement ➡️ patchChildren ➡️ patchKeyedChildren ➡️ patch

首先先简单了解下有关diff算法的几个方法的主要逻辑和作用

patch方法逻辑

  1. 判断新旧节点是否类型相同,如不同则卸载旧节点
  2. 根据节点类型进行不同处理:
    • 文本节点:调用processText
    • 注释节点:调用processCommentNode
    • 静态节点:调用mountStaticNode或patchStaticNode
    • Fragment:调用processFragment
    • 普通元素:调用processElement
    • 组件:调用processComponent

其中的processElementprocessFragment为本文的主要研究对象。

本文将通过两个示例来展示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执行的整个过程

image.png

A节点的patch过程

image.png

  1. patch方法中根据节点类型判断,因为属于元素节点,所以执行processElement方法

  2. processElement方法中,A节点属于更新dom而不是新增节点,所以直接执行patchElement方法

  3. patchElement方法中,因为A有动态子节点,所以执行patchBlockChildren方法

  4. 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 }} 是动态的

image.png

a3节点的patch

a2节点,对应的代码是 <button @click="increment">增加</button>

这段代码中有一个点击事件,具有动态属性onclick,所以a3也被判断动态节点

对于a3来说,他的动态属性是一个方法,这个方法没有变量变更,方法也没有变更,所以diff认为他的这个动态属性暂时没有变化,所以最终处理为不做变更。

image.png

示例二

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>

  1. 需要支持多个同级节点的动态渲染
  2. 无法用单个容器元素包裹(不像template那样需要真实 DOM 的容器)

所以vue在编译的时候会将其编译为Fragment节点

image.png

对于这个示例,BC节点的过程可以参考上一个示例解释,这里主要了解D以及其子节点的过程。

processFragment的执行逻辑

在这之前,我们了解下processFragment函数的执行逻辑,其中的伪代码如下:

if n1 不存在(旧节点不存在)
执行挂载操作
else 
if n2节点是STABLE_FRAGMENT类型 && 有动态子节点
执行patchBlockChildren方法
else
执行patchChildren方法

这里稍微解释一下STABLE_FRAGMENT是什么

STABLE_FRAGMENT是指稳定的子节点顺序,触发条件如下

  1. 列表项的顺序不会改变(没有 unshift/push 等顺序变更操作)
  2. 仅通过 :key 绑定进行内容更新
  3. 没有 v-if 等导致结构变化的指令

image.png

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]

image.png

其中这个方法的步骤如下:

  1. 从头部遍历比较,直到找到不同的节点
  2. 从尾部遍历比较,直到找到不同的节点
  3. 处理新增节点,旧节点已全部处理但新节点还有剩余,挂载剩余新节点
  4. 处理删除节点,新节点已全部处理但旧节点还有剩余,卸载剩余旧节点
  5. 处理中间部分节点,包括更新、移动、删除和新增节点

从头部遍历

从头开始对新旧节点进行比较

i = 0时,旧节点A = 新节点A

继续遍历

image.png

i = 1时,旧节点B ≠ 新节点F,停止遍历

image.png

从尾部遍历

旧节点最后一个:E

新节点最后一个:G

E ≠ G

尾部遍历结束

image.png

此时:

{ 
    "i": 1, 
    "e1": 4, 
    "e2": 5, 
    "oldNode": { "key": "E", "text": "E" }, 
    "newNode": { "key": "G", "text": "G" } 
}

处理新增节点

这里需要注意的是,处理的新增节点是单纯的只有新增节点的情况!

像下图的情况就会走到这个逻辑,新节点的其他节点位置都和旧节点一样,只是在后面新增了节点

image.png

vue3源码中,需要 i > e1,且 i <= e2 才会走到这个逻辑 image.png

在我们的例子中,经过第二步处理后,当前的i,e1,e2值是:

"i": 1, 
"e1": 4, 
"e2": 5

并不满足 i > e1,且 i <= e2,所以不会走到这里

处理删除节点

同上面的新增节点处理,这里的删除节点是指:只有需要删除的节点,且删除最末尾(参考下图情况)

image.png

所以我们本文中的例子也不会走到这里

处理中间部分

此时,新旧节点都有剩余,需要处理未知序列
旧节点剩余索引范围: 1 到 4
新节点剩余索引范围: 1 到 5
旧节点序列: B C D E
新节点序列: F B E D G

这时对旧节点进行循环遍历并处理下面的三个逻辑:

旧节点进行循环遍历

构建key映射

构建key到新索引的映射是为了可以快速查找旧节点在新节点中的位置

image.png

所以这时的映射为:newIndexToOldIndexMap=[0, 2, 5, 4, 0]

处理删除节点

在构建映射的同时会判断出旧节点中是否有节点被删除掉,在本文的例子中,C节点被判断为需要删除的节点。

也就是在这个逻辑中对C进行删除操作

image.png

判断是否有节点需要移动

这个逻辑也是在对旧节点的遍历里中进行,主要是判断是否需要移动节点。

  1. 如果需要移动节点:使用最小递增子序列来优化出最少的移动节点

  2. 如果不需要移动节点:在后面的逻辑就不需要执行节点移动

但是如何判断需不需要移动呢?

在vue3源码中会维护一个变量maxNewIndexSoFar,它记录遍历旧节点过程中,在新序列里找到的最大位置索引。如果后续找到的新位置索引比之前的小,就意味着有节点需要移动。

简单理解就是新节点在旧节点的位置相对发生了变化,这个就标记为移动。在我们的这个例子中,旧节点的顺序是DE,但是在新节点中是ED,顺序变化就认为需要进行节点的移动。

循环遍历旧节点过程

上面说了,在循环遍历旧节点时,会有三个逻辑:构建key映射处理删除节点判断是否有节点需要移动

具体遍历的过程再描述一下。

遍历旧节点也就是遍历:BCDE

image.png

B节点:在新序列中位置为2,maxNewIndexSoFar = 2

image.png

C节点:在新序列中不存在,将被移除

image.png

D节点:在新序列中位置为4,maxNewIndexSoFar = 4

image.png

E节点:新序列中位置为3,比maxNewIndexSoFar小,设置moved = true

image.png

总结:

  1. 需要对节点进行移动
  2. 旧节点删除了C节点
  3. 得到了索引到旧索引的映射数组[0, 2, 5, 4, 0]

其中1和3操作都是为了对节点移动优化做准备

循环遍历新节点

在这个循环中主要进行了两个操作:新增节点移动节点

新增节点

在上面得到的映射数组中,如果值为0,代表该节点没有在旧节点出现,即视为新增节点

本文例子中,F和G对应的节点映射值是0,被判断为新增节点,进行挂载操作

移动节点

这个逻辑可以说是核心的核心

在移动之前,我们会获取一个最少移动节点的方案来减少dom的操作。

这个时候最小递增子序列就派上用场了

image.png

具体移动的操作步骤如下:

  1. 拿到映射数组对应的最小递增子序列
  2. 在循环时对比当前节点是否在最小递增子序列里面
  3. 如果在,则不移动;
  4. 如果不在,则移动。

最小递增子序列

在这里不详细描述最小递增子序列了,想要了解的看官可以移步这篇文章:最小递增子序列

对于本文的示例,得到的最小递增子序列应该是:[0, 1, 3]

image.png

其中,F和D对应的节点映射值是0,被判定为新增节点,所以需要判断是否移动的节点只有BED

image.png

而B和D都在最小递增子序列中,所以最终需要移动的节点只有节点E

E: 小丑竟是我自己!!

image.png

循环遍历新节点过程

上面了解了在遍历新节点之后执行的操作有新增节点移动节点,但是这个过程具体是什么呢?

前提了解

遍历顺序是倒序遍历,所以从新节点的最后一个节点开始遍历,所以遍历的顺序应该是GDEBF

且在遍历之前我们已经做了两个操作:

  1. 删除了C节点
  2. 得到了最小递增子序列(下图中浅紫色标记表示为在最小递增子序列中)

所以这时的新旧节点是这样对应的

image.png

G节点

G节点的映射值是0,在旧节点不存在,判断为新增节点,挂载到旧节点数组索引值为5的位置。

image.png

D节点

D节点属于移动节点,但是D在最小递增子序列(图中浅紫色为最小递增子序列)中,所以无需移动。

image.png

E节点

E节点属于移动节点,且不在最小递增子序列中,所以需要移动E节点:将E节点移动到D节点前面。

image.png

移动后如下图所示

image.png

B节点

B节点属于移动节点,但是在最小递增子序列中,所以无需移动。

image.png

F节点

F节点的映射值是0,属于新增节点,需要将F节点新增到旧节点索引值为1的位置。

image.png

移动之后变为:

image.png

到这里就遍历结束了,最终我们对旧节点执行了FG两个节点的新增操作,和E节点的移动操作。

vue2的diff算法

流程图

先看下vue2的patch逻辑是什么样子(这里只摘取了比较重要的逻辑分支)

image.png

相比vue3来说,vue2的逻辑比较简单暴力一些,主要函数就是:

patch ---> patchVnode ---> updateChildren

updateChildren也就是vue2-diff算法的核心

核心算法

image.png

Vue2采用双端比较算法,同时从新旧子节点的两端开始比较,需要四次比对(newStart/oldStart, newEnd/oldEnd, newStart/oldEnd, newEnd/oldStart)

  1. 采用双指针(首尾指针)算法,通过四种优化手段比较新旧子节点:
    • 旧头与新头比较:oldStartVnode vs newStartVnode
    • 旧尾与新尾比较:oldEndVnode vs newEndVnode
    • 旧头与新尾比较:oldStartVnode vs newEndVnode
    • 旧尾与新头比较:oldEndVnode vs newStartVnode 2 如果以上四种情况都不匹配,则通过key值在旧节点中查找新节点
  2. 如果找到,则复用并移动节点,否则创建新节点
  3. 循环结束后,可能有多余的旧节点(需要删除)或新节点(需要添加)

有关该核心算法,我们还是使用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

第一次对比

image.png

旧节点指向的开始节点:A

新节点指向的开始节点:A

此时对比结果是相同的,无需更新

第二次对比

image.png

  1. 旧节点指向的开始节点:B;新节点指向的开始节点:F ;不相同,继续对比
  2. 旧节点指向的结束节点:E;新节点指向的结束节点:G ;不相同,继续对比
  3. 旧节点指向的开始节点:B;新节点指向的结束节点:G ;不相同,继续对比
  4. 旧节点指向的结束节点:E;新节点指向的开始节点:F ;不相同,对比结束
  5. 在旧节点找key为F的节点,未找到,认为F节点是新增节点
  6. 创建F节点,插入到旧节点B之前

此时旧节点指针不变,新节点往前移动一次

image.png

第三次对比

image.png

此时的新旧开始节点都是B,相同,不需要执行操作

新旧节点指针同时往前移动一次

第四次对比

image.png

  1. 旧节点指向的开始节点:C;新节点指向的开始节点:E ;不相同,继续对比
  2. 旧节点指向的结束节点E;新节点指向的结束节点:G ;不相同,继续对比
  3. 旧节点指向的开始节点:C;新节点指向的结束节点:G ;不相同,继续对比
  4. 旧节点指向的结束节点:E;新节点指向的开始节点:E ;相同,执行移动操作
  5. 将E移动到C前面

旧节点开始指针保持不变,结束指针向前移动

新节点开始指针+1

image.png

第五次对比

image.png

  1. 旧节点指向的开始节点:C 新节点指向的开始节点:D ;不相同,继续对比
  2. 旧节点指向的结束节点;D 新节点指向的结束节点:G ;不相同,继续对比
  3. 旧节点指向的开始节点:C 新节点指向的结束节点:G ;不相同,继续对比
  4. 旧节点指向的结束节点:D 新节点指向的开始节点:D ;相同,执行移动操作
  5. 将D移动到C前面

旧节点开始指针保持不变,结束指针向前移动

新节点开始指针+1

image.png

第六次对比

image.png

  1. 旧节点指向的开始节点:C 新节点指向的开始节点:G ;不相同,继续对比
  2. 旧节点指向的结束节点;C 新节点指向的结束节点:G ;不相同,继续对比
  3. 旧节点指向的开始节点:C 新节点指向的结束节点:G ;不相同,继续对比
  4. 旧节点指向的结束节点:C 新节点指向的开始节点:G ;不相同,结束对比
  5. 旧节点中查找key为G的节点,未找到,认为G是新增节点
  6. 创建新节点G,插入到当前旧开始节点C之前
  7. 旧节点指针不动,新节点指针继续往前移动,此时指针超出,结束循环。

image.png

循环结束处理

上面循环到后面,新节点开始指针 > 新节点结束指针,表示着新节点已经处理完毕了

下面开始处理旧节点,也就是删除剩余的旧节点,即C节点

image.png

最终结果

  • 原始旧节点:[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的静态优化

  1. 真正的静态提升(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)
    ]))
  }
}
  1. 跳过静态节点的Patch

在本文的vue3的核心算法中能很清晰看到,Vue3在diff算法中可以完全跳过静态内容的比较,只遍历动态节点。 而Vue2则需要遍历所有节点

// Vue3 patch优化
if (patchFlag > 0) {
  if (patchFlag & PatchFlags.FULL_PROPS) {
    // 处理动态属性
  } else if (patchFlag & PatchFlags.TEXT) {
    // 仅处理文本内容变化
  }
  // ...其他标志处理
} 
// 静态节点不进入任何分支,直接跳过比较

image.png

  1. 颗粒度更细

在VUE2中,只能标记整个节点是否为静态节点。

而vue3中,Vue3引入了PatchFlag等概念,可以精确跟踪模板中哪些部分是动态的

Vue3静态提升的实际作用

  1. 内存占用优化:静态内容只被创建一次,减少了内存分配和垃圾回收压力
  2. 初始渲染速度提升:通过减少虚拟节点创建数量,加快了初始渲染速度
  3. 更新性能大幅提升:
    • 跳过静态内容对比
    • 减少了虚拟DOM比较的范围
    • 降低了渲染函数执行成本
  4. 优化编译输出:生成更高效的渲染代码,特别是对于包含大量静态内容的组件
  5.  体积优化:通过更有效的代码生成,可以产生更小的编译后代码

核心算法优化:最小递增子序列

文本上面已经详细表述了vue2和vue3的核心算法以及其运行的过程。

相对比来说,vue3的最小递增子序列减少了dom的移动操作

假设我们有以下场景:

  • 旧节点序列:[A, B, C, D, E]
  • 新节点序列:[A, C, E, B, D] 对于vue2的双端对比算法来说,它可能需要移动BCDE四个节点来保证节点更新。

而对于vue3,长递增子序列算法会识别最小移动的节点,只需要移动两个节点就能保证节点更新。

总结来说,vue3的核心算法优化有下面这些优势:

  1. 在大量元素重排的场景下,可以显著减少DOM操作次数。
  2. 复杂列表更新或者频繁重排的场景下,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相关优化

  1. 多根节点支持
<template>
  <!-- 没有包裹元素 -->
  <header>页面标题</header>
  <main>页面内容</main>
  <footer>页面底部</footer>
</template>
  1. 扁平化处理

Vue 3将多根节点视为一种特殊的"Fragment"节点类型,这个Fragment本身不会渲染为DOM元素,只渲染其子节点

  1. 更高效的DOM操作

由于不需要额外的包裹元素,减少了DOM节点的数量,提高了性能和内存效率。

  1. diff优化

Vue 3将Fragment作为一种特殊的VNode类型,让包含多个根元素的组件可以被表示为单一的VNode树结构。具体体现在processFragment方法中:

image.png

总结

  1. 更简洁的模板结构:不再需要不必要的包裹元素
  2. 更高效的DOM操作:减少DOM节点数量,提高性能
  3. 更优化的diff算法:更智能地识别和更新变化的内容
  4. 更好的开发体验:代码更清晰,不需要处理多余的DOM层级
昨天以前首页

面试得知道的编程题 | 系列1:扁平化处理、过滤对象等👍

2025年3月28日 10:25
总结自己最近遇到的编程题: 快排、冒泡(接近有序降得时间复杂度)、扁平化处理、过滤对象(将包含null或数组的对象过滤出对象)、列表和数字之间转换、螺旋数组(顺时针转) 快排 性能接近o(n) 接近有
❌
❌