深入了解虚拟DOM和DOM-diff
虚拟DOM和比对算法讲解
本篇文章是在近期的学习中整理出来的,内容是有关 Vue2.0
中 虚拟DOM 和比对算法的解释。本篇依旧秉承着尽力通俗易懂的解释。如若哪部分没有解释清楚,或者说写的有错误的地方,还请各位 批评指正
近期我还在整理 个人的Vue
的所学。从0开始再一次手写Vue
。本篇内容将会在那篇文章中进行使用。
理论知识
为什么需要虚拟DOM
DOM
是很大的,里面元素很多。操作起来比较浪费时间,浪费性能。所以我们需要引入虚拟dom
的概念
什么是虚拟DOM
简单来说,虚拟DOM
其实就是用js
中的对象来模拟真实DOM
,再通过方法的转换,将它变成真实DOM
优点
- 最终表现在
真实DOM
上 部分改变,保证了渲染的效率 - 性能提升 (对比操作真实DOM)
正式开始
思路
- 我们需要获取一个节点来挂载我们的渲染结果
- 我们需要把对象(
虚拟节点
),渲染成真实节点。插入到 获取的节点中(当然这个中间会有很多繁琐的过程。后面会一点点的说) - 在更新的过程中,我们需要比对
dom
元素的各个属性,能复用复用。复用不了就更新
webpack
配置
// webpack.config.jsconst 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
文件,我们明确了目标。
- 我们需要一个
h
方法来把虚拟DOM
变成真实DOM - 还需要一个
render
方法,将我们所创建的节点挂载到app
上
接下来我们开始写这两个方法
为了方便管理。我们新建一个文件名为vdom
的文件夹。里面有一个index.js
文件,作为 总导出
// vdom/index.jsimport 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.jsimport {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 等属性
}
}
}
**思路:**其实很简单
- 把 老属性中的样式不存在于 新属性的样式置为空
- 删除 老属性中不存在于 新属性 的 属性
- 新属性 中 老属性 没有的,把它 添加/更新 上
总结和再串一次流程
这样一来。我们就完成了 从 虚拟dom
的创建再到 渲染
的过程
我们再回顾一遍流程
- 先通过
h
方法,把传入的各个属性进行组合,变成虚拟dom
- 再通过
render
方法,把传入的虚拟dom
进行渲染和挂载 - 在渲染的过程中,我们用了
createElem
方法,创建了真实节点,并挂载到了虚拟节点的el属性上,并返回真实节点 - 在执行
createElem
方法的过程中,我们还需要对 节点的属性进行修改和更新。所以我们创建了updateProperties
,用来更新节点属性 - 方法都执行完成后,回到了
h
方法,把我们创建好的真实节点挂载到了app
上
以上就是从获取节点,再到 初次渲染的整个过程
结果展示
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
。新节点。即要更新成的节点
else if
所作的事情。就是 从头开始比对, 例如 老节点是 1 2 3 4 新节点 1 2 3 5.开始调用patch
进行更新判断。它会先判断是不是同一个节点。再更新文本 属性 子节点。 直到结束 把老节点内容更新成 1 2 3 5。else if
所作的事情。就是 从尾部开始比对, 例如 老节点是 5 2 3 4 新节点 1 2 3 4。 方法同上else if
所作的事情。就是 优化了反序。例如 老节点是1 2 3 4 新节点 4 3 2 1。当不满足上述两个条件的时候,会拿老节点的首项和新节点的末尾项相比。结束后插入到老节点的前面。 利用了insertBefore
API。两个参数,一个,要插入的元素。**二个:**插入元素的位置else if
所作的事情。就是 把末尾节点提到前面。老节点1 2 3 5. 新节点 5 1 2 3 。
以上就是四个else if
的作用。较为容易理解。就是 模拟链表操作
接下来就是else
了
以上情况都不满足的条件下,证明 新节点是乱序。这样我们本着 能复用就复用的原则,从头开始比对,如果老节点中存在,就移动(注意数组塌陷)。不存在就创建。多余的就删除。
步骤
- 利用我们创建好的
map
来找要比对的元素 - 如果没有找到,就创建这个元素并插入。
- 找到了就先
patch
这个元素 移动这个元素,并把原来的位置设置为undefined
。以防数组塌陷 - 移动 要被对比的元素
- 因为我们设置了
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