Vue3的diff算法实现页面更新

前言

在前一篇文章学习了Vue3的createApp和h函数实现,但是只出了vnode渲染成真实dom,这里再来介绍一下dom的更新操作。

dom更新

createApp和h函数实现的我们知道dom的挂载和更新主要是在执行effect方法,instance.isMounted判断元素是否被挂载,挂载过就执行更新逻辑,未挂载执行创建逻辑

  • 更新dom时,先取到旧的vnode
  • 执行render,获得新的vnode
  • patch方法 执行,传入新旧vnode,diff开始

setupRenderEffect方法

js 复制代码
const setupRenderEffect = (instance, contanier) => {
    effect(() => {
      if (!instance.isMounted) {
        // 创建dom
        const proxy = instance.proxy;
        const subTree = instance.subTree = instance.render.call(proxy, proxy);
        patch(null, subTree, contanier)
        instance.isMounted = true
      } else {
        // 更新dom
        const proxy = instance.proxy;
        const prevTree = instance.subTree; // 记录旧的vnode
        // 执行render方法,获取到新的vnode
        const subTree = instance.subTree = instance.render.call(proxy, proxy);
        // patch方法比较新旧vnode,diff开始的地方
        patch(prevTree, subTree, contanier)
      }
    })
  }

patch方法

  • 新旧的vnode存在,并且vnode标签不同,删除旧的元素,创建新的元素
  • 新旧的vnode存在,标签相同,继续processElement方法,下一步的diff
  • 旧的不存在,直接创建新的元素
js 复制代码
  // 判断元素是否相同
  const isSameVnode = (vnode1, vnode2) => {
    return vnode1.type === vnode2.type && vnode1.key === vnode2.key;
  }
  
  // 移除vnode对应的真实dom
  const unmount = (vnode) => {
    const parent = vnode.el.parentNode
    if (parent) {
      parent.removeChild(vnode.el);
    }
  }
  
  const patch = (n1, n2, contanier, ancher = null) => {
    // 旧的vnode存在,新
    if (n1 && !isSameVnode(n1, n2)) {
      // 元素不同,直接删除,下面再创建新的
      unmount(n1);
      n1 = null;
    }

    const { shapeFlag, type } = n2;
    if (type === TEXT) {
      processText(n1, n2, contanier)
    } else if (shapeFlag & ShapeFlags.ELEMENT) {
      processElement(n1, n2, contanier, ancher)
    } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
      processComponent(n1, n2, contanier)
    }
  }

processElement方法

相同标签的文本可能不同,可以通过patchChildren更新标签文本;

如果相同标签的还有子标签,就是递归的patch过程

  • 新旧vnode都存在,再去patchChildren,进一步diff
js 复制代码
  const processElement = (n1, n2, contanier, ancher) => {
    if (n1 == null) {
      mountElement(n2, contanier, ancher)
    } else {
      // 更新元素
      patchElement(n1, n2, contanier, ancher);
    }
  }
  
  const patchElement = (n1, n2, contanier) => {
    let el = n2.el = n1.el;
    const oldProps = n1.props || {};
    const newProps = n2.props || {};
    // 省略比对属性...
    
    // 比对儿子
    patchChildren(n1, n2, el);
  }

patchChildren方法

  • 新旧vnode的children是文本,直接文本覆盖
  • 新的vnode的children是文本,旧的children是数组,直接文本覆盖
  • 新的vnode的children是数组,旧的children是文本,删除文本,新的children挂载
  • 新旧vnode的children都是数组,进一步**patchKeyChildren方法,diff核心**
js 复制代码
const patchChildren = (n1, n2, el) => {
    const c1 = n1.children;
    const c2 = n2.children;
    const prevShapeFlage = n1.shapeFlag;
    const newShapeFlage = n2.shapeFlag;
    if (newShapeFlage & ShapeFlags.TEXT_CHILDREN) {
      el.textContent = c2;
    } else {
      // 新旧都是数组
      if (prevShapeFlage & ShapeFlags.ARRAY_CHIDLREN) {
        patchKeyChildren(c1, c2, el)
      } else { // 旧的是文本
        // 删除文本
        el.textContent = '';
        mountChildren(el, c2)
      }
    }
  }
  
  const mountChildren = (el, children) => {
    for (const child of children) {
      let n2 = child;
      // 不是vnode生成vnode,具体实现参考上一篇文章
      if (!child._v_isVnode) {
        n2 = createVnode(TEXT, null, child)
      }
      patch(null, n2, el);
    }
  }

