从0-1自己实现一个Vue2 diff算法

242 阅读11分钟

一、前言

  • 大家好,我是小洛,我前几天去面试了,我简历上写着熟悉Vue2 diff算法。然后和面试官经历了几轮对话。
  • 面试官:看你简历上写着熟悉Vue2 diff 算法是吧。
  • :我说是的。此时我心里嘀咕着,不就是一个diff 算法嘛,不是很简单嘛。
  • 面试官:此时面试官边敲键盘边说:我们来做做题吧!
  • :心里嘀咕着,哎,怎么不问diff算法了,我可以给你倒背如流的讲下来。此时面试官说:我们来写一个diif 算法吧。当听到这句话的时候,我当场直接破防了,让我说,我会说,我可不会写呀。也不好拒绝,只能硬着头皮开始写了。下面请大家看我的表演吧。手撕diff算法。

1.1 什么是 diff 算法。

在Vue2/3,react,diff算法是对新旧两个虚拟dom树按照一定的规则,进行对比,从而的出那些节点发生变化了,需要去新增节点还是删除节点,还是移动节点。
从而让我们去减少dom操作,因为dom操作比较昂贵嘛,消耗性能,diff算法会在js层面进行对比,然后决定哪些节点是变动了,再对节点进行操作。

1.2 什么是虚拟dom

  • 什么是虚拟dom呢?可以用一句话来表述,虚拟dom就是一个js对象,是对真实dom的一种抽象。最终会渲染为宿主环境上的真实dom,如何渲染的请看:将vnode渲染为真实dom
  • 虚拟dom有哪些好处呢?使用虚拟dom可以减少对dom的操作,提升性能,由于虚拟dom是对dom的一种抽象,所以他拥有跨平台的能力,只要我们为特定的平台提供特定的实现就可以了。
  • 虚拟dom有哪些缺点?由于要增加运行时的diff,所以runtime体积会增大。
    下面我们定义几个不同case 情况下的虚拟dom,看看他们有哪些差异。
  1. 普通div元素 <div key="1"></div>
const vnode = {
    tagName:"div",
    key:'1',
    text:'',
    children:[]
}
  1. 文本节点 text
const vnode = {
    tagName:"",
    key:'',
    text:'text',
    children:[]
}
  1. 带有孩子节点的div,<div key='1'>我是文本节点<div>
const vnode = {
    tagName:"div",
    key:'1',
    text:'text',
    children:[
        {
           tagName:"",
           key:'',
           text:'我是文本节点',
           children:[]
        }
    ]
}
  1. 有多个孩子的div,<div key='1'><span key='2'>text1</span><span key="3">text2</span></div>
const vnode = {
    tagName:"div",
    key:'1',
    text:'',
    children:[
        {
           tagName:"span",
           key:'2',
           text:'',
           children:[
               {
                 tagName:"",
                 key:'',
                 text:'text1',
                 children:[]
               }
           ]
        },
        {
           tagName:"span",
           key:'3',
           text:'',
           children:[
               {
                 tagName:"",
                 key:'',
                 text:'text2',
                 children:[]
               }
           ]
        },
    ]
}

以上就是几个简单的不同情况下的vnode,diff算法要做的就是将这样的两个vnode树进行对比,从而求出最小操作dom的解。

二、diff算法实现篇

2.1 基础篇

  • 首先我们肯定需要定义一个函数,这个函数能够传入oldVnodenewVnode,然后再函数内部会进行一系列的判断。
const patch =(oldVnode, newVnode) => {
    ....
}

我们下面讨论几种情况。

  1. 如果oldVnode为null,是不是对应着我们的组件首次挂载呀。所以这是需要去遍历newVnode创建节点。
  2. 如果newVnode为null,是不是对应着我们的组件卸载,这时我们只需要卸载旧组件就可以了。
  3. 如果两个都不为null,那么就要进行其他条件的对比了。此时patch函数的代码如下
const patch = (oldNode, newNode) => {
  if (!newNode) {
    console.warn(`卸载旧节点${oldNode}`)
    return
  }
  
  
  if (!oldNode) {
    console.warn(`挂载新节点${newNode}`)
  } else {
    // 两个都不为null, 进行其他条件的对比
  }
} 
  1. 如果两个都不为null,我们此时应该判断下这两个节点相同吗,比如tagName相同吗?或者节点唯一标识key相同吗?此时写一个辅助函数 sameNode,如果两个节点相同,我们就可以进行更仔细的对比了,如果两个节点不同,我们需要卸载旧的,挂载新的。此时patch函数代码如下
