帮你深入了解虚拟DOM和DOM-diff

1,754 阅读14分钟

虚拟DOM和比对算法讲解

​ 本篇文章是在近期的学习中整理出来的,内容是有关 Vue2.0虚拟DOM比对算法的解释。本篇依旧秉承着尽力通俗易懂的解释。如若哪部分没有解释清楚,或者说写的有错误的地方,还请各位 批评指正

近期我还在整理 个人的Vue的所学。从0开始再一次手写Vue。本篇内容将会在那篇文章中进行使用。

理论知识

为什么需要虚拟DOM

DOM是很大的,里面元素很多。操作起来比较浪费时间,浪费性能。所以我们需要引入虚拟dom的概念

什么是虚拟DOM

简单来说,虚拟DOM其实就是用js中的对象来模拟真实DOM,再通过方法的转换,将它变成真实DOM

优点

  1. 最终表现在真实DOM部分改变,保证了渲染的效率
  2. 性能提升 (对比操作真实DOM)

正式开始

思路

  1. 我们需要获取一个节点来挂载我们的渲染结果
  2. 我们需要把对象(虚拟节点),渲染成真实节点。插入到 获取的节点中(当然这个中间会有很多繁琐的过程。后面会一点点的说)
  3. 在更新的过程中,我们需要比对dom元素的各个属性,能复用复用。复用不了就更新

webpack配置

// webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  entry: './src/vdomLearn/index.js', // 入口文件
  output: {  // 输出文件
    filename: 'bundle.js',
    path: path.resolve(__dirname,'dist'),
  },
  devtool: 'source-map',  // 源码映射
  plugins: [ // 插件
      new HtmlWebpackPlugin({
        template: path.resolve(__dirname,'public/index.html'),
      })
  ],
}

// package.json
"scripts": {
    "start": "webpack-dev-server",
    "build": "webpack"
  },

获取节点并初次渲染

首先先看一下我们的 模板Html,没什么重要内容,就是有一个id='#app'的div(作为挂载的节点)

<!doctype html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Vue</title>
</head>
<body>
<div id="#app">

</div>
</body>
</html>

我们创建一个名为index.js的文件,用来作为 入口文件

// 获取挂载节点
let app = document.getElementById('#app')

// 创建虚拟节点  我们先用死数据模拟
let oldNode = h('div',{id:'container'},
 h('span',{style:{color:'red'}},'hello')
 'hello'              
)

// 渲染函数 将 我们创建的虚拟节点 挂载到对应节点上
render(oldNode,app)

为什么这么叫名字呢? h是遵循Vue里面的叫法。剩下的基本都是英语翻译

目标明确

经过上面的index.js文件,我们明确了目标。

  1. 我们需要一个h方法来把虚拟DOM变成真实DOM
  2. 还需要一个render方法,将我们所创建的节点挂载到app

接下来我们开始写这两个方法

为了方便管理。我们新建一个文件名为vdom的文件夹。里面有一个index.js文件,作为 总导出

// vdom/index.js
import h from './h'
import {render} from './patch';

export {
  h,render
}

h方法

为了方便管理,我们创建一个名为vNode.js的文件。用来放与虚拟节点相关的内容

// vdom/vNode.js
// 主要放虚拟节点相关的
/**
 * 创建虚拟dom
 * @param tag 标签
 * @param props 属性
 * @param key only one 标识
 * @param children  子节点
 * @param text  文本内容
 // 返回一个虚拟节点
 * @returns {{children: *, tag: *, text: *, key: *, props: *}}
 */
export function vNode(tag,props,key,children,text='') {
  return {
    tag,
    props,
    key,
    children,
    text
  }
}
// vdom/h.js
import {vNode} from './vNode';

// 主要放渲染相关的
/**
 *  h方法就是 CreateElement
 * @param tag 标签
 * @param props  属性
 * @param children  孩子节点和文本
 * @returns {{children: *, tag: *, text: *, key: *, props: *}} 返回一个虚拟dom
 */
