您现在的位置是:首页 > 文章详情

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

日期:2020-07-07点击:542

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


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

原文链接:https://my.oschina.net/u/4621706/blog/4517782
关注公众号

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。

持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。

转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。

文章评论

共有0条评论来说两句吧...

文章二维码

扫描即可查看该文章

点击排行

推荐阅读

最新文章