Vue2 - VDOM 和双端 Diff 算法

理解 Vue 2 中虚拟 DOM(VDOM)的实现原理和 Diff 算法的核心机制,包括 VNode 的创建、patch 流程、以及双端 diff 算法的实现细节。

vue版本:以 vue@2.7.16 代码为参考,可能会包含部分 vue3 polyfill 代码。

VDOM 存在的原因

  • DOM 性能开销过大:直接操作 DOM 的性能开销比较大,如果修改 DOM 树去执行更新,可能会引起回流和重绘,VDOM 通过维护一个 VDOM 树,通过 diff 和 patch 优化更新流程,计算出最小化更新操作,再一次性同步到真实 DOM 状态,减少 DOM 操作次数。
  • 开发人员关注如何编写代码:VDOM 给开发者提供声明式的 UI 编程模型,让开发人员可以把关注点从"如何编写改变 UI 的代码"转变为 “如何描述 UI 的最终状态”,让框架本身去负责 DOM 的更新操作。
  • 跨平台:VDOM 是平台无关的 JS 对象结构,小程序、web、uniapp 都可以利用 VDOM 抽象结构来让渲染器消费。
  • 细粒度可控和优化空间:key 可以帮助识别列表节点复用,避免重建节点,后续只需更新。vue3 中引入了静态节点优化,同时引入了异步更新队列 nextTick 来支持批量更新操作。

其中 diff 算法是 VDOM 中最核心的部分。

h 函数/createElement 函数

在 Vue 2 中,用户编写 template 模板语法,之后模板语法会被编译成 render 函数,render 函数内部会调用 createElement() 去渲染节点。

h() 函数是在编写函数式组件时用到的渲染函数。

// 创建带子元素的元素
h("div", { class: "container" }, [h("span", "Child 1"), h("span", "Child 2")]);

在 Vue 2 中,h() 是对 createElement() 的再封装:

// src/v3/h.ts
export function h(type: any, props?: any, children?: any) {
  if (!currentInstance) {
    return createElement(currentInstance!, type, props, children, 2, true)
  }
}

// src/shared/util.ts
export function isPrimitive(value: any): boolean {
  return (
    typeof value === 'string' ||
    typeof value === 'number' ||
    // $flow-disable-line
    typeof value === 'symbol' ||
    typeof value === 'boolean'
  )
}

