虚拟 DOM 介绍
虚拟
DOM是使用JS对象标识DOM元素及结构
真实DOM:
<ul id="list">
<li class="item">项目一</li>
<li class="item">项目二</li>
<li class="item">项目三</li>
</ul>
对应的虚拟DOM:
const oldVDOM = { // 旧虚拟DOM
tagName: 'ul', // 标签名
props: { // 标签属性
id: 'list'
},
children: [ // 标签子节点
{
tagName: 'li', props: { class: 'item' }, children: ['项目一']
},
{
tagName: 'li', props: { class: 'item' }, children: ['项目二']
},
{
tagName: 'li', props: { class: 'item' }, children: ['项目三']
}
]
}
这时候如果我们修改了一个li标签的内容,变成如下DOM:
<ul id="list">
<li class="item">项目一</li>
<li class="item">项目二</li>
<li class="item">项目四</li>
</ul>
这时会生成新的虚拟DOM,如下所示:
const newVDOM = { // 旧虚拟DOM
tagName: 'ul', // 标签名
props: { // 标签属性
id: 'list'
},
children: [ // 标签子节点
{
tagName: 'li', props: { class: 'item' }, children: ['项目一']
},
{
tagName: 'li', props: { class: 'item' }, children: ['项目二']
},
{
tagName: 'li', props: { class: 'item' }, children: ['项目四']
}
]
}
这样就形成了新旧两个虚拟DOM,如果我们直接用修改后生成的新的DOM去进行渲染,效率是不会比直接操作真实DOM快的,如下图:
由此可见,如果仅仅有虚拟DOM,是不会比直接操作DOM节点进行渲染来的快的,还需要有一个diff算法,能让我们比较出新旧两个虚拟DOM的差异,从而只对这部分差异进行对应的更新操作
虚拟
DOM算法 = 虚拟DOM+diff算法
什么是 diff 算法
diff算法是一种对比差异的算法,通过比较新、旧虚拟DOM节点,找出哪些虚拟节点有修改,然后去更新对应的真实DOM,提高效率和性能。
使用虚拟DOM算法的损耗计算: 总损耗 = 虚拟DOM增删改+(与Diff算法效率有关)真实DOM差异增删改+(较少的节点)排版与重绘
直接操作真实DOM的损耗计算: 总损耗 = 真实DOM完全增删改+(可能较多的节点)排版与重绘
diff 算法的原理分析
diff 同层对比
因为我们基本上不会把一个节点进行跨层级移动,所以当新旧DOM节点进行对比的时候,采用深度遍历优先,只进行同层级比较,不进行跨级比较。时间复杂度为O(n)
diff 对比流程
当数据改变时,会触发数据的setter,并通过Dep.notify()去通知所有订阅者更新Watcher,订阅者们就会在更新视图前,调用patch方法,给真实的DOM打补丁,更新响应的视图。
patch 方法
该方法会比较同层的新旧两个节点是否是同一种类型的vnode,如果是同一种类型,则执行patchVnode方法去进行比较,如果不是同一个类型的,则直接用新节点替换旧的节点。
核心代码如下:
function patch (oldVnode, vnode, hydrating, removeOnly) {
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// 组件初始化时,没有 oldVnode
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
// 是同类型的节点
if (sameVnode(oldVnode, vnode)) {
// 进行深层比较
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 不是同类型的节点
const oldElm = oldVnode.elm
// 获取父元素
const parentElm = nodeOps.parentNode(oldElm)
// 创建新节点,插入到父元素中
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 删除旧节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
sameVnode 方法
patch过程中,重要的一步就是判断新旧两个vnode是否是同一类型的节点,sameVnode方法的代码如下:
function sameVnode (a, b) {
return (
a.key === b.key && // key 值是否一样
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag && // 标签是否一样
a.isComment === b.isComment && // 是否都是注释
isDef(a.data) === isDef(b.data) && // 是否都定义了 data
sameInputType(a, b) // 都是 input 时,type 是否相同
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
patchVnode 方法
该方法主要做了以下几点工作
- 判断
oldVnode和vnode是否相等,如果相等直接返回 - 获取真实的
DOM,定义为elm - 如果新节点没有文本节点,对比
oldVnode和vnode的子节点 - 如果
oldVnode和vnode的都有子节点且不相等,调用updateChildren对比子节点并更新 - 新节点有子节点,同时旧节点由文本节点,将文本清空,在
elm中插入新节点的子节点 - 新节点没有子节点,旧节点存在子节点,直接将旧节点的子节点移除
- 旧节点有文本节点,清空文本
- 新旧节点都有文本节点且不相等,直接修改元素文本节点为新节点的文本值
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
if (oldVnode === vnode) {
return
}
// 获取真实的 DOM
const elm = vnode.elm = oldVnode.elm
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
const oldCh = oldVnode.children
const ch = vnode.children
// 新节点没有 text
if (isUndef(vnode.text)) {
// 新旧节点都有子节点
if (isDef(oldCh) && isDef(ch)) {
// 旧节点的子节点不等于新节点的子节点,调用 updateChildren 方法对比子节点并更新
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
// 新节点有子节点,同时旧节点由文本节点,将文本清空
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
// 在 dom 中插入新节点的子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 新节点没有子节点,旧节点存在子节点,直接将旧节点的子节点移除
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 旧节点有文本节点,清空文本
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) { // 新旧节点 text 不相等
// 直接修改元素文本为新节点的文本值
nodeOps.setTextContent(elm, vnode.text)
}
}
updateChildren 方法
通过updateChildren方法,对子节点进行更新操作,该方法使用了首尾指针法进行对比,新旧节点各有首尾两个指针,例子如下;
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
</ul>
修改数据后
<ul>
<li>b</li>
<li>c</li>
<li>e</li>
<li>a</li>
</ul>
新旧节点集合及首尾指针如下图:
首尾指针对比,主要有以下几种情况:
oldStartIdx对应的oldS未定义,指针后移一位,更新oldSoldEndVnode对应的oldE未定义,指针前移一位,更新oldE- 对比
oldS和newS,sameVnode(oldS, newS) - 对比
oldE和newE,sameVnode(oldE, newE) - 对比
oldS和newE,sameVnode(oldS, newE) - 对比
oldE和newS,sameVnode(oldE, newS) - 如果以上逻辑都未匹配到,则把所有旧节点的子节点的
key跟index做一个映射,然后用新的vnode的key找到可以复用的旧节点
我们通过代码分析一下对比过程:
第一步比较:
oldS = a, oldE = c
newS = b, newE = a
比较结果:oldS 和 newE 相等,需要把节点a移动到newE所对应的位置,也就是末尾,同时oldS++,newE--
第二步比较:
oldS = b, oldE = c
newS = b, newE = e
比较结果:oldS 和 newS相等,需要把节点b移动到newS所对应的位置,同时oldS++,newS++
第三步比较:
oldS = c, oldE = c
newS = c, newE = e
比较结果:oldS、oldE 和 newS相等,需要把节点c移动到newS所对应的位置,同时oldS++,newS++
第四步:
oldS > oldE,则oldCh先遍历完成了,而newCh还没遍历完,说明newCh比oldCh多,所以需要将多出来的节点,插入到真实DOM上对应的位置上
updateChildren的核心原理代码如下:
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
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
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 移动节点到对应位置
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
为什么不要用 index 做 key
我们平时开发列表的时候,最好不用index做key,因为一旦列表中有插入元素,会导致列表中的其他元素没变,但是他们的index变了,也就导致key发生了变化,导致两个不同的元素,有了相同的key,会进行patchVnode更新节点的文本,无法进行复用。所以最要用独一无二的标识做元素的key