深入了解虚拟DOM和DOM-diff

虚拟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>

<htmllang="zh">

<head>

<metacharset="UTF-8">

<metaname="viewport"

content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">

<metahttp-equiv="X-UA-Compatible"content="ie=edge">

<title>Vue</title>

</head>

<body>

<divid="#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: *}}

*/

exportfunctionvNode(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

*/

exportdefaultfunctionh(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 容器 即 需要向哪里添加节点

*/

exportfunctionrender(vNode, container) { // 把虚拟节点变成真实节点

let el = createElem(vNode)

container.appendChild(el) // 把 创建好的真实节点加入到 app 中

}

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

createElem方法

// vdom/patch.js

// ...前面含有上述的render方法 我省略一下

/**

* 根据虚拟节点创建真实节点

* @param vNode 虚拟DOM

* @returns {any | Text} 返回真实节点

*/

functioncreateElem(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

*/

functionupdateProperties(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]

}

} elseif (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

*/

exportfunctionpatch(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)

} elseif (oldNodeChildren.length > 0) {

// 新没有 老有

el.innerHTML = ''

} elseif (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}

*/

functionisSameVnode(oldVnode, newVnode) {

// 当两者标签 和 key 相同 可以认为是同一个虚拟节点 可以复用

return (oldVnode.tag === newVnode.tag) && (oldVnode.key === newVnode.key)

}

// 虚拟Dom 核心代码

/**

*

* @param parent 父节点的DOM元素

* @param oldChildren 老的虚拟dom

* @param newChildren 新得虚拟dom

*/

functionupdateChildren(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 {{}} 返回映射关系

*/

functionmakeIndexByKey(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]

} elseif (!oldEndVnode) {

oldEndVnode = oldChildren[--oldStartIndex]

// 上述先不用管 首先从这里开始看

// 以上代码在一个else中有用。跳过undefined 没有比较意义

// 先从头部开始比较 如果不一样 再丛尾部比较

} elseif (isSameVnode(oldStartVnode, newStartVnode)) { // 从头开始遍历 前面插入

patch(oldStartVnode, newStartVnode) // 用新属性更新老属性

// 移动 开始下一次比较

oldStartVnode = oldChildren[++oldStartIndex]

newStartVnode = newChildren[++newStartIndex]

} elseif (isSameVnode(oldEndVnode, newEndVnode)) { // 从尾部开始遍历 尾插法

patch(oldEndVnode, newEndVnode) // 用新属性更新老属性

oldEndVnode = oldChildren[--oldEndIndex]

newEndVnode = newChildren[--newEndIndex]

} elseif (isSameVnode(oldStartVnode, newEndVnode)) {

// 倒序操作

// 正倒序 老的头 新的尾部

patch(oldStartVnode, newEndVnode) // abc cba

// 这一步是关键 插入 把老 进行倒叙 nextSibling 某个元素之后紧跟的节点:

// parent 是一个父级的真实dom元素

parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)

oldStartVnode = oldChildren[++oldStartIndex]

newEndVnode = newChildren[--newEndIndex]

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

以上是 深入了解虚拟DOM和DOM-diff 的全部内容, 来源链接: utcz.com/a/21926.html

回到顶部