// src/core/vdom/create-element.ts
export function createElement(
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  // 判断传入的 data 是否为数组还是原始值
  if (isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

export function _createElement(
  context: Component,
  tag?: string | Component | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // 1. 检查 data 是否为响应式对象,是的话不应该作为 vnode 的data对象使用(不应该使用)
  if (isDef(data) && isDef((data as any).__ob__)) {
    return createEmptyVNode()
  }
  // 2. 处理 :is 指令
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }

  // 3. 处理 :is 为 falsy 值的情况
  if (!tag) {
    return createEmptyVNode()
  }

  // 4. 处理 scoped slot(单个函数作为 children)
  if (isArray(children) && isFunction(children[0])) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }

  // 5. 规范化 children
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }

  // 6. 创建 VNode
  let vnode, ns
  // 字符串标签
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // 平台内置元素(div, span 等)
      // platform built-in elements
      vnode = new VNode(
        config.parsePlatformTagName(tag),
        data,
        children,
        undefined,
        undefined,
        context
      )
    } else if (
      (!data || !data.pre) &&
      isDef((Ctor = resolveAsset(context.$options, 'components', tag)))
    ) {
      // 组件
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // 未知元素或命名空间元素
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(tag, data, children, undefined, undefined, context)
    }
  } else {
    // 直接传入组件选项或构造函数
    // direct component options / constructor
    vnode = createComponent(tag as any, data, context, children)
  }
  // 7. 应用命名空间
  if (isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

VNode

VNode(Virtual Node)是用于描述 DOM 节点的 JavaScript 对象。

概念理解

VNode:虚拟节点,是 Vue 用来描述真实 DOM 节点的轻量级 JavaScript 对象,包含了节点的所有必要信息,但不包含实际的 DOM 操作。

VNode 通常包含以下属性:

  • 当前 VNode 的标签名或者组件
  • 属性和事件
  • 子 vnode 数组
  • 文本内容
  • 对应的真实 DOM 节点
  • key(唯一标识)
  • context(组件实例)
const vnode = {
  tag: "div", // 标签名或组件
  data: {
    // 属性、事件等
    attrs: { id: "app" },
    on: { click: handler },
  },
  children: [], // 子 VNode
  text: undefined, // 文本内容
  elm: undefined, // 对应的真实 DOM
  key: "unique-key", // 唯一标识
  context: vm, // 组件实例
  // ... 其他属性
};

Vue 2 的 VDOM 和 Diff 算法

当数据发生改变时,响应式属性的 setter 方法会调用 Dep.notify 通知所有订阅者 Watcher,订阅者就会调用 patch 给真实的 DOM 打补丁,更新相应的视图。

进入 patch 步骤的流程

在 Vue 中会有两个步骤会走到 patch:

  • 首次渲染mountComponent() → Watcher → updateComponent() → patch()
  • 数据更新setter → Watcher → updateComponent() → patch()

mountComponent() 中会执行 new Watcher() 操作。

Watcher 将 updateComponent() 作为回调函数传入,可以看到是调用 。_update(vm._render()) 获取新的 VNode 后执行更新操作。

// src/core/instance/lifecycle.ts
export function mountComponent(
  vm: Component,
  el: Element | null | undefined,
  hydrating?: boolean
): Component {
  vm.$el = el;
  if (!vm.$options.render) {
    // @ts-expect-error invalid type
    vm.$options.render = createEmptyVNode;
  }
  callHook(vm, "beforeMount");

  let updateComponent;

  // 核心关键,当组件更新时,会触发该方法!!!
  updateComponent = () => {
    vm._update(vm._render(), hydrating);
  };

  const watcherOptions: WatcherOptions = {
    before() {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, "beforeUpdate");
      }
    },
  };

  // watcher 初始化
  new Watcher(
    vm,
    updateComponent,
    noop,
    watcherOptions,
    true /* isRenderWatcher */
  );
  hydrating = false;

  // flush buffer for flush: "pre" watchers queued in setup()
  const preWatchers = vm._preWatchers;
  if (preWatchers) {
    for (let i = 0; i < preWatchers.length; i++) {
      preWatchers[i].run();
    }
  }

  // 挂载相关
  if (vm.$vnode == null) {
    vm._isMounted = true;
    callHook(vm, "mounted");
  }
  return vm;
}

_update() 的内部,就调用了 __patch__() 比对节点去完成更新。

// src/core/instance/lifecycle.ts
Vue.prototype._update = function (vnode) {
  const prevVNode = this._vnode;
  this._vnode = vnode;

  if (!prevVNode) {
    // 初次渲染
    this.$el = this.__patch__(this.$el, vnode);
  } else {
    // 更新
    this.$el = this.__patch__(prevVNode, vnode);
  }
};

__patch__() 来源于:

//src/platforms/web/runtime/index.ts
// 在 web 平台创建 patch 函数
Vue.prototype.__patch__ = inBrowser ? patch : noop;

// 	src/platforms/web/runtime/patch.ts
import * as nodeOps from "web/runtime/node-ops";
import { createPatchFunction } from "core/vdom/patch";
import baseModules from "core/vdom/modules/index";
import platformModules from "web/runtime/modules/index";

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules);
// 最终返回了 patch 函数
export const patch: Function = createPatchFunction({ nodeOps, modules });

patch:vnode 新旧节点更新的入口

patch 函数是 VNode 更新的入口,主要处理以下四种情况:

  1. 无新节点,旧节点销毁:没有新节点,直接触发旧节点的 destroy() 钩子,销毁 vnode。
  2. 无旧节点,新节点创建:没有旧节点,说明是页面刚开始初始化的时候,此时,根本不需要比较了,直接全是新建,所以只调用 createElm()
  3. 新旧节点一致,执行 patchVnode 更新 VNode:旧节点和新节点自身一样,通过 sameVnode() 判断节点是否一样,一样时,直接调用 patchVnode() 去处理这两个节点。
  4. 新旧节点不一致,销毁旧 VNode,插入新 VNode:旧节点和新节点自身不一样,当两个节点不一样的时候,直接创建新节点,删除旧节点。