export default function h(tag,props,...children){
    // ... 是ES6语法
  let key = props.key // 标识
  delete props.key   // 属性中没有key 属性
    
    // 遍历子节点 如果子节点是对象,则证明他是一个节点。如果不是 则证明是一个文本
  children = children.map(child=>{
    if (typeof child === 'object'){
      console.log(child)
      return child
    }else {
      return vNode(undefined,undefined,undefined,undefined,child)
    }
  })
  // key 作用 only one 标识  可以对比两个虚拟节点是否是同一个
  // 返回一个虚拟节点
  return vNode(tag,props,key,children)
}

render方法

render方法的作用就是把 虚拟节点转换成真实节点,并挂载到 app 节点上,我们把它放到一个叫patch.js的文件中

// vdom/patch.js
/**
 *  渲染成 真实节点 并挂载
 * @param vNode  虚拟DOM
 * @param container 容器  即 需要向哪里添加节点
 */
export function render(vNode, container) { // 把虚拟节点变成真实节点
  let el = createElem(vNode)
  container.appendChild(el) // 把 创建好的真实节点加入到 app 中
}

虚拟节点传入后,我们要根据虚拟节点来创建真实节点。所以我们写一个名为createElem的方法,用来 把虚拟节点变成真实节点

createElem方法

// vdom/patch.js

// ...前面含有上述的render方法 我省略一下
/**
 *  根据虚拟节点创建真实节点
 * @param vNode  虚拟DOM
 * @returns {any | Text} 返回真实节点
 */
function createElem(vNode) {
  let { tag, props, children, text, key } = vNode
  if (typeof tag === 'string') { // 即 div span 等
    vNode.el = document.createElement(tag) //  创建节点 将创建出来的真实节点挂载到虚拟节点上
    updateProperties(vNode)  // 更新属性方法
    //  看是否有孩子 如果有孩子,则把这个孩子继续渲染
    children.forEach(child => {
      return render(child, vNode.el)
    })
  } else { // 不存在 undefined   Vnode.el 对应的是虚拟节点里面的真实dom元素
    vNode.el = document.createTextNode(text)
  }
  return vNode.el
}

难点解释

个人觉得难以理解的一个部分应该是这个for遍历,children是一个个虚拟子节点(用h方法创建的)。如果它有tag属性,则证明它是一个节点。里面可能包含有其他节点。所以我们要遍历children。拿到每一个虚拟子节点,继续渲染,把 所有虚拟子节点上都挂载上真实的dom。如果是文本,直接创建文本节点就可以了。然后把真实dom返回。

updateProperties方法

创建真实节点的过程中,我们为了以后考虑。写一个名为updateProperties 用来更新或者初始化dom的属性(props)

// vdom/patch.js

// ...前面含有上述的render,和createElem方法 我省略一下
/**
 * 更新或者初始化DOM props
 * @param vNode
 * @param oldProps
 */
function updateProperties(vNode, oldProps = {}) {
  let newProps = vNode.props || {}// 当前的老属性 也可能没有属性 以防程序出错,给了一个空对象
  let el = vNode.el // 真实节点  取到我们刚才再虚拟节点上挂载的真实dom

  let oldStyle = oldProps.style || {}
  let newStyle = newProps.style || {}

  // 处理 老样式中  要更新的样式 如果新样式中不存在老样式 就置为空
  for (let key in oldStyle) {
    if (!newStyle[key]) {
      el.style[key] = ''
    }
  }

  // 删除  更新过程中 新属性中不存在的 属性
  for (let key in oldProps) {
    if (!newProps[key]) {
      delete el[key]
    }
  }

  // 考虑一下以前有没有
  for (let key in newProps) {
    if (key === 'style') {
      for (let styleName in newProps.style) {
        // color                       red
        el.style[styleName] = newProps.style[styleName]
      }
    } else if (key === 'class') { // 处理class属性
      el.className = newProps.class
    } else {
      el[key] = newProps[key] // key 是id 等属性
    }
  }
}

**思路:**其实很简单

  1. 老属性中的样式不存在于 新属性的样式置为空
  2. 删除 老属性中不存在于 新属性 的 属性
  3. 新属性老属性 没有的,把它 添加/更新

总结和再串一次流程

