(八)Diff算法 面试题详解(2024)

138 阅读10分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 8 天,点击查看活动详情

Virtual DOM

背景信息总结

  • DOM操作非常耗时耗性能(且现代前端框架要求不手动操作DOM,可以大大提高开发效率)---- 因为会引起页面的回流或重绘
  • JS的执行很快(浏览器 V8 引擎的出现更加加快了JS的计算速度)
  • 实现更好的跨平台(如浏览器端渲染、 Node.js 实现 SSR 服务端渲染、安卓/IOS、小程序等)

推出 ==> Virtual DOM(虚拟 DOM)

将真实DOM抽象成一个以JS对象为节点的虚拟DOM树,DOM的变化过程中不需要操作真实DOM,只需要操作JS 对象(虚拟DOM),再与真实DOM比较差异,最后只对变化的DOM进行操作来更新真实DOM,减少了DOM操作,大大提升了性能。

VDOM(Virtual DOM 简写)是什么?

VDOM 是个啥?:用 JS 对象模拟的 DOM 结构(树形结构)

一个用JS对象表示DOM结构的简单例子:

<!-- DOM 结构 -->
<div id="cc" class="bigCc">
  <p>DOM</p>
  <ul style="font-size: 24px">
    <li>huohuoit</li>
  </ul>
</div>
    <!-- JS 对象表示 -->
    {
        tag: 'div',
        data: {
            id: 'cc',
            className: 'bigCc'
        },
        children: [
            {
                tag: 'p'
                text: 'DOM'
            },
            {
                tag: 'ul'
                data: { style: 'font-size: 20px' }
                children: [
                    {
                        tag: 'li',
                        text: 'huohuoit'
                    }
                ]
            }
        ]
    }

它的优势

  • 抽象了原本的渲染过程,实现了跨平台的能力
  • diff算法,减少JS操作真实DOM的带来的性能消耗

实现原理

JS对象 模拟DOM结构,然后比较出差异对象,再去操作DOM

来看看整体的流程:

  1. 虚拟DOM发生变化时,比较两颗DOM树的差异,生成差异对象
  2. 根据差异对象更新真实DOM
  3. 把虚拟DOM转换成真实DOM插入页面中
  4. JS对象模拟真实DOM结构

Vue 对 VDOM  的源码实现

1、描述

关注:使用 VNode 这个 Class 来描述 VDOM(VNode 由 Vue.js 的 _createElement 方法创建)

这里先关注一些主要描述点:

  • elmVNode对应的真实DOM节点
  • keyVNode标记,diff过程可以提高效率
  • dataVNode上的class/attribute/style/绑定事件等数据
  • childrenVNode的子节点
  • text :文本属性
  • tagVNode的标签属性

再看看VNode的创建过程:

  1. 初始化Vuenew Vue( ),调用this.\_init方法
  2. Vue实例挂载:通过$mount方法挂载DOM => 调用mountComponent方法(实例化一个渲染Watcher,并回调updateComponent方法) =>  调用mountComponent方法(实例化一个渲染Watcher,并回调updateComponent方法)在updateComponent方法中调用vm.\_render方法先生成VNode,最终调用vm.\_update更新DOM
  3. 创建VNode:使用\_render方法(\_render方法把实例渲染成VNode,这里调用了\_createElement方法(即h函数))

2、使用h函数创建Virtual DOM Tree

3、diff算法(性能关键):比较新旧Virtual DOM Tree找出差异并更新

4、createElm函数将VNode转化为真实DOM

Diff 算法

简述理解

理解:比对新旧VDOM的变化,然后将变化的部分更新到视图(真实DOM)上。 一边比较新旧 VDOM,一边改变真实 Dom 节点

diff 的比较方式

比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较(时间复杂度 O(n))

diff 流程理解(Vue2.0 源码)

1、开始

数据发生改变时,被订阅者的setter会调用Dep.notify通知所有订阅者Watcher,订阅者调用patch给真实DOM打补丁,更新视图

