Vue源码解析之虚拟DOM和diff算法

598 阅读7分钟

首先先简单介绍一下虚拟DOM和diff算法

diff算法的简单介绍

//旧节点
<div class="box">
    <h3>我是标题</h3>
    <ul>
        <li>牛奶</li>
        <li>咖啡</li>
        <li>可乐</li>
    </ul>
</div>

变为

//新节点
<div class="box">
    <h3>我是标题</h3>
    <span>我是新span</span>
    <ul>
        <li>牛奶</li>
        <li>咖啡</li>
        <li>可乐</li>
        <li>雪碧</li>
    </ul>
</div>

在vue中,新节点通过v-if将span标签呈现到dom中,然后往数组中push了一项雪碧,那么此时就有一个难题,总不能把旧节点全部拆掉,把新节点替换上去吧,虽然计算机处理速度很快,但是面对更大型的DOM,那么代价还是有点昂贵。

面对上述问题,下面就是我们所讲的diff算法。其实我们只需要把span标签和雪碧插入到旧节点的位置,那么这就是diff算法,它可以进行精细化比对,实现最小量更新

虚拟DOM的简单介绍

真实DOM

<div class="box">
    <h3>我是标题</h3>
    <ul>
        <li>牛奶</li>
        <li>咖啡</li>
        <li>可乐</li>
    </ul>
</div>

虚拟DOM

{
    "sel": "div",
    "data": {
        "class": { "box": true }
    },
    "children": [
        {
            "sel": "h3",
            "data": {},
            "text": "我是标题"
        },
        {
            "sel": "ul",
            "data": {},
            "children": [
                { "sel": "li", "data": {}, "text": "牛奶" },
                { "sel": "li", "data": {}, "text": "咖啡" },
                { "sel": "li", "data": {}, "text": "可乐" }
            ]
        }
    ]
}

一句话就是:虚拟DOM就是把真实DOM抽象成数据来表示。

接下来介绍的有如下内容:

  • snabbdom简介
  • h函数介绍及手写snabbdom的h函数(虚拟DOM如何被渲染函数(h函数)产生)
  • snabbdom的diff算法原理
  • 虚拟DOM如何通过diff变为真正的DOM(涵盖在diff算法当中)

snabbdom简介

snabbdom是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴了snabbdom。其中介绍了diff算法、子节点的更新策略等。

官方git:github.com/snabbdom/sn…

本次介绍不研究DOM如何变为虚拟DOM,属于模板编译原理范畴。

h函数介绍

h函数用来产生虚拟节点(vnode),比如这样调用h函数:

h('a',{props:{href:'http://www.baidu.com'}},'百度一下')

将得到这样的虚拟节点。

{'sel':'a','data':{props:{href:'http://www.baidu.com'}},'text':'百度一下'}

它表示真正的DOM节点:

<a href="http://www.baidu.com">百度一下</a>

一个虚拟节点有如下属性:

{
  children:undefined,
  data:{},
  elm:undefined,
  key:undefined,
  sel:'div',
  text:'我是盒子'
}
  • children:当前元素的子元素,数组类型。
  • data:属性、样式等。
  • elm:对应的真正的虚拟节点,如果是undefined表示当前节点还未真正的渲染到dom中。
  • key:唯一标识,例如vue中的key属性
  • text:节点的文字属性

先来看snabbdom是如何使用h函数即让虚拟节点渲染到DOM中。

import {init} from 'snabbdom/build/init'
import {classModule} from 'snabbdom/build/modules/class'
import {propsModule} from 'snabbdom/build/modules/props'
import {styleModule} from 'snabbdom/build/modules/style'
import {eventListenersModule} from 'snabbdom/build/modules/eventlisteners'
import {h} from 'snabbdom/build/h'

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule // attaches event listeners
])

const container = document.getElementById("container")//表示真正的DOM节点,这个节点必须通过webpack配置的静态资源下的html页面声明此元素。

