最近看了 vue-design 项目,写的很棒,文中 diff 的思路全是来自该项目,这里只是做一个学习的记录。作者(【掘金地址】hcysunyang)已经是 vue3 的 contributor 了。值得学习的一位 前端人。再次感谢👏👏👏

我是一名前端的小学生。行文中对某些设计原理理解有误十分欢迎大家讨论指正😁😁😁,谢谢啦!当然有好的建议也谢谢提出来
image.png
(玩笑)

当前前端框架都有 diff算法,作用主要是处理比对虚拟Dom(Vnode),最大化复用旧节点,最后渲染为真实 Dom,最大化降低节点创建、删除的的开销。

老样子,本来写一篇文章的。东西越写越多就裂开了🤣
在这里插入图片描述

【第一篇】和面试官聊聊Diff___React(本文)
【第二篇】和面试官聊聊Diff___vue2
【第三篇】和面试官聊聊Diff___Vue3

好了,不说废话了。我们开始吧。


前言

vue 、react中都是组件构成,每个组件又是标签元素构成。

我主要技术栈是Vue。稍微说一下vue
vue编译会涉及到几个过程 【参考剖析 Vue.js 内部运行机制,推荐看看】

parse(解析) => optimize(优化) => generate(节点生成)

这个diff算法是处在optimize(优化)阶段的一个操作。

另外,各个框架的节点比对都是同级比对,即同一层级的相应子节点比对。
image.png

本文注重的是patch过程,具体的细节和边界就没有考虑。

==另 外 注 意==

  • 三篇文章 diff 的讲解,为了方便展示 节点复用, 用了 children 保存内容,实际上这是不合理的,因为children不同还会递归补丁(patch)
  • diff也不是vue optimize的全部,只是其中一部分,例如compile时确定节点类型,不同类型 不同的 mount/patch 处理方式等等。

React_diff

基本思路

先说思路,
现在比如说由若干个新老节点(preNodes / nextNodes )。

  • 先将新节点(nextNodes)与老节点(preNodes)一一比对,
  • 遇到相同的节点(本文假设key相同即相同),根据索引相对大小判断节点是否需要移动
  • 遇到新节点,就挂载到 nextNodes 中上一个节点的前面
  • 最后将移动后的节点(newNodes)与新节点( nextNodes )比对去除多余节点

最后的结果就是由 nextNodespreNodes 产生的 节点树(newNodes)。

比如有如下新旧节点:

// 旧节点
const preNodes = [
  {key: "k-1", children: "<span>old1</span>"},
  {key: "k-2", children: "<span>old2</span>"},
  {key: "k-3", children: "<span>old3</span>"},
  {key: "k-4", children: "<span>old4</span>"},
  {key: "k-5", children: "<span>old5</span>"},
  {key: "k-6", children: "<span>old6</span>"},
]
//新节点,想要最后呈现的节点
const nextNodes = [
  {key: "k-11", children: "<span>11</span>"},
  {key: "k-0", children: "<span>0</span>"},
  {key: "k-5", children: "<span>5</span>"},
  {key: "k-13", children: "<span>13</span>"},
  {key: "k-1", children: "<span>1</span>"},
  {key: "k-7", children: "<span>7</span>"},
  {key: "k-16", children: "<span>16</span>"},
  {key: "k-3", children: "<span>3</span>"},
  {key: "k-15", children: "<span>15</span>"},
  {key: "k-17", children: "<span>7</span>"},
  {key: "k-4", children: "<span>4</span>"},
  {key: "k-6", children: "<span>6</span>"}
]

如上,在 preNodes 里如果有老节点可以复用,便用老节点替代他。期望的结果应该是
在这里插入图片描述

可以看到老节点都得到了复用~

下面就具体讲解得到最后新节点的过程。

图例讲解

  • i 是nextNodes的索引, j 是preNodes的索引,每个nextNode都要与所有preNode节点作比对。
  • preNodes的上一个索引节点橙色标记,虚线标记当前遍历的节点,绿色为新增节点,j 标记的是相等的节点(如果有)

初始状态,新生成节点(newNodes)基于老节点
image.png
i=0, lastIndex=0 (默认值),k-11preNodes 未找到, 为新节点。插入至 0
image.png
i=1, lastIndex=0(默认值),k-0preNodes 未找到, 为新节点。插入至 i -1 节点后面
image.png

i=2, lastIndex=4(更新后),k-5 在preNodes找到索引(j=4 > lastIndex,index更新)插入(删除+新增),复用节点
image.png
i=3, lastIndex=4,k-13 在preNodes未找到, 为新节点。插入至 i -1节点 后
image.png
i=4, lastIndex=4,k-1preNodes 找到索引(j=0 < lastIndex)插入(删除+新增)至 i - 1
image.png

i=5, lastIndex=0,k-7preNodes 未找到,为新节点。插入至 i-1 节点后
image.png

i=6, lastIndex=0,k-16preNodes 未找到,为新节点。插入至 i -1 节点后
image.png

i=7, lastIndex=4,k-3preNodes 找到索引(j=2 < lastIndex)插入(删除+新增)至 i - 1
image.png

i=8, lastIndex=0,k-5preNodes 未找到,为新节点。插入至 i -1 节点后
image.png

i=9, lastIndex=0,k-17preNodes 未找到,为新节点。插入至 i-1 节点 后
image.png
i=10, lastIndex=4,k-4 在preNodes找到索引(j=3 < lastIndex)插入(删除+新增)至 i - 1
image.png

i=11, lastIndex=6(更新后),k-6preNodes 找到索引(j=5 > lastIndex,index更新)插入(删除+新增),复用节点
image.png
遍历完成,清除多余节点。
image.png
最终结果
image.png
建议仔细理解。

