vue2源码解析之Diff

61 阅读24分钟

虚拟DOM

什么是虚拟DOM?

使用一个js对象来描述一个真实dom节点,这个js对象就是虚拟DOM;

// 真实的dom节点
<div id="app">哈哈<p class="child">我是子元素</p></div>
// 虚拟dom对象
const vnode = {
    tag: 'div', // 节点标签名称
    attrs: { // 节点属性
        id: 'app',
    },
    text: '哈哈', // 节点内容
    children: [ // 子节点p
        {
            tag: 'p', // 节点标签名称
            attrs: { // 节点属性
                class: 'child',
            },
            text: '我是子元素', // 节点内容
            children: [], // 子节点
        }
    ]
}

以上就是一个包含子节点的虚拟DOM对象,对象中包含了描述真实节点的一些属性,使用到的属性都有可以添加到这个对象中,比如当前节点的类型,节点的父级节点等等;

为什么使用虚拟DOM

因为操作真实的dom是非常消耗性能的,因此要尽可能少的去操作更新dom,有了虚拟dom,把真实的dom映射出一份虚拟的dom,每次数据变化之后,就可以比较虚拟dom,找出需要更新的地方,从而只操作更新这些不同的dom即可;这样就尽可能少的操作真实dom了,从而提高了性能;

虚拟dom的优点:只需要根据真实dom生成一次,方便对比,方便操作,不用每次都去获取真实dom再获取其中的类型和属性;

vue中的虚拟dom

vue中是通过一个Vnode类来生成虚拟DOM

// 源码位置:src/core/vdom/vnode.js

class VNode {
  tag; // 标签名
  data; // 数据
  children; // 子节点
  text; // 文本内容
  elm; // 对应的真实节点
  ns; // 命名空间
  context; // 当前组件所在的vue实例
  key; // key
  componentOptions; // 组件的options属性
  componentInstance; // 组件实例
  parent; // 父级节点

  // strictly internal
  raw; // 是否是innerHtml
  isStatic; // 是否是静态的节点
  isRootInsert; // 是否作为根节点插入
  isComment; // 是否是注释节点
  isCloned; // 是否是克隆节点
  isOnce; // 是否是有v-once属性的节点
  asyncFactory; // async component factory function
  asyncMeta;
  isAsyncPlaceholder;
  ssrContext;
  fnContext; // real context vm for functional nodes
  fnOptions; // for SSR caching
  devtoolsMeta; // used to store functional render context for devtools
  fnScopeId; // functional scope id support

  constructor (
    tag,
    data,
    children,
    text,
    elm,
    context,
    componentOptions,
    asyncFactory
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.fnContext = undefined
    this.fnOptions = undefined
    this.fnScopeId = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }

  // DEPRECATED: alias for componentInstance for backwards compat.
  /* istanbul ignore next */
  get child (){
    return this.componentInstance
  }
}

这个类包含了描述一个真实dom的一系列属性,通过这些属性的不同值可以描述出不同类型的真实dom;

vue中虚拟dom的类型

  • 文本节点
  • 元素节点
  • 注释节点
  • 组件节点
  • 函数组件节点
  • 克隆节点

不同类型的节点在vue中有不同的处理

  1. 文本节点
// 源码位置:src/core/vdom/vnode.js
// 文本节点
function createTextVNode (val) {
  return new VNode(undefined, undefined, undefined, String(val))
}
  1. 元素节点

vue中没有具体显示一个元素节点的函数,元素节点指定了tag,data,children等描述属性

  1. 注释节点
const createEmptyVNode = (text) => {
  const node = new VNode()
  node.text = text
  node.isComment = true // 设置为true表示是注释节点
  return node
}

创建一个空节点,只要注释的内容赋值给Text,并且标注isComment为True

  1. 组件节点

在元素节点的基础上,多两个属性
componentsOptions:组件的options属性
componentsInstance: 组件对应的vue实例

  1. 函数组件节点

在组件节点的基础上,多两个属性
fnOptions: 组件对应的option
fnContext: 组件对应的vue实例

  1. 克隆组件
function cloneVNode (vnode) {
  const cloned = new VNode(
    vnode.tag,
    vnode.data,
    // #7975
    // clone children array to avoid mutating original in case of cloning
    // a child.
    vnode.children && vnode.children.slice(),
    vnode.text,
    vnode.elm,
    vnode.context,
    vnode.componentOptions,
    vnode.asyncFactory
  )
  cloned.ns = vnode.ns
  cloned.isStatic = vnode.isStatic
  cloned.key = vnode.key
  cloned.isComment = vnode.isComment
  cloned.fnContext = vnode.fnContext
  cloned.fnOptions = vnode.fnOptions
  cloned.fnScopeId = vnode.fnScopeId
  cloned.asyncMeta = vnode.asyncMeta
  cloned.isCloned = true
  return cloned
}

克隆组件,就是把要克隆的组件的属性全部赋值到新的虚拟dom对象上;

虚拟DOM在vue中的应用

在Template模板语法编译的时候,就根据这个模板生成了虚拟的dom对象,当这个模板中数据发生变化的时候,就会根据变化之后的虚拟dom和变化之前的虚拟dom进行比较,找出它们之间不同的虚拟dom,根据不同的虚拟dom生成真实的dom,并且把这个真实dom插入到视图中;

小结

一个用来描述真实dom的一系列的属性的js对象就是虚拟dom,虚拟dom的作用就是方便操作,无需每次都操作真实dom,从而提高了性能;
vue中的虚拟dom,是通过一个Vnode类创建的,这个类中指定了真实dom的一系列属性,通过这些属性的不同从而可以创建出不同类型的虚拟dom;
vue中映射了6种类型的真实dom,分别有注释节点、元素节点、文本节点、组件节点、克隆节点、函数组件节点;
vue中在template模板编译的时候就映射出了相应的虚拟dom,在数据变化的时候通过对比新旧两个虚拟dom,找出不同的虚拟dom进行更新

Diff算法

Diff算法:通过比较新旧虚拟dom,找出其中的差异;

vue中把虚拟DOM分为新的虚拟DOM和旧的虚拟DOM,新的虚拟DOM就是在数据变化之后的虚拟DOM,在vue中称之为新的Vnode;旧的虚拟DOM就是在数据变化之前的虚拟DOM,在vue中称之为旧的Vnode;

vue中的Diff处理了以下三种情况:
创建节点:旧的Vnode中没有而新的Vnode中有,那么就需要在旧的中创建这个节点(新增了一条数据);
删除节点:旧的Vnode中有而新的Vnode中没有,那么就需要在旧的中删除这个节点(删除了一条数据);
更新节点:旧的和新的Vnode中都有,那么就需要根据新的去更新旧的这个节点(修改了一条数据);

创建节点

上文已经说过虚拟dom有六种类型,但是能够真正创建的真实dom只有三种,分别是元素节点,文本节点和注释节点,下面分析下vue源码中怎么创建这三种dom;

// 源码位置: src/core/vdom/patch.js
// 创建节点
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested, owerArray, index) {
  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag 
  // 如果标签不为空
  if (isDef(tag)) {
    // 创建元素
    vnode.elm = nodeOps.createElement(tag, vnode)
    // 创建子元素
    createChildren(vnode, children, insertedVnodeQueue)
    // 插入到父元素下
    insert(parentElm, vnode.elm, refElm)
  } else if (isTrue(vnode.isComment)) { // 如果为注释节点
    // 创建注释节点
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else { // 文本节点
    // 创建文本节点
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}
// 判断是否不为空
function isDef (v) {
  return v !== undefined && v !== null
}
// 是否为空
function isUndef (v) {
  return v === undefined || v === null
}
// 创建真实的dom
function createElement$1 (tagName, vnode) {
  var elm = document.createElement(tagName);
  if (tagName !== 'select') {
    return elm
  }
  // false or null will remove the attribute but undefined will not
  if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
    elm.setAttribute('multiple', 'multiple');
  }
  return elm
}
// 创建注释元素
function createComment (text) {
  return document.createComment(text)
}
// 创建文本
function createTextNode () {
  return document.createTextNode(text)
}
// 添加到元素中
function appendChild (node, child) {
  node.appendChild(child);
}

// 创建子元素
function createChildren (vnode, children, insertedVnodeQueue) {
  // 是数组就循环创建子元素
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) { // 是基本类型就直接添加
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}
// 获取父元素
function parentNode (node) {
  return node.parentNode
}
// 插入指定元素到指定位置
function insertBefore (parentNode, newNode, referenceNode) {
  parentNode.insertBefore(newNode, referenceNode);
}

var nodeOps = /*#__PURE__*/Object.freeze({
  createElement: createElement$1,
  createComment: createComment,
  createTextNode: createTextNode,
  appendChild: appendChild,
  parentNode: parentNode,
  insertBefore: insertBefore,
});

// 是不是基本类型
function isPrimitive (value) {
  return (
    typeof value === 'string' ||
    typeof value === 'number' ||
    // $flow-disable-line
    typeof value === 'symbol' ||
    typeof value === 'boolean'
  )
}

/*
测试:
// html
<button id="btn" >插入到这里:</button>
// js
const vnode = new VNode('div', null, null, '我是div元素')
createElm(vnode, [], document.getElementById('btn'))
*/

测试: image.png 通过以上代码可以看出,vue中是根据vnode的tag来判断是否是元素节点,不为空就是元素节点,创建这个元素节点,创建完毕之后把真实的dom放在vnode.elm下;并且创建这个元素节点下的子节点,把子节点插入到当前节点中,最后把当前元素插入到指定的元素中;
如果vnode.isComment存在表示当前节点为注释节点,直接创建这个注释节点,并且添加到指定元素中;
如果既不是元素节点也不是注释节点,那么就是文本节点,创建文本节点,并且插入到指定元素中;
真正创建元素和插入元素的方法都放在nodeOps对象下;

删除节点

如果在旧的vnode中有而在新的vnode中没有,那么表示删除了这条数据,因此需要删除掉这个节点;

// 源码位置: src/core/vdom/patch.js
// 删除节点
function removeNode (el) {
  // 获取到当前元素的父元素
  const parent = nodeOps.parentNode(el)
  // element may have already been removed due to v-html / v-text
  // 如果父元素存在就直接从父元素中删除这个元素
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}
// 删除元素
function removeChild (node, child) {
  node.removeChild(child);
}
var nodeOps = /*#__PURE__*/Object.freeze({
  ...
  removeChild: removeChild,
});

测试:

image.png 通过当前元素获取到它的父元素,如果父元素存在,就直接从父元素中删除掉这个子元素;

更新节点

新旧vnode都存在,那么就需要更加细致的比较,找出不同进行更新;

  1. 如果都是静态节点,并且key相同,表示这个节点没有变化,直接不做处理;(静态节点:节点中没有使用到vue相关的数据,全是静态的文字)
// 静态节点
<p>纯文本</p>
  1. 如果新节点为文本节点,如果旧节点也是文本节点,那么比较它们的文本是否相同,如果不相同,那么直接把旧的节点中的文本改成新的节点中的文本;如果旧节点不是文本节点,那么直接把它改成文本节点并且内容和新节点的一样;
  2. 如果新节点为元素节点
    如果新节点下包含子节点:如果旧节点下也有子节点,那么就递归更新旧节点下的子节点;如果旧节点下是空节点,那么直接创建一份新节点下的子节点添加进去;如果旧节点下为文本节点,那么直接清空,再创建一份新节点的子节点添加进去;
    如果新节点下不包含子节点:如果新节点下也不包含文本节点,那么就是空节点直接把旧节点进行清空
// 更新节点
function patchVnode (oldVnode,vnode,insertedVnodeQueue,ownerArray,index,removeOnly) {
  // 如果新旧节点相等,直接返回
  if (oldVnode === vnode) {
    return
  }
  // 获取到真实的dom
  const elm = vnode.elm = oldVnode.elm
  // 如果新旧节点都是静态节点并且它们的key一样,并且新节点是克隆节点,或新节点是一次性节点直接返回
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance
    return
  }
  // 获取新旧节点的子节点
  const oldCh = oldVnode.children
  const ch = vnode.children
  // 如果新节点中没有text
  if (isUndef(vnode.text)) {
    // 新旧节点的子节点都存在
    if (isDef(oldCh) && isDef(ch)) {
      // 新旧节点的子节点不相同,那么元素的子节点为新节点的子节点
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) { // 如果新节点的子节点存在,旧节点的子节点则不存在
      // 如果旧节点有文本 直接清空
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      // 把新节点的子节点添加到元素上
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) { // 如果旧节点的子节点存在,新节点的子节点不存在
      // 删除掉旧节点的子节点
      removeVnodes(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) { // 如果旧节点有文本,新节点没有子节点
      // 直接清空元素上的内容
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) { // 新旧节点的内容不同
    // 把新节点的文本放在当前元素上
    nodeOps.setTextContent(elm, vnode.text)
  }
}
// 删除 dom元素
function removeVnodes (vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        removeNode(ch.elm)
      }
    }
  }
}

function removeAndInvokeRemoveHook (vnode, rm) {
  if (isDef(rm) || isDef(vnode.data)) {
    
  } else {
    removeNode(vnode.elm)
  }
}
// 删除节点
function removeNode (el) {
  debugger
  const parent = nodeOps.parentNode(el)
  // element may have already been removed due to v-html / v-text
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}

//根据虚拟dom 添加相应的元素
function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {
  for (; startIdx <= endIdx; ++startIdx) {
    createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm, false, vnodes, startIdx)
  }
}
// 判断是否为true
function isTrue (v) {
  return v === true
}
// 删除真实的dom子元素
function removeChild (node, child) {
  node.removeChild(child);
}
// 设置真实dom的Text
function setTextContent (node, text) {
  node.textContent = text;
}

var nodeOps = /*#__PURE__*/Object.freeze({
  removeChild: removeChild,
  setTextContent: setTextContent,
});

/*
 测试数据
 // 创建新旧虚拟dom
const oldVnode = new VNode('div', null, null, '我是div元素')
const vnode = new VNode('div', null, null, '我是新的div元素')
// 创建新旧真实的dom,把旧的显示在页面中
createElm(oldVnode, [], document.getElementById('btn'))
createElm(vnode, [])
// 2秒之后更新旧的dom
setTimeout(() => {
  patchVnode(oldVnode, vnode)
}, 2000)
*/

测试: image.png 新旧节点都有子节点,那么就调用updateChildren方法进行更新,那么diff算法的核心就在这个方法中,会在下面进行解析;

diff的核心updateChildren

  • 什么是相同的节点?
    asyncFactory、key、tag、isComment、data相同表示是相同的节点;
// 源码位置 ./core/cdom/patch.js
// 判断两个节点是否是相同节点
function sameVnode (a, b) {
  return (
    a.key === b.key &&
    a.asyncFactory === b.asyncFactory && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
  • 什么是新前指针?新前节点? 新后指针?新后节点?
    新的虚拟dom列表中,从第0项开始的指针称之为新前指针;新前指针指向的节点就称之为新前节点;从列表的最后一项开始的指针称之为新后指针;新后指针指向的节点称之为新后节点;

image.png

  • 什么是旧前指针?旧前节点?旧后指针?旧后节点?
    旧的虚拟dom列表中,从第0项开始的指针称之为旧前指针;旧前指针指向的节点就是旧前节点;从列表的最后一项开始的指针称之为旧后指针;旧后指针指向的节点称之为旧后节点;

image.png

  • 什么是oldKeyToIdx?
    旧的虚拟dom列表中,从旧前指针到旧后指针之间的节点,并且把它们节点中的key取出来,设置为一个对象的key,它们的索引index,设置为key的值,这个对象就是oldKeyToIdx;

image.png

  • 什么是idxInOld?
    从旧节点列表中找出新前节点对应的索引

image.png 新前节点为o3,那么在旧节点列表中对应的索引就是2,那么idxInOld就是2;

  • 什么是vnodeToMove?
    从旧节点列表中找出新前节点对应的节点就是vnodeToMove;
  1. 为了方便测试看到结果,先完善下patchVnode在哪里被使用的
// 源码位置 ./core/cdom/patch.js
function createPatchFunction(backend){
      return function patch(oldVnode, vnode) {
        const insertedVnodeQueue = []
        // 如果旧节点不存就直接创建新节点
        if (isUndef(oldVnode)) {
          createElm(vnode, insertedVnodeQueue)
        } else {
          // 判断旧节点是否是真实的节点
          const isRealElement = isDef(oldVnode.nodeType)
          // 如果不是真实的节点就是虚拟的节点了,就比较新旧节点是否相同
          if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // 相同就比较两个虚拟节点的其他属性
            patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null)
          } else {
            // 如果是真实的节点, 就转换为虚拟的节点
            if (isRealElement) {
              oldVnode = emptyNodeAt(oldVnode)
            }
            // 是两个不同的虚拟节点,直接获取旧节点的真实dom和其父级元素
            const oldElm = oldVnode.elm
            // const parentElm = nodeOps.parentNode(oldElm)
            // 创建真实dom
            createElm(
              vnode,
              insertedVnodeQueue,
              oldElm || null,
              // nodeOps.nextSibling(oldElm)
            )
          }
        }

        function emptyNodeAt(elm) {
          return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
        }
      }
    }
    
// 修改下createElm函数
function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested, owerArray, index) {
   ...
  // 如果标签不为空
  if (isDef(tag)) {
    // 创建元素
    vnode.elm = nodeOps.createElement(tag, vnode)
    // 创建子元素
    createChildren(vnode, children, insertedVnodeQueue)
    // .............新增开始.......................
    // 为了方便测试 直接添加内容
    if (vnode.text) {
      vnode.elm.innerText = vnode.text
    }
    // .............新增结束.......................
    // 插入到父元素下
    insert(parentElm, vnode.elm, refElm)
  } else if (isTrue(vnode.isComment)) { // 如果为注释节点
    ...
  } else { // 文本节点
    ...
  }
}
  1. 算法的实现步骤
    在updateChildren方法中首先定义了上面解释的变量,通过while循环判断旧前指针是否小于等于旧后的指针并且新前指针是否小于等于新后的指针;如果小于等于进入循环体进行以下步骤的判断;
// 源码位置 ./core/cdom/patch.js
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[oldStartIdx] // 旧前节点
    let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
    let newStartVnode = newCh[newStartIdx] // 新前节点
    let newEndVnode = newCh[newEndIdx] // 新后节点
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm
}
  • 新前和旧前节点比较,判断是否是相同的节点

image.png 如果是相同的节点,那么直接通过patchVnode查找两个节点中其他的差异,比如文本,属性等;如果不是相同的节点继续判断新后和旧后;

image.png

// 源码位置 ./core/cdom/patch.js
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[oldStartIdx] // 旧前节点
    let oldEndVnode = oldCh[oldEndIdx] // 旧后节点
    let newStartVnode = newCh[newStartIdx] // 新前节点
    let newEndVnode = newCh[newEndIdx] // 新后节点
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm
    // 遍历新旧节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 如果旧前节点不存,就向下移动旧前指针找到下个旧前节点
        if (isUndef(oldStartVnode)) {
          oldStartVnode = oldCh[++oldStartIdx]
        } else if (isUndef(oldEndVnode)) { // 旧后不存在就向上移动找个上个旧后
          oldEndVnode = oldCh[--oldEndIdx]
        } else if (sameVnode(oldStartVnode, newStartVnode)) { // 比较新前和旧前节点是否相同
          // 如果相等就递归对比两个节点
          patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          // 移动旧前和新前的指针
          oldStartVnode = oldCh[++oldStartIdx]
          newStartVnode = newCh[++newStartIdx]
        } 
    }
}

// 测试数据
// html
<div id="app"></div>
<button id="btn">修改div的内容:</button>
// js
const patch = createPatchFunction()
// 创建一个div虚拟节点 ,里面有两个p子节点
const b = new VNode('div', {}, [
  new VNode('p', {}, undefined, '1'),
  new VNode('p', {}, undefined, '2'),
], undefined, null)
debugger
// 把b节点插入到app的元素中
const app = document.getElementById('app')
patch(app, b)
// 点击按钮更新div元素的第一个p元素的内容为11
var btn = document.getElementById('btn')
// 创建新div虚拟节点,第一个p元素的内容为11
const bb = new VNode('div', {}, [
  new VNode('p', {}, undefined, '11'),
  new VNode('p', {}, undefined, '2'),
], undefined, null)
btn.onclick = function () {
  patch(b,bb)
}

结果

GIF 2022-8-4 16-30-47.gif

  • 新后和旧后节点比较,判断是否是相同的节点

image.png 如果新后和旧后相等,通过patchVnode更新,更新完成之后,新后和旧后的指针和节点都指向上个;如果不相同就进行旧前和新后的比较;

image.png

// 源码位置 ./core/cdom/patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    ...
    // 遍历新旧节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 如果旧前节点不存,就向下移动旧前指针找到下个旧前节点
        if (isUndef(oldStartVnode)) {
            ...
        } else if (isUndef(oldEndVnode)) {
          ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) { // 比较新前和旧前节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldEndVnode)) { // 比较新后和旧后节点是否相同
          // 如果相同就递归比较两个节点
          patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newStartIdx)
          // 移动两个节点的指针
          oldEndVnode = oldCh[--oldEndIdx]
          newEndVnode = newCh[--newEndIdx]
        } 
    }
}

// 测试数据
// html
<div id="app"></div>
<button id="btn">修改div的内容:</button>
// js
const patch = createPatchFunction()
// 创建一个div虚拟节点 ,里面有两个p子节点
const b = new VNode('div', {}, [
  new VNode('p', {}, undefined, '1'),
  new VNode('p', {}, undefined, '2'),
], undefined, null)
debugger
// 把b节点插入到app的元素中
const app = document.getElementById('app')
patch(app, b)
// 点击按钮更新div元素的第一个p元素的内容为11
var btn = document.getElementById('btn')
// 创建新div虚拟节点,第一个p元素的内容为11
const bb = new VNode('div', {}, [
  new VNode('p', {}, undefined, '1'),
  new VNode('p', {}, undefined, '22'),
], undefined, null)
btn.onclick = function () {
  patch(b,bb)
}

结果: GIF 2022-8-4 16-30-47.gif

  • 旧前和新后节点比较,判断是否是相同的节点(把第一项移动到了最后一项)

image.png 如果相同,通过patchVnode更新,更新完成之后,把旧前的节点移动到旧后的后面,并且向下移动旧前的指针,向上移动新后的指针;

image.png

// 源码位置 ./core/cdom/patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    ...
    // 遍历新旧节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 如果旧前节点不存,就向下移动旧前指针找到下个旧前节点
        if (isUndef(oldStartVnode)) {
            ...
        } else if (isUndef(oldEndVnode)) {
          ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) { // 比较新前和旧前节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldEndVnode)) { // 比较新后和旧后节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldStartVnode)) { // 比较新后和旧前节点是否相同
          // 如果相同就递归比较两个节点
          patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newStartIdx)
          // 如果允许移动,就把旧前节点插入到旧后的后面
          canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
          oldStartVnode = oldCh[++oldStartIdx]
          newEndVnode = newCh[--newEndIdx]
        }
    }
}

// 测试数据
// html
<div id="app"></div>
<button id="btn">修改div的内容:</button>
// js
const patch = createPatchFunction()
// 创建一个div虚拟节点 ,text为div旧
const b = new VNode('div', {}, [
  new VNode('div', {}, undefined, '旧前'),
  new VNode('p', {}, undefined, '1'),
], undefined, null)
debugger
// 插入到app的元素中
const app = document.getElementById('app')
patch(app, b)
// 点击按钮更新div元素的text为divb新
var btn = document.getElementById('btn')
const bb = new VNode('div', {}, [
  new VNode('p', {}, undefined, '1'),
  new VNode('div', {}, undefined, '新后'),
], undefined, null)
btn.onclick = function () {
  debugger
  patch(b,bb)
}

GIF 2022-8-4 16-30-47.gif 如果不同,就判断旧后和新前;

  • 旧后和新前节点比较,判断是否是相同的节点

image.png 如果相同,通过patchVnode更新,更新完成之后,就把旧后的节点移动到旧前的前面,并且向上移动旧后的指针,向下移动新前的指针;

image.png

// 源码位置 ./core/cdom/patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    ...
    // 遍历新旧节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 如果旧前节点不存,就向下移动旧前指针找到下个旧前节点
        if (isUndef(oldStartVnode)) {
            ...
        } else if (isUndef(oldEndVnode)) {
          ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) { // 比较新前和旧前节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldEndVnode)) { // 比较新后和旧后节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldStartVnode)) { // 比较新后和旧前节点是否相同
         ...
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // 比较旧后和新前节点是否相同
          // 如果相同就递归比较
          patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          // 如果可移动,把旧后节点插入到旧前的前面
          canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
          // 移动两个节点指针
          oldEndVnode = oldCh[--oldEndIdx]
          newStartVnode = newCh[++newStartIdx]
        }
    }
}

// 测试数据
// html
<div id="app"></div>
<button id="btn">修改div的内容:</button>
// js
const patch = createPatchFunction()
// 创建一个div虚拟节点 ,text为div旧
const b = new VNode('div', {}, [
  new VNode('p', {}, undefined, '1'),
  new VNode('div', {}, undefined, '旧后'),
], undefined, null)
debugger
// 插入到app的元素中
const app = document.getElementById('app')
patch(app, b)
// 点击按钮更新div元素的text为divb新
var btn = document.getElementById('btn')
const bb = new VNode('div', {}, [
  new VNode('div', {}, undefined, '新前'),
  new VNode('div', {}, undefined, '1'), // 为了让代码不执行新后和旧前的比较,所以需要设置为不一样的节点
], undefined, null)
btn.onclick = function () {
  patch(b,bb)
}

GIF 2022-8-4 16-30-47.gif

  • 如果以上都不相同:
  1. 就找出旧节点中旧前和旧后之间的节点对应的key和索引,存入一个对象中oldKeyToIdx;
  2. 如果新前节点的key存在,就从oldKeyToIdx中找到其对应的索引;
  3. 如果新前节点的key不存在,就从旧节点的列表中进行查找,找出其中的索引;
  4. 如果新前节点的索引没有找到,那么直接创建这个新前节点并且插入到旧前的前面
  5. 如果新前节点的索引找到了,就从旧节点列表中找出这个节点(标记为可移动节点) 5.1 对比可移动节点和新前节点是否是相同的节点;如果是通过patchVnode进行更新,再旧节点列表中标注这个节点为undefined,并且把可移动的节点插入到旧前的前面;如果不是相同的节点,直接创建这个新前的节点并且插入到旧前的前面;

1. 验证插入新的节点(插入新的数据)

image.png

image.png

// 源码位置 ./core/cdom/patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    ...
    // 遍历新旧节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 如果旧前节点不存,就向下移动旧前指针找到下个旧前节点
        if (isUndef(oldStartVnode)) {
            ...
        } else if (isUndef(oldEndVnode)) {
          ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) { // 比较新前和旧前节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldEndVnode)) { // 比较新后和旧后节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldStartVnode)) { // 比较新后和旧前节点是否相同
         ...
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // 比较旧后和新前节点是否相同
          ...
        } else { // 如果以上都不是,新旧的前后节点都不相同
          // 找出旧节点列表中开始和结束指针之间的节点对应的key和index
          if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
          // 从旧节点列表中找出新前节点的索引
          idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh,
            oldStartIdx, oldEndIdx)
          // 如果不存在就直接创建节点,插入到旧前的前面
          if (isUndef(idxInOld)) {
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
          // 移动新前指针
          newStartVnode = newCh[++newStartIdx]
        }
    }
}

// 测试数据
// html
<div id="app"></div>
<button id="btn">修改div的内容:</button>
// js
const patch = createPatchFunction()
// 创建一个div虚拟节点 ,text为div旧
const b = new VNode('div', {}, [
    new VNode('p', undefined, undefined, '1'),
    new VNode('p', undefined, undefined, '2'),
    new VNode('p', undefined, undefined, '3'),
    new VNode('p', undefined, undefined, '4'),
    new VNode('p', undefined, undefined, '5'),
    new VNode('p', undefined, undefined, '6'),
], undefined, null)
debugger
// 插入到app的元素中
const app = document.getElementById('app')
patch(app, b)
// 点击按钮更新div元素的text为divb新
var btn = document.getElementById('btn')
const bb = new VNode('div', {}, [
    new VNode('p', undefined, undefined, '1'),
    new VNode('div', undefined, undefined, 'div2'),
    new VNode('p', undefined, undefined, '2'),
    new VNode('p', undefined, undefined, '3'),
    new VNode('p', undefined, undefined, '4'),
    new VNode('p', undefined, undefined, '5'),
    new VNode('p', undefined, undefined, '6'),
], undefined, null)
btn.onclick = function () {
    debugger
    patch(b,bb)
}

结果:

GIF 2022-8-4 16-30-47.gif

2. 验证删除节点(删除了数据)
image.png
删除了p 3和p 5的数据

image.png
新前和旧前节点p1相等,新前和旧前指针下移;比较新前和旧前的p2,p2也相等,新前和旧前指针继续下移;比较新前p3和旧前p4,发现不等;继续比较旧后p6和新后p6,发现相等,新后和旧后的指针分别上移;比较旧后p5和新后p4,发现不等;继续比较新后p4和旧前p3,发现不等,继续比较新前p4和旧后p5,发现不相等;获取到旧前指针和旧后指针之间的节点的key对应的index的对象oldKeyToIdex;

image.png 从oldKeyToIdex中查找到新前节点p4的key对应的index;从旧前指针和旧后指针的节点列表中找到p4,把新的p4和旧的p4进行更新并且插入到旧前的前面也就是p3的前面,把之前位置的p4标记为undefined; image.png 新前指针向下移动,新前指针超过了新后指针,结束循环,旧列表有未遍历的节点,获取到这些节点直接删除; image.png

// 源码位置 ./core/cdom/patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    ...
    // 遍历新旧节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 如果旧前节点不存,就向下移动旧前指针找到下个旧前节点
        if (isUndef(oldStartVnode)) {
            ...
        } else if (isUndef(oldEndVnode)) {
          ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) { // 比较新前和旧前节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldEndVnode)) { // 比较新后和旧后节点是否相同
          ...
        } else if (sameVnode(newEndVnode, oldStartVnode)) { // 比较新后和旧前节点是否相同
         ...
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // 比较旧后和新前节点是否相同
          ...
        } else { // 如果以上都不是,新旧的前后节点都不相同
          // 找出旧节点列表中开始和结束指针之间的节点对应的key和index
          if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
          // 从旧节点列表中找出新前节点的索引
          idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh,
            oldStartIdx, oldEndIdx)
          // 如果不存在就直接创建节点,插入到旧前的前面
          if (isUndef(idxInOld)) {
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          } else {
            // 在旧节点列表中获取到这个节点
            vnodeToMove = oldCh[idxInOld]
            // 如果移动的节点和新前节点相同
            if (sameVnode(vnodeToMove, newStartVnode)) {
              // 通过递归比较差异
              patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
              // 旧节点列表中标记为undefined
              oldCh[idxInOld] = undefined
              // 可移动节点插入到旧前的前面
              canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
            }
          }
          // 移动新前指针
          newStartVnode = newCh[++newStartIdx]
        }
    }
    // 如果旧前指针大于了旧后指针,那么可能有剩余的新节点没有被遍历到,这些节点就是新增的数据
  if (oldStartIdx > oldEndIdx) {
    // 找出新后的下个节点
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    // 创建这些节点,并且插入到refElm的前面
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) { // 如果新前指针大于新后指针,表示旧节点列表有未遍历完的节点,这些节点就是需要删除的节点
    // 直接删除这些节点
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  }
}

// 测试数据
// html
<div id="app"></div>
<button id="btn">修改div的内容:</button>
// js
const patch = createPatchFunction()
// 创建一个div虚拟节点 ,text为div旧
const b = new VNode('div', {}, [
     new VNode('p', undefined, undefined, '1', undefined,undefined,undefined, 1),
      new VNode('p', undefined, undefined, '2', undefined,undefined,undefined, 2),
      new VNode('p', undefined, undefined, '3', undefined,undefined,undefined, 3),
      new VNode('p', undefined, undefined, '4', undefined,undefined,undefined, 4),
      new VNode('p', undefined, undefined, '5', undefined,undefined,undefined, 5),
      new VNode('p', undefined, undefined, '6', undefined,undefined,undefined, 6),
], undefined, null)
debugger
// 插入到app的元素中
const app = document.getElementById('app')
patch(app, b)
// 点击按钮更新div元素的text为divb新
var btn = document.getElementById('btn')
const bb = new VNode('div', {}, [
      new VNode('p', undefined, undefined, '1', undefined,undefined,undefined, 1),
      new VNode('p', undefined, undefined, '2', undefined,undefined,undefined, 2),
      new VNode('p', undefined, undefined, '4', undefined,undefined,undefined, 4),
      new VNode('p', undefined, undefined, '6', undefined,undefined,undefined, 6),
], undefined, null)
btn.onclick = function () {
    debugger
    patch(b,bb)
}

结果:

GIF 2022-8-4 16-30-47.gif

3. 乱序(各个数据打乱)

image.png 新前旧前不同,比较新后和旧后,发现新后和旧后相同,进行更新,并且新后和旧后的指针分别上移;

image.png 新前和旧前,新后和旧后,新后和旧前,新前和旧后都不相同,从旧前指针和旧后指针之间的节点找出key对应的index的oldKeyToIdex对象;

image.png 从oldKeyToIdex对象中找出新前节点的index和新前节点

image.png 把新前节点插入到旧前的前面,把在旧节点列表中的p4标记为undefined;新前的指针下移;新前和旧前,新后和旧后,新后和旧前,新前和旧后都不相同,从旧的开始和结束的列表中找出p2;

image.png 把p2插入就旧前的前面,旧列表中把之前的p2标记为undefined,继续下移新前指针;新前和旧前的节点相同,直接更新,再分别向下移动旧前和新前的指针;

image.png 新前和旧前不同,新后和旧后不同,新后p3和旧前p3的相同进行更新比较;

image.png 把旧前的p3移动到旧后的后面,向下移动旧前,向上移动新后的指针,比较新前p5和旧前p5的节点,发现一样直接更新;

image.png 最总的结果

// 测试数据
// html
<div id="app"></div>
<button id="btn">修改div的内容:</button>
 const patch = createPatchFunction()
    // 创建一个div虚拟节点 ,text为div旧
    const b = new VNode('div', {}, [
      new VNode('p', undefined, undefined, '1', undefined,undefined,undefined, 1),
      new VNode('p', undefined, undefined, '2', undefined,undefined,undefined, 2),
      new VNode('p', undefined, undefined, '3', undefined,undefined,undefined, 3),
      new VNode('p', undefined, undefined, '4', undefined,undefined,undefined, 4),
      new VNode('p', undefined, undefined, '5', undefined,undefined,undefined, 5),
      new VNode('p', undefined, undefined, '6', undefined,undefined,undefined, 6),
    ], undefined, null)
    debugger
    // 插入到app的元素中
    const app = document.getElementById('app')
    patch(app, b)
    // 点击按钮更新div元素的text为divb新
    var btn = document.getElementById('btn')
    const bb = new VNode('div', {}, [
      new VNode('p', undefined, undefined, '4', undefined,undefined,undefined, 4),
      new VNode('p', undefined, undefined, '2', undefined,undefined,undefined, 2),
      new VNode('p', undefined, undefined, '1', undefined,undefined,undefined, 1),
      new VNode('p', undefined, undefined, '5', undefined,undefined,undefined, 5),
      new VNode('p', undefined, undefined, '3', undefined,undefined,undefined, 3),
      new VNode('p', undefined, undefined, '6', undefined,undefined,undefined, 6),
    ], undefined, null)
    btn.onclick = function () {
      debugger
      patch(b,bb)
    }

结果:

GIF 2022-8-4 16-30-47.gif

  1. 再次解释下新增、删除节点(当遍历完新旧节点列表之后)

    2.1 如果旧前指针大于了旧后指针,表示新列表中还有未遍历的节点,这些节点表示是新增的节点;找出新后节点的后一个节点A,并且找出新前和新后索引之间的节点,这些节点就是新添加的数据,创建这些节点,并且插入到A的前面;

image.png 2.2 如果新前指针大于了新后指针,表示旧列表中还有未遍历的节点,这些节点在新列表中没有,表示需要删除,找出旧前和旧后索引之间的节点,这些节点表示需要删除的节点,直接进行删除;

image.png

总结:

虚拟dom就是使用js描述真实dom的一个对象;

使用虚拟dom可以不用直接操作真实dom,数据的每次更新只需要比较新旧虚拟dom找出其中的差异,更新这些有差异的dom即可,从而可以提高性能;

vue中的diff算法,vue中采用了双指针的形式,通过新前和旧前,新后和旧后,新后和旧前,新前和旧后四个位置对比新旧节点;新前旧前,新后旧后如果相同就直接更新移动指针;新后和旧前相同,移动旧前节点到旧后的后面,移 动指针;新前和旧后相同,移动旧后的节点到旧前的前面,移动指针;如果以上四步后不相等,那么直接在未遍历的旧节点中找到新前节点,找到就移动到旧前的前面,没找到就创建并且插入到旧前的前面;双指针循环完毕,如果旧前大于旧后指针表示新节点列表中有剩余的节点是新添加的,直接插入到新后的前面;如果新前大于新后指针表示旧节点列表有剩余的节点是需要删除的,直接在列表中删除这些节点;

面试题

v-for中为什么不建议使用index索引作为key的值?

key在diff算法中的作用就是标注当前元素是唯一的,在diff算法中对比两个元素是否相同就是通过key和元素的标签名和data等进行比较;如果使用可索引作为key,那么当在列表中添加或删除一个数据的时候整个列表的key和之前列表的key都不对应,比如在头部添加一条数据,之前索引为0的元素现在就变为1,为1的变为2依次递增1;当diff算法进行比较的时候整个列表中的元素都会重新渲染;而如果使用其他的唯一值作为key,比如id,那么在头部添加一个元素,之前列表的元素的key是不会变化的,因为diff算法能够找出新增的这一项进行添加,而之前的元素是不会重新渲染;

使用索引作为key: image.png

新前旧前比较,发现是新前和旧前的元素相同(key相同元素标签名相同),文本不同更改文本;继续移动旧前和新前的指针,继续比较新前和旧前的节点,发现也是相同,文本不同更改文本;一直比较到旧节点列表遍历完毕,每个节点都重新渲染,因为文本不同,新节点有新增的节点,并且新后的后面没有节点了,直接把新节点追加到新后的后面;可以看出以上的分析整个列表都重新渲染了;

使用id作为key:

image.png 新前和旧前比较,发现不同,就进行新后和旧后进行比较,发现相同,继续向上移动旧后和新后的指针,继续重复以上步骤进行比较;最后旧列表遍历完成,发现新列表有剩余表示是要新增的节点;把新增的节点插入到新后的后一个节点的前面;从以上可以看出只需要创建一个节点插入到列表中;而不是渲染整个列表;