使用patch (oldVNode,VNode) 比较新旧节点差异:

function patch(oldVnode, vnode) {
  // ...
  if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode);
  } else {
    const oEl = oldVnode.el; // 当前 oldVnode 对应的真实元素节点
    let parentEle = api.parentNode(oEl); // 父元素
    createEle(vnode); // 根据 Vnode 生成新元素
    if (parentEle !== null) {
      api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)); // 将新元素添加进父元素
      api.removeChild(parentEle, oldVnode.el); // 移除以前的旧元素节点
      oldVnode = null;
    }
  }
  // ...
  return vnode;
}

如何比较呢?

// 比较函数
function sameVnode(a, b) {
  return (
    a.key === b.key && // key 值
    a.tag === b.tag && // 标签名
    a.isComment === b.isComment && // 是否为注释节点
    // 是否都定义了 data,data 包含一些具体信息,例如 onclick , style
    isDef(a.data) === isDef(b.data) &&
    sameInputType(a, b) // 当标签是<input>的时候,type 必须相同
  );
}

针对比较出的结果分别做处理:

  • 不同,则用新VNode更新真实DOM,并替换旧节点oldVNode(如果子节点不一样就说明Vnode完全被改变了,就可以直接替换oldVnode
  • 相同,则继续比较子节点/文本节点等情况,做出相应的处理
function patchVnode(oldVnode, vnode) {
  // 当前 oldVnode 和 Vnode 对应的真实 DOM 节点
  const el = (vnode.el = oldVnode.el);
  let i,
    oldCh = oldVnode.children,
    ch = vnode.children;
  // 判断 Vnode 和 oldVnode 是否指向同一个对象,如果是,那么直接 return
  if (oldVnode === vnode) return;
  // 都有文本节点且不相等,那么将 el 的文本节点设置为 Vnode 的文本节点
  if (
    oldVnode.text !== null &&
    vnode.text !== null &&
    oldVnode.text !== vnode.text
  ) {
    api.setTextContent(el, vnode.text);
  } else {
    updateEle(el, vnode, oldVnode);
    if (oldCh && ch && oldCh !== ch) {
      // 两者都有子节点,则执行 updateChildren 函数比较子节点
      updateChildren(el, oldCh, ch);
    } else if (ch) {
      // oldVnode 没有子节点而 Vnode 有,则将 Vnode 的子节点真实化之后添加到 el
      createEle(vnode);
    } else if (oldCh) {
      // oldVnode 有子节点而 Vnode 没有,则删除 el 的子节点
      api.removeChildren(el);
    }
  }
}

2、updateChildren 函数(diff 算法核心)

注意到updateChildren函数 ,来看看它做了什么

它是怎么做的?

遍历Vnode的子节点vCh(源码中为参数newCh)和oldVnode的子节点oldCh,进行比较和更新

先来看看相关源码(Vue2.0)

// Vue2.0 diff 算法核心的理解
function updateChildren(
  parentElm,
  oldCh,
  newCh,
  insertedVnodeQueue,
  removeOnly
) {
  let oldStartIdx = 0;
  let newStartIdx = 0;
  let oldEndIdx = oldCh.length - 1; // 以下简化一下
  let oldStartVnode = oldCh[0]; // oldS:子节点 oldCh 的头部指针
  let oldEndVnode = oldCh[oldEndIdx]; // oldE:子节点 oldCh 的尾部指针
  let newEndIdx = newCh.length - 1;
  let newStartVnode = newCh[0]; // S:子节点 vCh 的头部指针
  let newEndVnode = newCh[newEndIdx]; // E:子节点 vCh 的尾部指针
  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;

  if (process.env.NODE_ENV !== "production") {
    checkDuplicateKeys(newCh);
  }
  // 遍历 Vnode 的子节点 vCh(这里记为 newCh 方便理解比较) 和 oldVnode 的子节点 oldCh,进行比较和更新
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 循环条件:oldS <= oldE && S <= E
    // 1、检查 oldS oldE 非空
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx]; // oldS 向左移动
    } else if (isUndef(oldEndVnode)) {
      // oldE 向右移动
      oldEndVnode = oldCh[--oldEndIdx];
    }
    // 2、两两比较 oldS、oldE、S、E(4种情况)
    //   2.1 oldS = S
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(
        oldStartVnode,
        newStartVnode,
        insertedVnodeQueue,
        newCh,
        newStartIdx
      ); // 执行 patch,不需要移动 dom
      oldStartVnode = oldCh[++oldStartIdx]; // 匹配上的两个指针都向中间移动(向左)
      newStartVnode = newCh[++newStartIdx];
    }
    //   2.2 oldE = E
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(
        oldEndVnode,
        newEndVnode,
        insertedVnodeQueue,
        newCh,
        newEndIdx
      ); // 执行 patch,不需要移动 dom
      oldEndVnode = oldCh[--oldEndIdx]; // 匹配上的两个指针向中间移动(向右)
      newEndVnode = newCh[--newEndIdx];
    }
    //   2.3 oldS = E
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      // Vnode moved right
      patchVnode(
        oldStartVnode,
        newEndVnode,
        insertedVnodeQueue,
        newCh,
        newEndIdx
      );
      // 把获得更新后的 (oldS/E) 的 DOM 右移,移动到oldE 对应的 DOM 的右边
      // 注意一下这里的 DOM 移动
      // 2.3.1 oldS = E,显然是 vnode 右移了
      // 2.3.2 第一轮 while 循环:移到 oldE(oldEndVnode.elm)右边就是最右边
      // 2.3.3 非第一轮 while 循环:比较过程是两头向中间移动,两头比较过的位置(也即真实DOM位置)是正确的,这次 DOM 是移动到 oldE(oldEndVnode.elm)右边
      canMove &&
        nodeOps.insertBefore(
          parentElm,
          oldStartVnode.elm,
          nodeOps.nextSibling(oldEndVnode.elm)
        );
      oldStartVnode = oldCh[++oldStartIdx]; // 匹配上的两个指针向中间移动
      newEndVnode = newCh[--newEndIdx];
    }
    //   2.4 oldE = S
    else if (sameVnode(oldEndVnode, newStartVnode)) {
      // Vnode moved left
      patchVnode(
        oldEndVnode,
        newStartVnode,
        insertedVnodeQueue,
        newCh,
        newStartIdx
      );
      // 同上,左移更新后的 DOM
      canMove &&
        nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    }
    // 3、上面四种情况都不存在,通过 key 值查找对应 vnode 进行对比
    else {
      // 3.1 oldKeyToIdx不存在,创建 oldCh 中 vnode 的 key 到 index 的映射(即根据oldCh的key生成一张hash表),方便我们之后通过 key 去拿下标
      if (isUndef(oldKeyToIdx))
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      // 3.2 拿到下标,用 S 的 key 与 hash表做匹配
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
      // 3.2.1 下标不存在(匹配失败),说明 S(newStartVnode) 是原来没有(新)的 vnode
      if (isUndef(idxInOld)) {
        // 为 S(newStartVnode) 创建 DOM节点 并插入到 oldS(oldStartVnode.elm) 的前面
        createElm(
          newStartVnode,
          insertedVnodeQueue,
          parentElm,
          oldStartVnode.elm,
          false,
          newCh,
          newStartIdx
        );
      }
      // 3.2.2 下标存在(匹配成功),说明 oldCh 中有相同 key 的 vnode
      else {
        vnodeToMove = oldCh[idxInOld];
        // (1) type 相同(且key相同),说明是相同的 vnode,执行 patch
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(
            vnodeToMove,
            newStartVnode,
            insertedVnodeQueue,
            newCh,
            newStartIdx
          );
          oldCh[idxInOld] = undefined; // 被匹配oldCh中的节点置为 undefined
          canMove &&
            nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm); // 直接将S生成新的节点插入真实DOM
        } else {
          // (2) type 不同(且key相同),作为新元素处理。为 S(newStartVnode) 创建 DOM节点 并插入到 oldS(oldStartVnode.elm) 的前面
          createElm(
            newStartVnode,
            insertedVnodeQueue,
            parentElm,
            oldStartVnode.elm,
            false,
            newCh,
            newStartIdx
          );
        }
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }
  // 比较的过程中,指针会往中间靠,直到达到匹配完成条件(即退出循环条件)oldS <= oldE && S <= E
  if (oldStartIdx > oldEndIdx) {
    // oldS > oldE:oldCh 先遍历完
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
    addVnodes(
      parentElm,
      refElm,
      newCh,
      newStartIdx,
      newEndIdx,
      insertedVnodeQueue
    ); // 将多余的 vCh 节点根据 index 添加到 DOM 中去
  } else if (newStartIdx > newEndIdx) {
    // S > E:vCh 先遍历完
    removeVnodes(oldCh, oldStartIdx, oldEndIdx); // 在真实 DOM 中将未遍历(区间为[oldS, oldE])的多余节点删掉
  }
}