//src/core/vdom/patch.ts
function patch(oldVnode, vnode, hydrating, removeOnly) {
  if (isUndef(vnode)) {
    // 没有新节点,直接执行 destroy 钩子函数
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode);
    return;
  }

  let isInitialPatch = false;
  const insertedVnodeQueue = [];

  if (isUndef(oldVnode)) {
    isInitialPatch = true;
    createElm(vnode, insertedVnodeQueue); // 没有旧节点,直接用新节点生成 DOM 元素
  } else {
    const isRealElement = isDef(oldVnode.nodeType);
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 判断旧节点和新节点自身一样,一致执行 patchVnode
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
    } else {
      // 否则直接销毁旧节点,根据新节点生成 DOM 元素
      if (isRealElement) {
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR);
          hydrating = true;
        }
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true);
            return oldVnode;
          }
        }
        oldVnode = emptyNodeAt(oldVnode);
      }
      return vnode.elm;
    }
  }
}

patchVnode:新旧同节点的内容更新过程

patchVnode 主要做了几个判断:

  • 新节点是否是文本节点:如果是,则直接更新 DOM 的文本内容为新节点的文本内容。
  • 新节点和旧节点如果都有子节点:则处理比较更新子节点,进入双端 diff 流程。
  • 只有新节点有子节点,旧节点没有:那么不用比较了,所有节点都是全新的,所以直接全部新建就好了,新建是指创建出所有新 DOM,并且添加进父节点。
  • 只有旧节点有子节点而新节点没有:说明更新后的页面,旧节点全部都不见了,那么要做的,就是把所有的旧节点删除,也就是直接把 DOM 删除。
function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  // 如果新旧节点一致,什么都不做
  if (oldVnode === vnode) {
    return;
  }
  
  // 假如已创建,克隆复用VNode
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // clone reused vnode
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  // 让 vnode.elm 引用到现在的真实 DOM,当 elm 修改时,vnode.elm 会同步变化
  const elm = (vnode.elm = oldVnode.elm);

  // 异步占位符
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue);
    } else {
      vnode.isAsyncPlaceholder = true;
    }
    return;
  }
  // 如果新旧都是静态节点,并且具有相同的 key
  // 当 vnode 是克隆节点或是 v-once 指令控制的节点时,只需要把 oldVnode.elm 和 oldVnode.child 都复制到 vnode 上
  // 也不用再有其他操作
  if (
    isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance;
    return;
  }

  let i;
  const data = vnode.data;
  if (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) {
    i(oldVnode, vnode);
  }

  const oldCh = oldVnode.children;
  const ch = vnode.children;
  if (isDef(data) && isPatchable(vnode)) {
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
    if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode);
  }
  // 如果 vnode 不是文本节点或者注释节点
  if (isUndef(vnode.text)) {
    // 并且都有子节点
    if (isDef(oldCh) && isDef(ch)) {
      // 并且子节点不完全一致,则调用 updateChildren
      if (oldCh !== ch)
        updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);

      // 如果只有新的 vnode 有子节点
    } else if (isDef(ch)) {
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");
      // elm 已经引用了老的 DOM 节点,在老的 DOM 节点上添加子节点
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);

      // 如果新 vnode 没有子节点,而旧 vnode 有子节点,直接删除老的 oldCh
    } else if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);

      // 如果老节点是文本节点
    } else if (isDef(oldVnode.text)) {
      nodeOps.setTextContent(elm, "");
    }

    // 如果新 vnode 和老 vnode 是文本节点或注释节点
    // 但是 vnode.text != oldVnode.text 时,只需要更新 vnode.elm 的文本内容就可以
  } else if (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text);
  }
  if (isDef(data)) {
    if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode);
  }
}

updateChildren:新旧 vnode 子节点的 diff 算法比较流程

通过比较新旧 vnode 子节点,就会通过双端 diff 来比较同层节点。

当节点一致时,就会递归调用 patchVnode() 进行更新。

