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

虚拟DOM和比对算法讲解

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

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

理论知识

为何须要虚拟DOM

DOM是很大的,里面元素不少。操做起来比较浪费时间,浪费性能。因此咱们须要引入虚拟dom的概念node

什么是虚拟DOM

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

优势

  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(做为挂载的节点)web

<!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里面的叫法。剩下的基本都是英语翻译json

目标明确

通过上面的index.js文件,咱们明确了目标。数组

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

接下来咱们开始写这两个方法微信

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

// 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等插件快速开发。


本文分享自微信公众号 - 阿琛前端成长之路(lwkWyc)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索