代码实现

本节是代码的具体实现,不多做讲解,如果有任何疑虑建议精度图例讲解。或者留言交流,十分欢迎~~~

初版

// React_diff()
function React_diff(){
  console.log(nextNodes.map(item => item.key));
  const newNodes = JSON.parse(JSON.stringify(preNodes));
  let lastIndex = 0;
  for(let i=0; i< nextNodes.length; i++){
    const nextNode = nextNodes[i];
    let find = false;
    for(let j=0; j< preNodes.length; j++){
      const preNode = preNodes[j];
      if(preNode.key === nextNode.key){
        find = true;
        if(j < lastIndex){ // 需要移动
          /* 
            insertBefore 效果时遇到同样的删除原来的再添加,
            这里因为是数组模拟,所以需要先添加再删除 ,
            数组处理有点不同,插入和删除索引 都是从老节点找的。
            [1,2,3].splice(0,0,4) => [4,1,2,3]
          */
          const index = i > 0 ? i-1 : 0;
          const insertPos = newNodes.findIndex(node => node.key === nextNodes[index].key);
          const deleteIndex = newNodes.findIndex(node => node.key === preNode.key);

          //添加由于 splice 是在某索引前面加。所以insertPos+1
          newNodes.splice(insertPos+1, 0, preNode)
          //删除
          newNodes.splice(deleteIndex, 1);
        }else {
          lastIndex = j;
        }
      }
    }
    if(!find) {// 插入新节点
      const index = i > 0 ? i-1 : 0;
      const insertPos = newNodes.findIndex(node => node.key === nextNodes[index].key);
      newNodes.splice(insertPos + 1, 0, nextNode);
    }
  }
  
  for(let i = newNodes.length - 1; i>=0; i--){
    let find = false;
    for(let j =0; j<nextNodes.length; j++){
      if(nextNodes[j].key === newNodes[i].key){
        find = true;
        continue;
      }
    }
    if(!find){
      newNodes.splice(i, 1);
    }
  }
  console.log('react diff: ', newNodes);
}

优化版

优化点可以利用key与index的做个对应关系,少一层遍历.

function React_diff(){
  const newNodes = JSON.parse(JSON.stringify(preNodes));
  let lastIndex = 0;

  //产生nextNodes 的keyInIndexmap
  const prekeyInIndex = {};
  for(let i =0; i< preNodes.length; i++){
    prekeyInIndex[preNodes[i].key] = i;
  }

  for(let i=0; i< nextNodes.length; i++){
    const nextNode = nextNodes[i];
    let find = false;
    const j = prekeyInIndex[nextNode.key];
    if(typeof j !== 'undefined') {
      find = true;
      const preNode = preNodes[j];
      console.log(j, lastIndex);
      if(j < lastIndex){ // 需要移动
        /* 
          insertBefore 效果时遇到同样的删除原来的再添加,
          这里因为是数组模拟,所以需要先添加再删除 ,
          数组处理有点不同,插入和删除索引 都是从老节点找的。
          [1,2,3].splice(0,0,4) => [4,1,2,3]
        */
        const index = i > 0 ? i-1 : 0;
        const insertPos = newNodes.findIndex(node => node.key === nextNodes[index].key);
        const deleteIndex = newNodes.findIndex(node => node.key === preNode.key);

        //添加由于 splice 是在某索引前面加。所以insertPos+1
        newNodes.splice(insertPos+1, 0, preNode)
        //删除
        newNodes.splice(deleteIndex, 1);
      }else {
        lastIndex = j;
      }
    }
    if(!find) {// 插入新节点
      const index = i > 0 ? i-1 : 0;
      const insertPos = newNodes.findIndex(node => node.key === nextNodes[index].key);
      newNodes.splice(insertPos + 1, 0, nextNode);
    }
  }
  
  //产生nextNodes 的keyInIndexmap
  const nextkeyInIndex = {};
  for(let i =0; i< nextNodes.length; i++){
    nextkeyInIndex[nextNodes[i].key] = i;
  }
  for(let i = newNodes.length - 1; i>=0; i--){
    const idx = nextkeyInIndex[newNodes[i].key];
    if(typeof idx === 'undefined'){
      newNodes.splice(i, 1);
    }
  }
  console.log('react diff: ', newNodes);
}

总结

本文中例子只是为了更好理解 diff 思路, patch 过程与真实情况还有些差异(下面为与vue patch的一些差异。可做参考)

  • 重复节点问题。新老节点有重复节点时,本文diff函数没处理这种情况。
  • 仅是用数组模拟了Vnode,真实的Vnode 不止 key和children,还有更多的参数
  • 比对相同节点时,仅比对了 key, 真实其实还涉及到 class(类名) 、attrs(属性值)、孩子节点(递归)等属性比对;另外上面的children也要比对若不同也要递归遍历
  • 插入、删除、添加节点我用的数组。其实应该用 insertbeforedeleteadd。这些方法均是单独封装不能采用相对应的 Dom Api,因为 vue 不止用在浏览器环境
  • ...

Vue@3.2 已经出来了,React@18也快了,哎,框架学不完。还是多看看不变的东西吧(js, 设计模式, 数据结构,算法...)

哎哎哎,,同志,看完怎么不点赞,别看别人就说你呢,你几个意思?
image.png


参考

站在别人肩膀能看的更远。

【推荐】vue-design
【掘金小册】剖析Vue.js内部运行机制
【CSDN】React、Vue2.x、Vue3.0的diff算法


以上。
image.png


ethanYin
205 声望0 粉丝

如果你想做,下一秒就去。