这样一来。我们就完成了 从 虚拟dom 的创建再到 渲染 的过程

我们再回顾一遍流程

  1. 先通过h方法,把传入的各个属性进行组合,变成虚拟dom
  2. 再通过render方法,把传入的虚拟dom进行渲染和挂载
  3. 在渲染的过程中,我们用了createElem方法,创建了真实节点,并挂载到了虚拟节点的el属性上,并返回真实节点
  4. 在执行createElem方法的过程中,我们还需要对 节点的属性进行修改和更新。所以我们创建了updateProperties,用来更新节点属性
  5. 方法都执行完成后,回到了h方法,把我们创建好的真实节点挂载到了 app

以上就是从获取节点,再到 初次渲染的整个过程

结果展示

结果展示.png


Dom的更新和比对算法

上述我们叙述了 如何把虚拟dom转换成真实dom 的过程。接下来我们 说一下 关于dom的更新

先看 index文件

import {h,render,patch} from './vdom'

// 获取挂载节点
let app = document.getElementById('#app')

// 创建虚拟节点  我们先用死数据模拟
let oldNode = h('div',{id:'container'},
 h('span',{style:{color:'red'}},'hello')
 'hello'              
)

// 渲染函数 将 我们创建的虚拟节点 挂载到对应节点上
render(oldNode,app)

// 我们设置一个定时器, 用patch 方法来更新dom
// 把新的节点和老的节点做对比   更新真实dom 元素
setTimeout(()=>{
  patch(oldNode,newNode)
},1000)

我们用一个patch方法来更新dom

vdom/index文件中导出这个方法

import h from './h'
import {render,patch} from './patch';

export {
  h,render,patch
}

patch文件

思路分析

​ 我们要做的是DOM的更新操作,需要接收两个参数(新老DOM),遵循着 能复用就复用的原则(复用比重新渲染效率高)。然后 更新属性。结束后再对比 子节点。 并做出响应的优化

patch dom对比和更新

// vdom/patch.js
// ...省略上面的 了

/**
 *  做dom 的对比更新操作
 * @param oldNode
 * @param newNode
 */
export function patch(oldNode, newNode) {
  // 传入的newNode是 一个对象   oldNode 是一个虚拟节点 上面el为真实节点
  // 1 先比对 父级标签一样不  不一样直接干掉  传进来是虚拟节点
  if (oldNode.tag !== newNode.tag) {
    // 必须拿到父亲才可以替换儿子
    // 老节点的 父级 替换     利用createElem创建真实节点 进行替换
    oldNode.el.parentNode.replaceChild(createElem(newNode), oldNode.el)
  }
  // 对比文本 更改文本内容
  if (!oldNode.tag) { // 证明其是文本节点
    if (oldNode.el.textContent !== newNode.text) {
      oldNode.el.textContent = newNode.text
    }
  }
  // 标签一样 对比属性
  let el = newNode.el = oldNode.el    // 新老标签一样 直接复用
  updateProperties(newNode, oldNode.props) // 更新属性

  // 开始比对孩子  必须要有一个根节点
  let oldNodeChildren = oldNode.children || []
  let newNodeChildren = newNode.children || []
  //  三种情况 老有新有  老有新没有  老没有新有
  if (oldNodeChildren.length > 0 && newNodeChildren.length > 0) {
    // 新老都有 就更新
      //  el是什么? 就是 两个虚拟节点渲染后的真实节点
    updateChildren(el, oldNodeChildren, newNodeChildren)
  } else if (oldNodeChildren.length > 0) {
    // 新没有 老有
    el.innerHTML = ''
  } else if (newNodeChildren.length > 0) {
    // 老没有 新有
    for (let i = 0; i < newNodeChildren.length; i++) {
      let child = newNodeChildren[i]
      el.appendChild(createElem(child)) // 将新儿子添加到 老的节点中
    }
  }
  return el  // 对比之后的返回真实节点
}

这段代码的 较简单都写出来了。稍微难一点的在于 **比对孩子的过程中,新老节点都有孩子。我们就需要再来一个方法,用于新老孩子的更新 **

updateChildren方法

**作用:**更新新老节点的子节点