const sameVnode = (oldNode, newNode) => {
  return oldNode.key === newNode.key &&  oldNode.tagName === newNode.tagName
}


const patch = (oldNode, newNode) => {
  if (!newNode) {
    console.warn(`卸载旧节点${oldNode}`)
    return
  }

  if (!oldNode) {
    console.warn(`挂载新节点${newNode}`)
  } else {
    // 是不是同一个节点,我们这里简单一下,根据key和 tagName  判断
    if(sameVnode(oldNode, newNode)) {
      console.warn(`oldNode:  ${oldNode.key}${newNode.key} 复用`)
      // 节点可以复用,要进行更仔细的对比了。
      patchVnode(oldNode,newNode)
    }  else{
      console.warn(`卸载${oldNode},挂载 ${newNode}`)
    }
  }
}
  1. 现在我们讨论更仔细的对比,我们再写一个函数patchVnode来对两个节点进行更细致的对比。
const patchVnode = (oldVnode, newVnode) => {
        ...
}

  1. 现在我们讨论更细致的对比case。\
    • 首先我们判断下,newVnode是不是文本节点,如果是文本节点,那么我们不管旧节点是什么,都需要删除oldVnode的所有children,并且将oldVnodtext设为newVnode.text
    • 如果newVnode不是文本节点,那我们判断下oldVnodenewVnode是否都有children,如果都有,那么就要对他们的所有children进行对比了,这种情况比较复杂我们后面在进行讨论。
    • 如果两个都没有children,那么说明newVnodeoldVnode至多只有一个有children,有三种情况,如下:
      • oldVnode有children,此时我们只需要将oldVnode的children置null就可以啦。
      • newVnode有children,此时我们只需要删除旧的文本节点,添加newVnode.children
      • oldVnodenewVnode都没有children,将旧节点的文本置null 此时patchVnode函数的代码变成如下:
const patchVnode = (oldNode, newNode) => {
  // 新节点是不是文本节点
  if (!newNode.text) {
    // 新旧节点同时有 children
    if (newNode.children && oldNode.children) {
      // 定义updateChildren 函数来处理两个都有children,后面会讨论的
      updateChildren(oldNode.children, newNode.children)
    }
    // 至多有一个有 children
    // 旧节点有 children
    else if (oldNode.children) {
      console.warn("删除所有旧节点")
    } else if(newNode.children) {
      console.warn('删除所有旧的文本节点,添加新节点')
    } else if (oldNode.text) {
      console.warn('将旧节点的文本置空')
    }
  } else {
    console.warn(`删除节点 key 为: ${oldNode.key} 的所有children,然后设置为 ${newNode.text}`)
  }

  1. 上面我们完成了几种不同case下的处理,还有一种是oldVnodenewVnode都有children的情况。我们定义updateChildren来处理。其简单的函数定义如下:
const updateChildren = (oldCh, newCh) => {
    ...
}

我们知道Vue2使用的是双端diff,这个diif算法是从snabdom这个库借鉴过来的,我们定义八个指针,分别指向新旧节点开始和结束的索引以及开始和结束索引所在的节点。如下图:

diff.png

在diff的时候会从开始往后进行对比:

2.1.1 名词解释:

  • 新前:newCh前面的节点
  • 新后:newCh后面的节点
  • 旧前:oldCh前面的节点
  • 旧后:oldCh后面的节点
  1. 新前vs旧前 先从最前面开始,看下两个节点是不是相同的节点,如果是调用patch函数递归打补丁
  2. 新后vs旧后 如果新前vs旧前不是相同的节点,那么开始从后面往前处理,看下是不是相同的节点
  3. 新前vs旧后 如果上面两种情况都不是相同的节点,那么交叉处理,看下新前vs旧后
  4. 新后vs旧前 如果上面三种情况都不是相同的节点,那么交叉处理,看下新后vs旧前
  • 我们举一个例子:如上图的节点
  • 第一步:判断旧节点的第一个和新节点的第一个,我们发现,tagNamekey 都相同,那么可以处理,此时只需要继续调用patch就可以啊啦。等patch(oldStartVnode, newStartVnode)处理完,我们让oldStartIndexnewStartIndex向后移动一位。
  • 当处理到新旧节点的第二个节点时,我们发现他们的key不同,所以不能处理了。此时我们从后往前处理
  • 第二步:开始从后往前处理,对比oldEndVnodenewEndVnode我们发现是相同节点,然后继续调用patch(oldEndVnode,newEndVnode)递归处理,等patch(oldEndVnode,newEndVnode)处理完,我们让oldEndIndex``newEndIndex往前走一步。此时oldEndVnode = {tagName:"div",key:5}, newEndVnode = {tagName:"div",key:6}我们发现key不相同,不能处理了。
  • 第三步:进行交叉比较 新前vs旧后newStartVnode = {tagName:"div", key:3},oldEndVnode = {tagName:"div", key:5}我们发现不是相同的节点,则进行下一步
  • 第四步:进行交叉比较 新后vs旧前newEndVnode = {tagName:"div", key:2},oldStartVnode = {tagName:"div", key:2}我们发现是相同的节点,调用patch(oldStartVnode, newEndVnode)进行递归处理,等处理完我们让oldStartIndex++,newEndIndex--。 经历完上面的几个步骤,各个指针的指向情况如下。

diff-1.png

  • 我们发现现在处理不下去了,现在我们需要做的是查找下新节点在旧节点当中有没有,如果有那么进行移动,如果没有那么进行添加。
  • 关于查找新节点在旧节点当中有没有,最简单的就是两个for循环,当遍历到每个元素的时候查找在另一个数组中有没有,这样事件复杂度为O(N^2)。
  • 我们可以选择先遍历oldStartIndexoldEndIndex的节点,然后将其映射为一个map,map的键为vnode.key,map的值为索引。然后在遍历newStartIndexnewEndIndex查找在map中有没有,如果有,判断是不是同一节点,如果没有,则说明是新节点,需要创建节点。 此部分代码如下:
const updateChildren = (oldCh, newCh) => {
  let newStartIndex = 0;
  let oldStartIndex = 0
  let newEndIndex = newCh.length - 1;
  let oldEndIndex = oldCh.length - 1;

  let newStartNode = newCh[0]
  let oldStartNode = oldCh[0]
  let newEndNode = newCh[newEndIndex]
  let oldEndNode = oldCh[oldEndIndex]

  let oldKeyToIdx,idxInOld;
  while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
    if (oldStartNode === undefined) { // 元素被右移
      console.warn( `oldStartNode   被右移了`)
      oldStartNode = oldCh[++oldStartIndex]
    } else if (oldEndNode === undefined) {
      console.warn( `oldEndNode   被左移了`)
      oldEndNode = oldCh[--oldEndIndex]
    } else if (sameVnode( oldStartNode,newStartNode)) { // 旧前 vs 新前
      console.warn( `旧前 vs 新前 ${oldStartNode.key}${newStartNode.key} 可以复用`)
      patch(oldStartNode, newStartNode)
      oldStartNode = oldCh[++oldStartIndex]
      newStartNode = newCh[++newStartIndex]
    } else if(sameVnode(oldEndNode,newEndNode )) {  // 旧后 vs 新后
      console.warn( `旧后 vs 新后 ${oldEndNode.key}${newEndNode.key} 可以复用`)
      patch(oldEndNode, newEndNode)
      oldEndNode = oldCh[-oldEndIndex]
      newEndNode = newCh[--newEndIndex]
    } else if (sameVnode( oldEndNode,newStartNode,)) { // 旧后 vs 新前
      console.warn( `旧后 vs 新前 ${oldEndNode.key}${newStartNode.key} 可以复用`)
      patch(oldEndNode, newStartNode)
      oldEndNode = oldCh[--oldEndIndex]
      newStartNode = newCh[++newStartIndex]
    } else if (sameVnode(oldStartNode,newEndNode, )) { // 旧前 vs 新后
      console.warn( `旧前 vs 新后 ${oldStartNode.key}${newEndNode.key} 可以复用`)
      patch(oldStartNode, newEndNode)
      oldStartNode = oldCh[++oldStartIndex]
      newEndNode = newCh[--newStartIndex]
    } else {
      if (!oldKeyToIdx) {
        oldKeyToIdx = {}
        for (let i = oldStartIndex; i <= oldEndIndex;i++ ) {
          const node = oldCh[i]
          const key = node.key
          oldKeyToIdx[key] = i 
        }
      }
      // 在映射的 map 中根据 key 查找有没有
      idxInOld = oldKeyToIdx[newStartNode.key]

      if (!idxInOld) {
        console.warn(`${newStartNode.key} 节点在旧节点中没有需要新创建`)
      } else {
        // 要移动的元素
        console.warn('根据key去查找')
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartNode)) {
          console.warn(`${newStartNode.key}根据可以在旧节点中查到了,并且可以复用`)
          patch(vnodeToMove, newStartNode)
          oldCh[idxInOld] = undefined
        } else {
          console.warn(`${newStartNode.key}根据可以在旧节点中查找了,但是元素类型不同,删除`)
        }
      }
      newStartNode= newCh[++newStartIndex]

    }
  }

  // 如果 newStartIndex > newEndIndex ,新的先处理完了,删除了元素
  if (newStartIndex > newEndIndex) {
    console.warn(`需要删除${oldStartIndex}${oldEndIndex} 的元素`)
  } else if (oldStartIndex > oldEndIndex) {
    console.warn(`需要添加${newStartIndex}${newEndIndex} 的元素`)
  }
}

2.1.2 注意点:

  1. 根据map去查找的时候,如果查找到了,也需要判断是不是用一个节点,因为key相同但tagName不一定相同。
  2. oldChnewCh都处理完了,需要对比newStartIndexnewEndIndex,以及oldStartIndexoldEndIndex的大小关系
    • 如果newStartIndex > newEndIndex,说明newCh先处理完了,说明有节点被删除了
    • 如果oldStartIndex > oldEndIndex,说明oldCh先处理完了,说明有新增节点
  • 以上就是Vue2 diff算法的大致流程。完整实现代码如下

2.2 完整实现

const sameVnode = (oldNode, newNode) => {
  return oldNode.key === newNode.key &&  oldNode.tagName === newNode.tagName
}


const patch = (oldNode, newNode) => {
  if (!newNode) {
    console.warn(`卸载旧节点${oldNode}`)
    return
  }

  if (!oldNode) {
    console.warn(`挂载新节点${newNode}`)
  } else {
    // 是不是同一个节点,我们这里简单一下,根据key和 tagName  判断
    if(sameVnode(oldNode, newNode)) {
      console.warn(`oldNode:  ${oldNode.key}${newNode.key} 复用`)
      patchVnode(oldNode, newNode)
    }  else{
      console.warn(`卸载${oldNode},挂载 ${newNode}`)
    }
  }
}

const patchVnode = (oldNode, newNode) => {
  // 新节点是不是文本节点
  if (!newNode.text) {
    // 新旧节点同时有 children
    if (newNode.children && oldNode.children) {
      updateChildren(oldNode.children, newNode.children)
    }
    // 至多有一个有 children
    // 旧节点有 children
    else if (oldNode.children) {
      console.warn("删除所有旧节点")
    } else if(newNode.children) {
      console.warn('删除所有旧节点,添加新节点')
    } else if (oldNode.text) {
      console.warn('将旧节点的文本置空')
    }
  } else {
    console.warn(`删除节点 key 为: ${oldNode.key} 的所有children,然后设置为 ${newNode.text}`)
  }
}


const updateChildren = (oldCh, newCh) => {
  let newStartIndex = 0;
  let oldStartIndex = 0
  let newEndIndex = newCh.length - 1;
  let oldEndIndex = oldCh.length - 1;

  let newStartNode = newCh[0]
  let oldStartNode = oldCh[0]
  let newEndNode = newCh[newEndIndex]
  let oldEndNode = oldCh[oldEndIndex]

  let oldKeyToIdx,idxInOld;
  while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
    if (oldStartNode === undefined) { // 元素被右移
      console.warn( `oldStartNode   被右移了`)
      oldStartNode = oldCh[++oldStartIndex]
    
    } else if (oldEndNode === undefined) {
      console.warn( `oldEndNode   被左移了`)
      oldEndNode = oldCh[--oldEndIndex]
    } else if (sameVnode( oldStartNode,newStartNode)) { // 旧前 vs 新前
      console.warn( `旧前 vs 新前 ${oldStartNode.key}${newStartNode.key} 可以复用`)
      patch(oldStartNode, newStartNode)
      oldStartNode = oldCh[++oldStartIndex]
      newStartNode = newCh[++newStartIndex]
    } else if(sameVnode(oldEndNode,newEndNode )) {  // 旧后 vs 新后
      console.warn( `旧后 vs 新后 ${oldEndNode.key}${newEndNode.key} 可以复用`)
      patch(oldEndNode, newEndNode)
      oldEndNode = oldCh[-oldEndIndex]
      newEndNode = newCh[--newEndIndex]
    } else if (sameVnode( oldEndNode,newStartNode,)) { // 旧后 vs 新前
      console.warn( `旧后 vs 新前 ${oldEndNode.key}${newStartNode.key} 可以复用`)
      patch(oldEndNode, newStartNode)
      oldEndNode = oldCh[--oldEndIndex]
      newStartNode = newCh[++newStartIndex]
    } else if (sameVnode(oldStartNode,newEndNode, )) { // 旧前 vs 新后
      console.warn( `旧前 vs 新后 ${oldStartNode.key}${newEndNode.key} 可以复用`)
      patch(oldStartNode, newEndNode)
      oldStartNode = oldCh[++oldStartIndex]
      newEndNode = newCh[--newStartIndex]
    } else {
      if (!oldKeyToIdx) {
        oldKeyToIdx = {}
        for (let i = oldStartIndex; i <= oldEndIndex;i++ ) {
          const node = oldCh[i]
          const key = node.key
          oldKeyToIdx[key] = i 
        }
      }
      // 在映射的 map 中根据 key 查找有没有
      idxInOld = oldKeyToIdx[newStartNode.key]

      if (!idxInOld) {
        console.warn(`${newStartNode.key} 节点在旧节点中没有需要新创建`)
      } else {
        // 要移动的元素
        console.warn('根据key去查找')
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartNode)) {
          console.warn(`${newStartNode.key}根据可以在旧节点中查到了,并且可以复用`)
          patch(vnodeToMove, newStartNode)
          oldCh[idxInOld] = undefined
        } else {
          console.warn(`${newStartNode.key}根据可以在旧节点中查找了,但是元素类型不同,删除`)
        }
      }
      newStartNode= newCh[++newStartIndex]

    }
  }

  // 如果 newStartIndex > newEndIndex ,新的先处理完了,删除了元素
  if (newStartIndex > newEndIndex) {
    console.warn(`需要删除${oldStartIndex}${oldEndIndex} 的元素`)
  } else if (oldStartIndex > oldEndIndex) {
    console.warn(`需要添加${newStartIndex}${newEndIndex} 的元素`)
  }
}

2.3 测试

  • 我们自己写两个简单的vNode树进行测试下。
const oldNode = {
  tagName: 'div',
  key: 1,
  text: '',
  children: [
    {
      tagName: 'div',
      key: 2,
      text: '',
      children: [
        {
          tagName: '',
          key: 6,
          text: '旧节点-文本-1',
          children: []
        },
      ]
    },
    {
      tagName: 'div',
      key: 3,
      text: '',
      children: [
        {
          tagName: 'div',
          key: 7,
          text: '旧节点-文本-2',
          children: []
        },
      ]
    },
    {
      tagName: 'div',
      key: 4,
      text: '',
      children: [
        {
          tagName: 'div',
          key: 8,
          text: '旧节点-文本-3',
          children: []
        },
      ]
    },
    {
      tagName: 'div',
      key: 5,
      text: '',
      children: [
        {
          tagName: 'div',
          key: 9,
          text: '旧节点-文本-4',
          children: []
        },
      ]
    }
  ]
}


const newNode = {
  tagName: 'div',
  key: 1,
  text: '',
  children: [
    {
      tagName: 'div',
      key: 20,
      text: '',
      children: [
        {
          tagName: '',
          key: 6,
          text: '新节点-文本-1',
          children: []
        },
      ]
    },
    {
      tagName: 'div',
      key: 3,
      text: '',
      children: [
        {
          tagName: 'div',
          key: 7,
          text: '新节点-文本-2',
          children: []
        },
      ]
    },
    {
      tagName: 'div',
      key: 4,
      text: '',
      children: [
        {
          tagName: 'div',
          key: 8,
          text: '新节点-文本-3',
          children: []
        },
      ]
    },
    {
      tagName: 'div',
      key: 5,
      text: '',
      children: [
        {
          tagName: 'div',
          key: 9,
          text: '新节点-文本-4',
          children: []
        },
      ]
    }
  ]
}


2.3.1 结果

image.png

三、面试题

3.1 什么是diff算法。答案参考本文

3.2 diff算法中key的作用是什么?

  • key的作用是当节点不能处理的时候,根据key去映射一个map,然后根据key去查找,可以降低时间复杂度

3.3 diff算法中key为什么要唯一,且要有一定的稳定性?

  • key唯一是因为要保证在映射map的时候,要让map有唯一的键,不要让后面添加的把前面的覆盖掉。例如:如果有两个节点:
const ch = [{tagName:"div", key:1}, {tagName:"div", key:1}]

上面两个节点的key重复了,最后映射的map结果为const map = {1:1},导致我们的map中丢掉了一条记录,这种情况有很大的可能会让页面多渲染一条数据。

  • key唯一且稳定,如果key每次都不稳定,比如key=Date.now(),那么diff的时候,发现key不同会删掉旧的节点,重新创建新节点,会造成性能问题。

3.4 有时我们也会故意的让key每次发生变化,让组件去重新创建或者被keep-alive包裹的组件不要使用缓存。

  • 今天的分享就到这里,一个diff算法引发的惨案,让我面试的时候很难堪。我们接下来会自己实现Vue3diff,以及Reactdiif,以及React diff 可中断模式,欢迎关注,我是小洛。