详细的比较更新过程

代码有点多,我们一步步来理解它...

首先在 updateChildren 函数的开始定义了一些辅助变量,这里我将几个重点变量名简化表示:

  • oldCh:旧虚拟DOM 的子节点
  • vCh:新虚拟DOM的子节点
  • oldS:子节点oldCh的头部指针
  • oldE:子节点oldCh的尾部指针
  • S:子节点vCh的头部指针
  • E:子节点vCh的尾部指针

我们通过上面的头尾指针遍历Vnode的子节点vCh(源码中为newCh)  和oldVnode的子节点oldCh,进行两两比较和更新

这里头尾指针一共有4 种比较匹配方式(只要其中两个能匹配上,真实 Dom 中的相应节点就移到 Vnode 相应的位置)

第一、二种:头尾对应匹配(执行 patch,不需要移动 dom)

  • oldS = S:匹配上的两个指针向中间移动
  • oldE = E:匹配上的两个指针向中间移动

第三、四种:头尾交叉匹配

  • oldS = E:把获得更新后的 (oldS/E) 的DOM右移,移动到oldE对应的DOM的右边。匹配上的两个指针向中间移动
  • oldE = S:同上,左移更新后的DOM

这里关于DOM移动注意点(oldS = E 为例)

  • oldS = E,显然是vnode右移了
  • 第一轮while循环:移到oldEoldEndVnode.elm)右边就是最右边
  • 非第一轮while循环:比较过程是两头向中间移动,两头比较过的位置(也即真实DOM位置)是正确的,这次DOM是移动到oldEoldEndVnode.elm)右边