/**
 * 工具函数,用于比较这两个节点是否相同
 * @param oldVnode
 * @param newVnode
 * @returns {boolean|boolean}
 */
function isSameVnode(oldVnode, newVnode) {
  // 当两者标签 和 key 相同 可以认为是同一个虚拟节点 可以复用
  return (oldVnode.tag === newVnode.tag) && (oldVnode.key === newVnode.key)
}


// 虚拟Dom 核心代码
/**
 *
 * @param parent  父节点的DOM元素
 * @param oldChildren  老的虚拟dom
 * @param newChildren  新得虚拟dom
 */
function updateChildren(parent, oldChildren, newChildren) {
  // 怎么对比? 一个一个对比,哪个少了就把 多余的拿出来 删掉或者加倒后面
  let oldStartIndex = 0  // 老节点索引
  let oldStartVnode = oldChildren[0]  // 老节点开始值
  let oldEndIndex = oldChildren.length - 1 // 老节点 结束索引
  let oldEndVnode = oldChildren[oldEndIndex] // 老节点结束值

  let newStartIndex = 0  // 新节点索引
  let newStartVnode = newChildren[0]  //        新节点开始值
  let newEndIndex = newChildren.length - 1 //     新节点 结束索引
  let newEndVnode = newChildren[newEndIndex] // 新节点结束值
  /**
   *  把节点的key 建立起映射关系
   * @param child  传入节点
   * @returns {{}}  返回映射关系
   */
  function makeIndexByKey(child) {
    let map = {}
    child.forEach((item, index) => {
      map[item.key] = index
    })
    return map   // {a:0,b:1,c:2}
  }
  let map = makeIndexByKey(oldChildren)
  // 开始比较
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 主要用来解决else 操作引起的 数组塌陷
    if (!oldStartVnode) {
      oldStartVnode = oldChildren[++oldStartIndex]
    } else if (!oldEndVnode) {
      oldEndVnode = oldChildren[--oldStartIndex]
       
        // 上述先不用管 首先从这里开始看
      // 以上代码在一个else中有用。跳过undefined 没有比较意义
        // 先从头部开始比较 如果不一样 再丛尾部比较
    } else if (isSameVnode(oldStartVnode, newStartVnode)) { // 从头开始遍历  前面插入
      patch(oldStartVnode, newStartVnode) // 用新属性更新老属性
      // 移动  开始下一次比较
      oldStartVnode = oldChildren[++oldStartIndex]
      newStartVnode = newChildren[++newStartIndex]
    } else if (isSameVnode(oldEndVnode, newEndVnode)) { // 从尾部开始遍历  尾插法
      patch(oldEndVnode, newEndVnode) // 用新属性更新老属性
      oldEndVnode = oldChildren[--oldEndIndex]
      newEndVnode = newChildren[--newEndIndex]
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      // 倒序操作
      // 正倒序  老的头 新的尾部
      patch(oldStartVnode, newEndVnode) //  abc cba
      // 这一步是关键 插入 把老 进行倒叙                   nextSibling 某个元素之后紧跟的节点:
      // parent 是一个父级的真实dom元素
      parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)
      oldStartVnode = oldChildren[++oldStartIndex]
      newEndVnode = newChildren[--newEndIndex]
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      // 对比把尾部提到最前面
      patch(oldEndVnode, newStartVnode)
      //                   要插入的元素  插入元素位置
      parent.insertBefore(oldEndVnode.el, oldStartVnode.el)
      oldEndVnode = oldChildren[--oldEndIndex]
      newStartVnode = newChildren[++newStartIndex]
    } else {
      //  上述都不行了的话  则证明是乱序,先拿新节点的首项和老节点对比。如果不一样,直接插在这个老节点的前面
      // 如果找到了 则直接移动老节点(以防数组塌陷)
      // 比对结束手可能老节点还有剩余,指直接删除
      // 这里用到了 map
      let movedIndex = map[newStartVnode.key]
      console.log(movedIndex)
      if (movedIndex === undefined) { // 找不到的条件下
        // Vnode.el 对应的是虚拟节点里面的真实dom元素
        parent.insertBefore(createElem(newStartVnode), oldStartVnode.el)
      } else {    // 找到的条件
        // 移动这个元素
        let moveVnode = oldChildren[movedIndex]
        patch(moveVnode, newStartVnode)
        oldChildren[movedIndex] = undefined
        parent.insertBefore(moveVnode.el, oldStartVnode.el)
      }
      newStartVnode = newChildren[++newStartIndex]
    }
  }
  // 如果比对结束后还有剩余的新节点    直接把后面的新节点插入
  if (newStartIndex <= newEndIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // 获取要插入的节点
      let ele = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el
      // 可能前插 可能后插
      parent.insertBefore(createElem(newChildren[i]), ele)
      // parent.appendChild(createElem(newChildren[i]))
    }
  }
  //  删除排序之后多余的老的
  if (oldStartIndex<= oldEndIndex){
    for (let i = oldStartIndex;i<=oldEndIndex;i++){
      let child = oldChildren[i]
      if (child !== undefined){
        // 注意  删除undefined 会报错
        parent.removeChild(child.el)
      }
    }
  }

  // 尽量不要用索引来作为key 可能会导致重新创建当前元素的所有子元素
  // 他们的tag 一样 key 一样 需要把这两个节点重新渲染
  // 没有重复利用的效率高
}