Vue 2 通过四个指针(新旧节点的起始和结束位置)进行比较,尽可能复用节点:

  • 当新老 VNode 节点的 start 相同时,直接 patchVnode(),同时新老 VNode 节点的开始索引都加 1。
  • 当新老 VNode 节点的 end 相同时,同样直接 patchVnode(),同时新老 VNode 节点的结束索引都减 1。
  • 当老 VNode 节点的 start 和新 VNode 节点的 end 相同时,这时候在 patchVnode() 后,还需要将当前真实 DOM 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加 1,新 VNode 节点的结束索引减 1。
  • 当老 VNode 节点的 end 和新 VNode 节点的 start 相同时,这时候在 patchVnode() 后,还需要将当前真实 DOM 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减 1,新 VNode 节点的开始索引加 1。
  • 如果都不满足以上四种情形,那说明没有相同的节点可以复用,则会分为以下两种情况:
    • 从旧的 VNodekey 值,对应 index 序列为 value 值的哈希表中找到与 newStartVnode 一致 key 的旧的 VNode 节点,再进行 patchVnode(),同时将这个真实 DOM 移动到 oldStartVnode 对应的真实 DOM 的前面。
    • 调用 createElm() 创建一个新的 DOM 节点放到当前 newStartIdx 的位置。
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0 // 旧头索引
    let newStartIdx = 0 // 新头索引
    let oldEndIdx = oldCh.length - 1 // 旧尾索引
    let newEndIdx = newCh.length - 1 // 新尾索引
    let oldStartVnode = oldCh[0] // oldVnode的第一个child
    let oldEndVnode = oldCh[oldEndIdx] // oldVnode的最后一个child
    let newStartVnode = newCh[0] // newVnode的第一个child
    let newEndVnode = newCh[newEndIdx] // newVnode的最后一个child
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    // 如果oldStartVnode和oldEndVnode重合,并且新的也都重合了,证明diff完了,循环结束
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 如果oldVnode的第一个child不存在
      if (isUndef(oldStartVnode)) {
        // oldStart索引右移
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left

      // 如果oldVnode的最后一个child不存在
      } else if (isUndef(oldEndVnode)) {
        // oldEnd索引左移
        oldEndVnode = oldCh[--oldEndIdx]

      // oldStartVnode 和 newStartVnode 是同一个节点
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // patch oldStartVnode 和 newStartVnode,索引左移,继续循环
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]

      // oldEndVnode 和 newEndVnode 是同一个节点
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // patch oldEndVnode 和 newEndVnode,索引右移,继续循环
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]

      // oldStartVnode 和 newEndVnode 是同一个节点
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // patch oldStartVnode 和 newEndVnode
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        // 如果 removeOnly 是 false,则将 oldStartVnode.elm 移动到 oldEndVnode.elm 之后
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        // oldStart 索引右移,newEnd 索引左移
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]

      // 如果 oldEndVnode 和 newStartVnode 是同一个节点
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        // patch oldEndVnode 和 newStartVnode
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        // 如果 removeOnly 是 false,则将 oldEndVnode.elm 移动到 oldStartVnode.elm 之前
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        // oldEnd 索引左移,newStart 索引右移
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]

      // 如果都不匹配
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

        // 尝试在 oldChildren 中寻找和 newStartVnode 的具有相同的 key 的 Vnode
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

        // 如果未找到,说明 newStartVnode 是一个新的节点
        if (isUndef(idxInOld)) { // New element
          // 创建一个新 Vnode
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)

        // 如果找到了和 newStartVnode 具有相同的 key 的 Vnode,叫 vnodeToMove
        } else {
          vnodeToMove = oldCh[idxInOld]
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !vnodeToMove) {
            warn(
              'It seems there are duplicate keys that is causing an update error. ' +
              'Make sure each v-for item has a unique key.'
            )
          }

          // 比较两个具有相同的 key 的新节点是否是同一个节点
          // 不设 key,newCh 和 oldCh 只会进行头尾两端的相互比较,设 key 后,除了头尾两端的比较外,还会从用 key 生成的对象 oldKeyToIdx 中查找匹配的节点,所以为节点设置 key 可以更高效的利用 DOM
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // patch vnodeToMove 和 newStartVnode
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
            // 清除
            oldCh[idxInOld] = undefined
            // 如果 removeOnly 是 false,则将找到的和 newStartVnode 具有相同的 key 的 Vnode,叫 vnodeToMove.elm
            // 移动到 oldStartVnode.elm 之前
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)

          // 如果 key 相同,但是节点不相同,则创建一个新的节点
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
          }
        }

        // 右移
        newStartVnode = newCh[++newStartIdx]
      }
    }