patchKeyChildren方法

新旧vnode的children都是数组时,分三种方式进行逐步diff比较,分别是1.新旧子vnode从前往后开始比较;2.新旧子vnode从后往前开始比较;3.剩余的新旧子vnode点建立索引,删除旧的,增加新的,新旧都存在,通过最长递增子序列,计算出做少移动dom的步骤

从前往后比较

新旧childVnode相同,继续递归patch;新旧childVnode不同,跳出循环

js 复制代码
// c1,c2对应的旧children和新的children
let i = 0;
let e1 = c1.length - 1;
let e2 = c2.length - 1;
while (i <= e1 && i <= e2) {
  const n1 = c1[i];
  const n2 = c2[i];
  if (isSameVnode(n1, n2)) {
    patch(n1, n2, el)
  } else {
    break
  }
  i++;
    }

从后往前比较

新旧childVnode相同,继续递归patch;新旧childVnode不同,跳出循环

js 复制代码
// c1,c2对应的旧children和新的children
while (i <= e1 && i <= e2) {
  const n1 = c1[e1];
  const n2 = c2[e2];
  if (isSameVnode(n1, n2)) {
    patch(n1, n2, el)
  } else {
    break
  }
  e1--;
  e2--;
}

删除不存在,增加新的vnode

根据从前往后的比较或者从后往前的vnode比较,删除多余的vnode,新增不存在的vnode

新增

新的比旧的多,新增,i > e1

删除

新的比旧的少

js 复制代码
// 旧的子vnode比新的子vnode少
if (i > e1) {
  const nextProps = e2 + 1;
  // 前追加
  const ancher = nextProps < c2.length ? c2[nextProps].el : null;
  while (i <= e2) {
    patch(null, c2[i++], el, ancher)
  }
} else if (i > e2) {
  // 旧的比新的多
  while (i <= e1) {
    unmount(c1[i++])
  }
} 

首尾不相等,建立索引

  • 计算出乱序的个数,创建数组,用于存储索引,初始值为0
  • 创建map对象,记录vnode的key和新vnode索引之间的关系
  • 循环旧的vode,发现在map对象上找不到索引,删除旧vnode,否则执行patch,同时将旧的索引位置记录在创建的数组上
  • 最后执行完上一步后,会得到一个旧的子vnode的位置的数组
  • 通过getSequence方法,传入上一步的数组,得到一个最长递增子序列;最长递增子序列中的元素是可以不移动位置的,这样保证了dom的最少操作次数。实现细节看另一篇文章
  • 最后,为了保证正确顺序,从后往前遍历,遇到数组旧vnode值为0,就新建vnode;遇到索引在最长递增子序列里面,无需处理,否则移动元素到对应
js 复制代码
let s1 = i;
let s2 = i;
const toBePathced = e2 - s2 + 1; // 乱序的个数
const newIndexToPatchMap = Array(toBePathced).fill(0);

// 建立起新的vnode的key和索引
const keyIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
    const childVnode = c2[i];
    keyIndexMap.set(childVnode.key, i);
}

// 处理旧的dom列表
for (let i = s1; i <= e1; i++) {
    const oldChildVnode = c1[i];
    let newIndex = keyIndexMap.get(oldChildVnode.key);
    // 删除不存在元素
    if (newIndex === undefined) {
      unmount(oldChildVnode)
    } else {
      // 新旧关系
      newIndexToPatchMap[newIndex - s2] = i + 1;
      patch(oldChildVnode, c2[newIndex], el)
    }
}

const increasingNewIndexSequence = getSequence(newIndexToPatchMap);

let j = increasingNewIndexSequence.length - 1;
// 移动节点,添加新增的元素,从后往前开始
for (let i = toBePathced - 1; i >= 0; i--) {
    let curruentIndex = i + s2; // 新的索引
    let child = c2[curruentIndex]; // 新的vnode
    // 新元素后一个位置作为参考
    let ancher = curruentIndex + 1 < c2.length ? c2[curruentIndex + 1].el : null
    if (newIndexToPatchMap[i] === 0) {
      // 新增元素
      patch(null, child, el, ancher)
    } else {
      // 元素位置调整
      if (i !== increasingNewIndexSequence[j]) {
        el.insertBefore(child.el, ancher)
      } else {
        j--;
      }
    }
}