比较的过程中,指针会往中间靠,直到达到匹配完成条件(退出循环)

  • oldS > oldEoldCh先遍历完,将多余的vCh节点根据index添加到DOM中去
  • S > EvCh先遍历完,在真实DOM中将未遍历(区间为[oldS, oldE])的多余节点删掉

如果不匹配以上 4 种比较方式,如果设置了 key,就会用 key 进行比较

情况一:oldCh 和 vCh   都存在 key

根据oldChkey生成一张hash表,用Skeyhash表做匹配。匹配成功就判断S和匹配的旧节点是否为相同节点。同时被匹配oldCh中的节点置为undefined

  • 是相同节点,则直接将S生成新的节点插入真实DOM
  • 不是相同节点,则将S生成对应的节点插入到DOM中对应的oldS的前面的位置(作新元素处理)

情况二:不存在 key

直接为S创建DOM节点 并插入到oldS的前面的位置

note1:为什么 v-for 的时候需要设置 key ?

如果没有key那么就只会做四种匹配,匹配不上就直接创建新节点插入DOM,这样指针中间有可复用的节点都不能被复用了

note2:Vue3.0 对这里的优化

如果都没有key,新的节点会到旧的children队列里通过samenode对比剩下的节点看看是否可以有重用的节点。不是直接插入