先说明一下isSameVnode函数作用,当发现他们两个 标签一样且key值一样(标识),则证明他们两个是同一个节点。

着重讲述

从这个else if开始看。也就是 判断条件为isSameVnode(oldStartVnode, newStartVnode)开始。

核心就是 模拟链表增删改 倒叙的操作。不过做了一部份优化

以下用这个else if开始说到末尾的一个else if。新节点。即要更新成的节点

  1. else if所作的事情。就是 从头开始比对, 例如 老节点是 1 2 3 4 新节点 1 2 3 5.开始调用patch进行更新判断。它会先判断是不是同一个节点。再更新文本 属性 子节点。 直到结束 把老节点内容更新成 1 2 3 5。
  2. else if所作的事情。就是 从尾部开始比对, 例如 老节点是 5 2 3 4 新节点 1 2 3 4。 方法同上
  3. else if所作的事情。就是 优化了反序。例如 老节点是1 2 3 4 新节点 4 3 2 1。当不满足上述两个条件的时候,会拿老节点的首项和新节点的末尾项相比。结束后插入到老节点的前面。 利用了insertBeforeAPI。两个参数,一个,要插入的元素。**二个:**插入元素的位置
  4. else if所作的事情。就是 把末尾节点提到前面。老节点1 2 3 5. 新节点 5 1 2 3 。

以上就是四个else if的作用。较为容易理解。就是 模拟链表操作

接下来就是else

以上情况都不满足的条件下,证明 新节点是乱序。这样我们本着 能复用就复用的原则,从头开始比对,如果老节点中存在,就移动(注意数组塌陷)。不存在就创建。多余的就删除。

步骤

  1. 利用我们创建好的map来找要比对的元素
  2. 如果没有找到,就创建这个元素并插入。
  3. 找到了就先patch这个元素 移动这个元素,并把原来的位置设置为undefined。以防数组塌陷
  4. 移动 要被对比的元素
  5. 因为我们设置了undefined,所以我们要在开始的时候要进行判断。 这就是我们在前面的if else if 的原因

while进行完毕之后。下面两个if的作用就简单的说了。因为while的判断条件。所以当一个节点比另一个节点长的时候。会有一些没有比较的,这些一定是新的或者老的多余的。直接添加或者删除就行了

补充,为什么不推荐用 索引做key值

举个例子

节点A: a b c d B: b a d r

索引 0 1 2 3 B: 0 1 2 3

判断条件中,他们索引不一样,导致觉得他们不是同一个节点。

这样会 重新创建,渲染这个节点,效率不如直接重复利用的高。且在节点比较大(含有许多子节点)的时候异常明显

总结

本篇文章,从0开始讲述了虚拟节点的创建 渲染 diff的过程。另外有一些配置没有说。利用了webpack进行打包,webpack-dev-server等插件快速开发。