虚拟DOM和比对算法讲解
本篇文章是在近期的学习中整理出来的,内容是有关 Vue2.0
中 虚拟DOM 和比对算法的解释。本篇依旧秉承着尽力通俗易懂的解释。如若哪部分没有解释清楚,或者说写的有错误的地方,还请各位 批评指正html
近期我还在整理 我的的Vue
的所学。从0开始再一次手写Vue
。本篇内容将会在那篇文章中进行使用。前端
理论知识
为何须要虚拟DOM
DOM
是很大的,里面元素不少。操做起来比较浪费时间,浪费性能。因此咱们须要引入虚拟dom
的概念node
什么是虚拟DOM
简单来讲,虚拟DOM
其实就是用js
中的对象来模拟真实DOM
,再经过方法的转换,将它变成真实DOM
webpack
优势
-
最终表如今 真实DOM
上 部分改变,保证了渲染的效率 -
性能提高 (对比操做真实DOM)
正式开始
思路
-
咱们须要获取一个节点来挂载咱们的渲染结果 -
咱们须要把对象( 虚拟节点
),渲染成真实节点。插入到 获取的节点中(固然这个中间会有不少繁琐的过程。后面会一点点的说) -
在更新的过程当中,咱们须要比对 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
文件,咱们明确了目标。数组
-
咱们须要一个 h
方法来把虚拟DOM
变成真实DOM -
还须要一个 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 等属性
}
}
}
**思路:**其实很简单
-
把 老属性中的样式不存在于 新属性的样式置为空 -
删除 老属性中不存在于 新属性 的 属性 -
新属性 中 老属性 没有的,把它 添加/更新 上
总结和再串一次流程
这样一来。咱们就完成了 从 虚拟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
*/
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
。新节点。即要更新成的节点
-
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
等插件快速开发。
本文分享自微信公众号 - 阿琛前端成长之路(lwkWyc)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。