完整patchKeyChildren代码

js 复制代码
  // 儿子都是数组
  const patchKeyChildren = (c1, c2, el) => {
    let i = 0;
    let e1 = c1.length - 1;
    let e2 = c2.length - 1;
    while (i <= e1 && i <= e2) {
      const n1 = c1[i];
      const n2 = c2[i];
      if (isSameVnode(n1, n2)) {
        patch(n1, n2, el)
      } else {
        break
      }
      i++;
    }
    
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1];
      const n2 = c2[e2];
      if (isSameVnode(n1, n2)) {
        patch(n1, n2, el)
      } else {
        break
      }
      e1--;
      e2--;
    }

    // 旧少,新多 
    if (i > e1) {
      const nextProps = e2 + 1;
      // 追加
      const ancher = nextProps < c2.length ? c2[nextProps].el : null;
      while (i <= e2) {
        patch(null, c2[i++], el, ancher)
      }
    } else if (i > e2) {
      // 旧的比新的多
      while (i <= e1) {
        unmount(c1[i++])
      }
    } else {
      let s1 = i;
      let s2 = i;

      const toBePathced = e2 - s2 + 1; // 乱序的个数
      const newIndexToPatchMap = Array(toBePathced).fill(0);

      // key记录的新元素的索引
      const keyIndexMap = new Map();
      for (let i = s2; i <= e2; i++) {
        const childVnode = c2[i];
        keyIndexMap.set(childVnode.key, i);
      }

      // 处理旧的dom列表
      for (let i = s1; i <= e1; i++) {
        const oldChildVnode = c1[i];
        let newIndex = keyIndexMap.get(oldChildVnode.key);
        // 删除不存在元素
        if (newIndex === undefined) {
          unmount(oldChildVnode)
        } else {
          // 新旧关系
          newIndexToPatchMap[newIndex - s2] = i + 1;
          patch(oldChildVnode, c2[newIndex], el)
        }
      }
      
      const increasingNewIndexSequence = getSequence(newIndexToPatchMap);
      let j = increasingNewIndexSequence.length - 1;
      // 移动节点,添加新增的元素,从后往前开始
      for (let i = toBePathced - 1; i >= 0; i--) {
        let curruentIndex = i + s2; // 新的索引
        let child = c2[curruentIndex]; // 新的vnode
        // 新元素后一个位置作为参考
        let ancher = curruentIndex + 1 < c2.length ? c2[curruentIndex + 1].el : null
        if (newIndexToPatchMap[i] === 0) {
          // 新增元素
          patch(null, child, el, ancher)
        } else {
          // 元素位置调整
          if (i !== increasingNewIndexSequence[j]) {
            el.insertBefore(child.el, ancher)
          } else {
            j--;
          }
        }
      }
    }
  }

  // 最大递增子序列
  const getSequence = (arr) => {
    const p = [] // 修正结果的数组
    const result = [0];
    const len = arr.length;
    let i, j, start, end, mid;
    for (i = 0; i < len; i++) {
      const arrI = arr[i]; // 第一个元素的索引
      if (arrI !== 0) {
        j = result[result.length - 1];
        if (arr[j] < arrI) {
          p[i] = j; // push时,最后一个元素是前一项
          result.push(i)
          continue
        }

        // 二分法
        start = 0
        end = result.length - 1
        while (start < end) {
          mid = start
          if (arr[mid] < arrI) {
            start = mid + 1
          } else {
            end = mid;
          }
        }

        // 替换result的第start个元素,它的值可能大于现有值
        if (arrI < arr[result[start]]) {
          if (start > 0) {
            p[i] = result[start - 1]
          }
          result[start] = i;
        }
      }
    }

    // 通过数组p,修正最长递增子序列对应的值
    start = result.length
    end = result[start - 1]
    while (start-- > 0) {
      result[start] = end
      end = p[end];
    }

    return result
  }

最后

终于完成了vue3的diff的简单实现,更多细节还是需要大家阅读源码,不过对应Vue3面试,还是会有点作用。感兴趣的朋友还可以阅读前面2遍文章,对Vue3有个全面了解。

Vue3响应式reactive、computed等api的简单实现(1)

Vue3的createApp和h函数实现vnode到页面(2)

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui