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

虚拟DOM和比对算法讲解

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

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

理论知识

为何须要虚拟DOM

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

什么是虚拟DOM

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

优势

  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的文件,用来做为 入口文件json

// 获取挂载节点
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文件,咱们明确了目标。app

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

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

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

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

相关文章
相关标签/搜索