Vue 之虚拟 DOM 及 Diff 算法详解
作者:互联网
Virtual DOM
1、背景信息总结
- DOM 操作非常耗时耗性能(且现代前端框架要求不手动操作 DOM,可以大大提高开发效率)---- 因为会引起页面的回流或重绘
- JS 的执行很快(浏览器V8引擎的出现更加加快了JS的计算速度)
- 实现更好的跨平台(如浏览器端渲染、 Node.js 实现 SSR 服务端渲染、安卓/IOS、小程序等)
推出 ==> Virtual DOM(虚拟DOM)
将真实 DOM 抽象成一个以 JS 对象为节点的虚拟 DOM 树,DOM 的变化过程中不需要操作真实DOM,只需要操作 JS 对象(虚拟 DOM),再与真实 DOM比较差异,最后只对变化的 DOM 进行操作来更新真实 DOM,减少了 DOM 操作,大大提升了性能。
2、VDOM(Virtual DOM简写)是什么?
VDOM是个啥?:用 JS 对象模拟的 DOM 结构(树形结构)
一个用JS 对象表示 DOM 结构的简单例子:
<!-- DOM 结构 -->
<div id="cc" class="bigCc">
<p>DOM</p>
<ul style="font-size: 24px">
<li>huohuoit</li>
</ul>
</div>
<!-- JS 对象表示 -->
{
tag: 'div',
data: {
id: 'cc',
className: 'bigCc'
},
children: [
{
tag: 'p'
text: 'DOM'
},
{
tag: 'ul'
data: { style: 'font-size: 20px' }
children: [
{
tag: 'li',
text: 'huohuoit'
}
]
}
]
}
它的优势:
- 抽象了原本的渲染过程,实现了跨平台的能力
- diff 算法,减少 JS 操作真实 DOM 的带来的性能消耗
3、实现原理
用 JS对象 模拟 DOM 结构,然后比较出差异对象,再去操作 DOM
来看看整体的流程:
- 虚拟 DOM 发生变化时,比较两颗 DOM 树的差异,生成差异对象
- 根据差异对象更新真实 DOM
- 把虚拟 DOM 转换成真实 DOM 插入页面中
- 用 JS 对象模拟真实 DOM 结构
4、Vue 对 VDOM 的源码实现
1、描述
关注:使用 VNode 这个 Class 来描述 VDOM(VNode 由 Vue.js 的 _createElement 方法创建)
这里先关注一些主要描述点:
- elm:VNode 对应的真实 DOM 节点
- key:VNode 标记, diff 过程可以提高效率
- data:VNode 上的 class/attribute/style/绑定事件等数据
- children:VNode 的子节点
- text :文本属性
- tag:VNode 的标签属性
再看看 VNode 的创建过程:
- 初始化 Vue:new Vue( ),调用this._init方法
- Vue 实例挂载:通过$mount 方法挂载 DOM => 调用mountComponent 方法(实例化一个渲染 Watcher,并回调updateComponent 方法) => 调用mountComponent 方法(实例化一个渲染 Watcher,并回调updateComponent 方法)在updateComponent 方法中调用 vm._render 方法先生成VNode,最终调用 vm._update 更新 DOM
- 创建VNode:使用_render 方法(_render 方法把实例渲染成VNode,这里调用了_createElement 方法(即 h 函数))
2、使用 h 函数创建Virtual DOM Tree
3、diff 算法(性能关键):比较新旧Virtual DOM Tree找出差异并更新
4、createElm 函数将 VNode 转化为真实 DOM
Diff 算法
简述理解
理解:比对新旧 VDOM 的变化,然后将变化的部分更新到视图(真实 DOM)上 一边比较新旧VDOM,一边改变真实 Dom节点
diff 的比较方式
比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较(时间复杂度O(n))
diff 流程理解(Vue2.0源码)
1、开始
数据发生改变时,被订阅者的 setter 会调用Dep.notify通知所有订阅者 Watcher,订阅者调用 patch 给真实 DOM 打补丁,更新视图
使用patch (oldVNode, VNode) 比较新旧节点差异:
function patch (oldVnode, vnode) {
// ...
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
let parentEle = api.parentNode(oEl) // 父元素
createEle(vnode) // 根据Vnode生成新元素
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
oldVnode = null
}
}
// ...
return vnode
}
如何比较呢?
// 比较函数
function sameVnode (a, b) {
return (
a.key === b.key && // key值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了data,data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type必须相同
)
}
针对比较出的结果分别做处理:
- 不同,则用新 VNode更新真实 DOM,并替换旧节点oldVNode(如果子节点不一样就说明Vnode完全被改变了,就可以直接替换oldVnode)
- 相同,则继续比较子节点/文本节点等情况,做出相应的处理
function patchVnode(oldVnode, vnode) {
// 当前oldVnode 和 Vnode对应的真实 DOM 节点
const el = vnode.el = oldVnode.el;
let i, oldCh = oldVnode.children, ch = vnode.children;
// 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return
if (oldVnode === vnode) return;
// 都有文本节点且不相等,那么将el的文本节点设置为Vnode的文本节点
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text);
} else {
updateEle(el, vnode, oldVnode);
if (oldCh && ch && oldCh !== ch) {
// 两者都有子节点,则执行updateChildren函数比较子节点
updateChildren(el, oldCh, ch);
} else if (ch) {
// oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
createEle(vnode);
} else if (oldCh) {
// oldVnode有子节点而Vnode没有,则删除el的子节点
api.removeChildren(el);
}
}
}
2、updateChildren 函数(diff 算法核心)
注意到 updateChildren 函数 ,来看看它做了什么
它是怎么做的?
遍历Vnode的子节点vCh(源码中为参数 newCh)和oldVnode的子节点oldCh,进行比较和更新
先来看看相关源码(Vue2.0)
// Vue2.0 diff 算法核心的理解
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1 // 以下简化一下
let oldStartVnode = oldCh[0] // oldS:子节点 oldCh 的头部指针
let oldEndVnode = oldCh[oldEndIdx] // oldE:子节点 oldCh 的尾部指针
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0] // S:子节点 vCh 的头部指针
let newEndVnode = newCh[newEndIdx] // E:子节点 vCh 的尾部指针
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
// 遍历 Vnode 的子节点 vCh(这里记为 newCh 方便理解比较) 和 oldVnode 的子节点 oldCh,进行比较和更新
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 循环条件:oldS <= oldE && S <= E
// 1、检查 oldS oldE 非空
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // oldS 向左移动
} else if (isUndef(oldEndVnode)) { // oldE 向右移动
oldEndVnode = oldCh[--oldEndIdx]
}
// 2、两两比较 oldS、oldE、S、E(4种情况)
// 2.1 oldS = S
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 执行 patch,不需要移动 dom
oldStartVnode = oldCh[++oldStartIdx] // 匹配上的两个指针都向中间移动(向左)
newStartVnode = newCh[++newStartIdx]
}
// 2.2 oldE = E
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) // 执行 patch,不需要移动 dom
oldEndVnode = oldCh[--oldEndIdx] // 匹配上的两个指针向中间移动(向右)
newEndVnode = newCh[--newEndIdx]
}
// 2.3 oldS = E
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 把获得更新后的 (oldS/E) 的 DOM 右移,移动到oldE 对应的 DOM 的右边
// 注意一下这里的 DOM 移动
// 2.3.1 oldS = E,显然是 vnode 右移了
// 2.3.2 第一轮 while 循环:移到 oldE(oldEndVnode.elm)右边就是最右边
// 2.3.3 非第一轮 while 循环:比较过程是两头向中间移动,两头比较过的位置(也即真实DOM位置)是正确的,这次 DOM 是移动到 oldE(oldEndVnode.elm)右边
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx] // 匹配上的两个指针向中间移动
newEndVnode = newCh[--newEndIdx]
}
// 2.4 oldE = S
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// 同上,左移更新后的 DOM
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
// 3、上面四种情况都不存在,通过 key 值查找对应 vnode 进行对比
else {
// 3.1 oldKeyToIdx不存在,创建 oldCh 中 vnode 的 key 到 index 的映射(即根据oldCh的key生成一张hash表),方便我们之后通过 key 去拿下标
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 3.2 拿到下标,用 S 的 key 与 hash表做匹配
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 3.2.1 下标不存在(匹配失败),说明 S(newStartVnode) 是原来没有(新)的 vnode
if (isUndef(idxInOld)) {
// 为 S(newStartVnode) 创建 DOM节点 并插入到 oldS(oldStartVnode.elm) 的前面
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
// 3.2.2 下标存在(匹配成功),说明 oldCh 中有相同 key 的 vnode
else {
vnodeToMove = oldCh[idxInOld]
// (1) type 相同(且key相同),说明是相同的 vnode,执行 patch
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined // 被匹配oldCh中的节点置为 undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) // 直接将S生成新的节点插入真实DOM
} else {
// (2) type 不同(且key相同),作为新元素处理。为 S(newStartVnode) 创建 DOM节点 并插入到 oldS(oldStartVnode.elm) 的前面
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
// 比较的过程中,指针会往中间靠,直到达到匹配完成条件(即退出循环条件)oldS <= oldE && S <= E
if (oldStartIdx > oldEndIdx) { // oldS > oldE:oldCh 先遍历完
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // 将多余的 vCh 节点根据 index 添加到 DOM 中去
} else if (newStartIdx > newEndIdx) { // S > E:vCh 先遍历完
removeVnodes(oldCh, oldStartIdx, oldEndIdx) // 在真实 DOM 中将未遍历(区间为[oldS, oldE])的多余节点删掉
}
}
详细的比较更新过程
代码有点多,我们一步步来理解它...
首先在 updateChildren 函数的开始定义了一些辅助变量,这里我将几个重点变量名简化表示:
- oldCh:旧虚拟 DOM 的子节点
- vCh:新虚拟 DOM 的子节点
- oldS:子节点 oldCh 的头部指针
- oldE:子节点 oldCh 的尾部指针
- S:子节点 vCh 的头部指针
- E:子节点 vCh 的尾部指针
我们通过上面的头尾指针遍历 Vnode 的子节点 vCh(源码中为 newCh ) 和 oldVnode 的子节点 oldCh,进行两两比较和更新
这里头尾指针一共有 4 种比较匹配方式(只要其中两个能匹配上,真实Dom中的相应节点就移到Vnode相应的位置)
1、头尾对应匹配(执行 patch,不需要移动 dom)
- oldS = S:匹配上的两个指针向中间移动
- oldE = E:匹配上的两个指针向中间移动
2、头尾交叉匹配
- oldS = E:把获得更新后的 (oldS/E) 的 DOM 右移,移动到oldE 对应的 DOM 的右边。匹配上的两个指针向中间移动
- oldE = S:同上,左移更新后的 DOM
这里关于 DOM 移动注意点(oldS = E为例)
- oldS = E,显然是 vnode 右移了
- 第一轮 while 循环:移到 oldE(oldEndVnode.elm)右边就是最右边
- 非第一轮 while 循环:比较过程是两头向中间移动,两头比较过的位置(也即真实DOM位置)是正确的,这次 DOM 是移动到 oldE(oldEndVnode.elm)右边
比较的过程中,指针会往中间靠,直到达到匹配完成条件(退出循环)
- oldS > oldE:oldCh 先遍历完,将多余的 vCh 节点根据 index 添加到 DOM 中去
- S > E:vCh 先遍历完,在真实 DOM 中将未遍历(区间为[oldS, oldE])的多余节点删掉
如果不匹配以上 4 种比较方式,如果设置了key,就会用key进行比较
1、oldCh 和 vCh 都存在 key
根据oldCh的key生成一张hash表,用S的key与hash表做匹配。匹配成功就判断S和匹配的旧节点是否为相同节点。同时被匹配oldCh中的节点置为 undefined。
- 是相同节点,则直接将S生成新的节点插入真实DOM,
- 不是相同节点,则将S生成对应的节点插入到dom中对应的oldS的前面的位置(作新元素处理)
2、不存在 key
直接为 S 创建 DOM节点 并插入到 oldS的前面的位置
note1:为什么 v-for 的时候需要设置 key ?
如果没有key那么就只会做四种匹配,匹配不上就直接创建新节点插入 DOM ,这样指针中间有可复用的节点都不能被复用了
note2:Vue3.0对这里的优化
如果都没有key,新的节点会到旧的children队列里通过samenode对比剩下的节点看看是否可以有重用的节点。不是直接插入
标签:Vue,DOM,VNode,oldCh,oldVnode,Diff,vnode,节点 来源: https://blog.csdn.net/huohuoit/article/details/115520780