四种规则的具体原因和作用

假设:

  • 旧列表(oldVnodes):指针 oldStartIdx(开始索引)、oldEndIdx(结束索引)。
  • 新列表(newVnodes):指针 newStartIdx(开始索引)、newEndIdx(结束索引)。
  • 节点复用的判断:key 相同(且标签名等基础属性一致)。

1. 当 oldStartVnode.key === newStartVnode.key(新旧开始节点相同)

  • 操作:直接 patchVnode()(复用节点,更新内容),然后 oldStartIdx++newStartIdx++
  • 原因:这是最理想的情况 —— 新旧列表头部节点完全一致,无需移动 DOM,直接更新内容即可。例如:旧列表 [A, B, C],新列表 [A, B, D],首节点 A 相同,直接复用并后移指针。

2. 当 oldEndVnode.key === newEndVnode.key(新旧结束节点相同)

  • 操作:直接 patchVnode(),然后 oldEndIdx--newEndIdx--
  • 原因:与规则 1 对称,尾部节点相同,无需移动,直接更新内容。例如:旧列表 [A, B, C],新列表 [D, B, C],尾节点 C 相同,复用并前移指针

3. 当 oldStartVnode.key === newEndVnode.key(旧开始节点 = 新结束节点)

  • 操作patchVnode() 后,将旧开始节点对应的真实 DOM 移动到旧结束节点的后面,然后 oldStartIdx++newEndIdx--
  • 原因:这种情况说明该节点在新列表中被 “移到了尾部”。通过一次移动操作(而非删除再新增)复用节点,减少 DOM 操作成本。例如:旧列表 [A, B, C],新列表 [B, C, A],旧首 A 与新尾 A 匹配,此时只需把 A 移动到 C 后面,即可符合新列表结构。

4. 当 oldEndVnode.key === newStartVnode.key(旧结束节点 = 新开始节点)

  • 操作patchVnode() 后,将旧结束节点对应的真实 DOM 移动到旧开始节点的前面,然后 oldEndIdx--newStartIdx++
  • 原因:与规则 3 对称,该节点在新列表中被 “移到了头部”,通过一次移动操作复用节点。例如:旧列表 [A, B, C],新列表 [C, A, B],旧尾 C 与新首 C 匹配,把 C 移动到 A 前面即可。

双端 diff 算法的优势

  1. 优先处理 “无需移动” 的节点:规则 1 和 2 直接复用头部 / 尾部相同的节点,避免无效操作。
  2. 用最少移动解决 “顺序调整”:规则 3 和 4 针对节点位置互换的场景,通过一次移动即可完成位置修正,比 “删除 + 新增” 高效得多(DOM 移动的成本远低于删除和创建)。
  3. 减少遍历范围:每处理完一个节点,指针就向中间收缩,逐步缩小对比范围,避免全量遍历(时间复杂度从 O(n²) 优化为 O(n))。

思考

1. Vue2 中所指的同层节点比较,是指哪一块?

同层节点比较,发生在同个新旧 vnode 节点的子节点 children 比较阶段,即双端 diff 阶段,不会发生跨层级节点的比较。

2. 为何要采用双端 diff?

如果是正常的子节点 diff,我们需要遍历新节点,然后再嵌套遍历旧节点,即 Vue 设计与实现中讲到的简单 diff 流程,时间复杂度是 O(n²),使用双端 diff,首尾指针比对,可以把实现复杂度压缩到 O(n)。

总结

  • VDOM 存在的价值:通过维护虚拟 DOM 树,减少直接操作真实 DOM 的性能开销,提供声明式编程模型,支持跨平台渲染。
  • Diff 算法流程:数据变化触发 setter → Watcher → updateComponent() → patch() → patchVnode() → updateChildren(),通过双端 diff 算法高效比较和更新节点。
  • 双端 diff 算法:使用四个指针(新旧节点的起始和结束位置)进行比较,通过四种匹配规则优先处理无需移动的节点,用最少移动解决顺序调整,时间复杂度从 O(n²) 优化为 O(n)。
  • key 复用节点:key 属性帮助识别相同节点并复用,避免不必要的重建。

参考内容

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值