const myVnode = h('p', {props:{class:{p1:true}, '我是p标签')//myVnode就是通过h函数生成的虚拟节点

patch(container, myVnode) //patch函数就是比较渲染在页面中的DOM和将要进行比较的myVode虚拟结点,然后将虚拟节点就会渲染到DOM中。

image.png h函数可以嵌套使用,从而得到虚拟DOM,比如这样使用:

h('ul', {}, [
  h('li', {}, [
    h('p',{},'p1'),
    h('p',{},'p2')
  ]),
  h('li', {}, 'p3')
])

得到这样的虚拟DOM树:

image.png

看了大概使用方法及概念后,下面就开始手写h函数:

声明:此函数为低配版,只写了核心功能!

import vnode from "./vnode"


// 形态一 h('div',{},'文字')
// 形态二 h('div',{},[])
// 形态三 h('div',{},h())

export default function (sel, data, c) {
  if (arguments.length !== 3) {
    throw Error("h函数参数必须是3个")
  } else if (typeof c === 'string' || typeof c === 'number') {
    // 第一种形态:直接返回
    return vnode(sel, data, undefined, c, undefined)
  } else if (Array.isArray(c)) {
    // 第二种形态:对子元素进行Vnode函数处理,最终返回虚拟节点。
    let children = []
    for (let i = 0; i < c.length; i++) {
      // 这里不用执行c[i],测试用例已执行过
      if (!(c[i] && c[i].hasOwnProperty('sel'))) {
        throw Error("参数不是h函数")
      }
      // 收集children
      children.push(c[i])
    }
    return vnode(sel, data, children, undefined, undefined)
  } else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
    // 第三种形态
    let children = [c]
    return vnode(sel, data, children, undefined, undefined)
  } else {
    throw Error("参数不对")
  }
}

接下来就是核心函数patch,大体思维是这样:

image.png

import vnode from "./vnode"
import createElement from './createElement'
import patchVnode from './patchVnode'

export default function (oldVnode, newVnode) {
  // 如果是真实节点,将其包装为虚拟节点。
  if (!oldVnode.sel) {
    oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
  }
  // 判断新老元素是否为同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    console.log('是同一个节点,进行精细化比较');
    // 维护一个函数来进行递归
    patchVnode(oldVnode, newVnode)
  } else {
    console.log('删除旧的,插入新的');
    let newVnodeElm = createElement(newVnode)
    // 以父元素为准(parentNode),插入到老节点之前
    oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
  }
}

patchVnode函数(精细化比较)

import createElement from "./createElement";
import updateChildren from "./updateChildren";

export default function patchVnode(oldVnode, newVnode) {
  if (oldVnode === newVnode) return
  // 判断新结点中是否有text属性
  if (newVnode.text !== '' && (!newVnode.children || !newVnode.children.length)) {
    // console.log('新结点有text属性!');
    if (newVnode.text !== oldVnode.text) {
      // console.log('新结点老节点text属性相同!');
      oldVnode.elm.innerText = newVnode.text
    }
  } else {
    // console.log('newVnode中没有text属性(证明有children属性)');
    // 判断老结点是否有children属性,有的话就需要进行最复杂的情况计算。。
    if (oldVnode.children && oldVnode.children.length) {
      updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
    } else {
      oldVnode.elm.innerText = ""
      for (let i = 0; i < newVnode.children.length; i++) {
        let ch = createElement(newVnode[i])
        oldVnode.elm.appendChild(ch)
      }
    }
  }
}

updateChildren函数,接下来就是diff算法的最核心之处。

一共分为一下四种比对方法,如果都没有命中,那么就使用for循环来比较

image.png

import createElement from "./createElement";
import patchVnode from "./patchVnode";

function isSameNode(a, b) {
  return a.sel === b.sel && a.key === b.key
}

export default function updateChildren(parentElm, oldch, newch) {
  let oldStartIdx = 0;
  let oldEndIdx = oldch.length - 1;
  let oldStartVnode = oldch[0];
  let oldEndVnode = oldch[oldEndIdx];
  let newStartIdx = 0;
  let newEndIdx = newch.length - 1;
  let newStartVnode = newch[newStartIdx];
  let newEndVnode = newch[newEndIdx];
  let keyMap = null;
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 略过undefined的项
    if (!oldStartVnode || !oldch[oldStartIdx]) {
      oldStartVnode = oldch[++oldStartIdx]
    } else if (!oldEndVnode || !oldch[oldEndIdx]) {
      oldEndVnode = oldch[--oldEndIdx]
    } else if (!newStartVnode || !newch[newStartIdx]) {
      newStartVnode = newch[++newStartIdx]
    } else if (!newEndVnode || !newch[newEndIdx]) {
      newEndVnode = newch[--newEndIdx]
    } else if (isSameNode(oldStartVnode, newStartVnode)) {
      // 命中1:新前与旧前
      console.log('命中1:新前与旧前');
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldch[++oldStartIdx]
      newStartVnode = newch[++newStartIdx]
    } else if (isSameNode(oldEndVnode, newEndVnode)) {
      // console.log('sel:',oldEndVnode.sel,newEndVnode.sel);
      // console.log('key:',oldEndVnode.key,newEndVnode.key);
      console.log('命中2:新后与旧后');
      // 进行结点比较
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldch[--oldEndIdx]
      newEndVnode = newch[--newEndIdx]
    } else if (isSameNode(oldStartVnode, newEndVnode)) {
      console.log(oldStartVnode, newEndVnode, 'inner');
      console.log('命中3:新后与旧前');
      // 进行结点比较。
      patchVnode(oldStartVnode, newEndVnode)
      // 顺序替换:将老结点移动到旧后的后面。
      parentElm.insertBefore(oldStartVnode.elm, oldStartVnode.elm.nextsibling)
      oldStartVnode = oldch[++oldStartIdx]
      newEndVnode = newch[--newEndIdx]
    } else if (isSameNode(newStartVnode, oldEndVnode)) {
      console.log('命中4:新前与旧后');
      // 进行结点比较。
      patchVnode(newStartVnode, oldEndVnode)
      // 顺序替换:将老结点移动到旧前的前面。
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldch[--oldEndIdx]
      newStartVnode = newch[++newStartIdx]
    } else {
      // 四种情况都没有找到
      // 制作keyMap一个映射对象,这样每次就不用都遍历老对象了。
      if (!keyMap) {
        keyMap = {}
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
          let key = oldch[i].key
          if (key !== undefined) {
            keyMap[key] = i
          }
        }
      }
      let idxInOld = keyMap[newStartVnode.key]
      if (idxInOld === undefined) {
        // 要加项(newStartVnode)
        // newStartVnode现在还不是真正的结点
        parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
      } else {
        // 如果不是undefined就表示要移项(elmToMove)
        let elmToMove = oldch[idxInOld]
        // console.log(elmToMove, 'elmToMove');
        // 把这项设置为undefined,表示已经处理完这项了。
        patchVnode(elmToMove, newStartVnode)
        oldch[idxInOld] = undefined;
        parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
      }
      newStartVnode = newch[++newStartIdx]
      console.log(keyMap, 'keyMap');
      // 查找出当前元素在老结点中的位置
    }
  }
  // 当while条件结束有几种可能性:
  // 1.新children已结束,但旧children还未结束,表示要进行旧children的删除。例如以下表示:
  // 旧 :1,2,3,4,5 新:1,2,3

  // 2.新children未结束,但旧children已结束,表示要进行旧children的增加。
  // 新:1,2,3 旧 :1,2,3,4,5 
  if (newStartIdx <= newEndIdx) {
    console.log('新结点还有剩余结点没处理,要加项');
    // before:标杆元素
    // let before = !newch[newStartIdx + 1]? null : newch[newStartIdx + 1].elm
    let before = !oldch[oldStartIdx] ? null : oldch[oldStartIdx].elm
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // insertBefore的第二个参数如果为null,和appendChild意思一样。如果不是null,就将元素插入到标杆元素前面
      parentElm.insertBefore(createElement(newch[i]), before)
    }
  } else if (oldStartIdx <= oldEndIdx) {
    console.log('旧结点中还有剩余结点未处理,要减项');
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      if (oldch[i]) {
        parentElm.removeChild(oldch[i].elm)
      }
    }
  }

}

createElement函数(挂载到DOM树)

// 把虚拟DOM变为真实DOM
// 判断text与child属性不能共存
// 判断是否为文字
// 对DOM的child进行递归,然后把child插入到父元素真实DOM中
// 将最外层元素返回
export default function createElement(vnode) {
  console.log('用来把虚拟DOM变为真实DOM');
  let domNode = document.createElement(vnode.sel)
  // 判断是否为文字
  if (vnode.text !== "" && !vnode.children) {
    domNode.innerText = vnode.text
  } else if (Array.isArray(vnode.children) && vnode.children.length) {
    for (let i = 0; i < vnode.children.length; i++) {
      let ch = vnode.children[i]
      let chNode = createElement(ch)
      domNode.appendChild(chNode) //真正上树操作
    }
  }
  vnode.elm = domNode
  return domNode
}