
node.js极速入门课程:进入学习
虚拟dom和diff算法是vue学习过程中的一个难点,也是面试中必须掌握的一个知识点。这两者相辅相成,是vue框架的核心。今天我们再来总结下vue2中的虚拟dom 和 diff算法。(学习视频分享:vue视频教程)
什么是 VNode
我们知道,render function 会被转化成 VNode。VNode 其实就是一棵以 JavaScript 对象作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。
比如有如下template
<template>
<span class="demo" v-show="isShow"> This is a span. </span>
</template>
登录后复制它换成 VNode 以后大概就是下面这个样子
{
tag: "span",
data: {
/* 指令集合数组 */
directives: [
{
/* v-show指令 */
rawName: "v-show",
expression: "isShow",
name: "show",
value: true,
},
],
/* 静态class */
staticClass: "demo",
},
text: undefined,
children: [
/* 子节点是一个文本VNode节点 */
{
tag: undefined,
data: undefined,
text: "This is a span.",
children: undefined,
},
],
};登录后复制总的来说,VNode 就是一个 JavaScript 对象。这个JavaScript 对象能完整地表示出真实DOM。
为什么vue要使用 VNode
笔者认为有两点原因
由于
Virtual DOM是以JavaScript对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。减少操作
DOM,任何页面的变化,都只使用VNode进行操作对比,只需要在最后一次进行挂载更新DOM,避免了频繁操作DOM,减少了浏览器的回流和重绘从而提高页面性能。
diff算法
下面我们来看看组件更新所涉及到的diff算法。
前面我们讲依赖收集的时候有说到,渲染watcher传递给Watcher的get方法其实是updateComponent方法。
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)登录后复制所以组件在响应式数据发生变化的时候会再次触发该方法,接下来我们来详细分析一下updateComponent里面的_update方法。
_update
在_update方法中做了初始渲染和更新的区分,虽然都是调用__patch__方法,但是传递的参数不一样。
// src/core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
vm._vnode = vnode
// 初次渲染没有 prevVnode,组件更新才会有
if (!prevVnode) {
// 初次渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 更新
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ...
}登录后复制下面我们再来看看__patch__方法
__patch__
patch方法接收四个参数,由于初始渲染的时候oldVnode为vm.$el是null,所以初始渲染是没有oldVnode。
// src/core/vdom/patch.js
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 新节点不存在,只有oldVnode就直接销毁,然后返回
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
// 没有老节点,直接创建,也就是初始渲染
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
// 不是真实dom,并且是相同节点走patch
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 这里才会涉及到diff算法
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
if (isRealElement) {
// ...
}
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// 1.创建一个新节点
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 2.更新父节点占位符
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
const insert = ancestor.data.hook.insert
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}
// 3.删除老节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
//触发插入钩子
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}登录后复制patch方法大概流程如下:
没有新节点只有老节点直接删除老节点。
只有新节点没有老节点直接添加新节点。
既有新节点又有老节点则判断是不是相同节点,相同则进入
pathVnode。patchVnode我们后面会重点分析。既有新节点又有老节点则判断是不是相同节点,不相同则直接删除老节点添加新节点。
我们再来看看它是怎么判断是同一个节点的。
// src/core/vdom/patch.js
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
function sameInputType (a, b) {
if (a.tag !== 'input') return true
let i
const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}登录后复制判断两个VNode节点是否是同一个节点,需要同时满足以下条件
key相同都有异步组件工厂函数
tag(当前节点的标签名)相同isComment是否同为注释节点是否
data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型)当标签是
<input>的时候,type必须相同
当两个VNode的tag、key、isComment都相同,并且同时定义或未定义data的时候,且如果标签为input则type必须相同。这时候这两个VNode则算sameVnode,可以直接进行patchVnode操作。
patchVnode
下面我们再来详细分析下patchVnode方法。
// src/core/vdom/patch.js
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// 两个vnode相同则直接返回
if (oldVnode === vnode) {
return
}
if (isDef(vnode.elm) && isDef(ownerArray)) {
// clone reused vnode
vnode = ownerArray[index] = cloneVNode(vnode)
}
const elm = vnode.elm = oldVnode.elm
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
/*
如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),
并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),
那么只需要替换componentInstance即可。
*/
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
/*调用prepatch钩子*/
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// 获取新老虚拟节点的子节点
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
// 新节点不是文本节点
if (isUndef(vnode.text)) {
/*新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren*/
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
/*如果只有新节点有子节点,先清空elm文本内容,然后为当前DOM节点加入子节点。*/
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
/*如果只有老节点有子节点,则移除elm所有子节点*/
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
/*当新老节点都无子节点的时候,因为这个逻辑中新节点text不存在,所以直接去除ele的文本*/
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
// 新节点是文本节点,如果文本不一样就设置新的文本
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
/*调用postpatch钩子*/
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}登录后复制patchVnode方法大概流程如下:
1.新老节点相同就直接返回。
2.如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换componentInstance即可。
3.新节点不是文本节点,新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren是diff算法的核心,后面我们会重点说。
4.新节点不是文本节点,如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。
5.新节点不是文本节点,当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。
6.新节点不是文本节点,并且新老节点都无子节点的时候,只需要将老节点文本清空。
7.新节点是文本节点,并且新老节点文本不一样,则进行文本的替换。
updateChildren(diff算法核心)
updateChildren是diff算法的核心,下面我们来重点分析。

这两张图代表旧的VNode与新VNode进行patch的过程,他们只是在同层级的VNode之间进行比较得到变化(相同颜色的方块代表互相进行比较的VNode节点),然后修改变化的视图,所以十分高效。所以Diff算法是:深度优先算法。 时间复杂度:O(n)
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
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
// 老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode,同时 oldStartIdx 与 newStartIdx 向后移动一位。
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
// 两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode 操作。并将 oldEndVnode 与 newEndVnode 向前移动一位。
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// oldStartVnode 与 newEndVnode 符合 sameVnode 的时候,
// 将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。
// 然后 oldStartIdx 向后移动一位,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]
// oldEndVnode 与 newStartVnode 符合 sameVnode 时,
// 将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。
// oldEndIdx 向前移动一位,newStartIdx 向后移动一位。
} 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 {
// createKeyToOldIdx 的作用是产生 key 与 index 索引对应的一个 map 表
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果没有找到相同的节点,则通过 createElm 创建一个新节点,并将 newStartIdx 向后移动一位。
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
// 如果找到了节点,同时它符合 sameVnode,则将这两个节点进行 patchVnode,将该位置的老节点赋值 undefined
// 同时将 newStartVnode.elm 插入到 oldStartVnode.elm 的前面
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 如果不符合 sameVnode,只能创建一个新节点插入到 parentElm 的子节点中,newStartIdx 往后移动一位。
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
// 当 while 循环结束以后,如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,
// 需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
// 如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,
// 将这些无用的老节点通过 removeVnodes 批量删除即可。
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}登录后复制vue2的diff算法采用的是双端比较,所谓双端比较就是新列表和旧列表两个列表的头与尾互相对比,在对比的过程中指针会逐渐向内靠拢,直到某一个列表的节点全部遍历过,对比停止。
首尾对比的四种情况
我们首先来看看首尾对比的四种情况。
使用旧列表的头一个节点
oldStartNode与新列表的头一个节点newStartNode对比使用旧列表的最后一个节点
oldEndNode与新列表的最后一个节点newEndNode对比使用旧列表的头一个节点
oldStartNode与新列表的最后一个节点newEndNode对比使用旧列表的最后一个节点
oldEndNode与新列表的头一个节点newStartNode对比
首先是 oldStartVnode 与 newStartVnode 符合 sameVnode 时,说明老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode,同时 oldStartIdx 与 newStartIdx 向后移动一位。

其次是 oldEndVnode 与 newEndVnode 符合 sameVnode,也就是两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode 操作并将 oldEndVnode 与 newEndVnode 向前移动一位。

接下来是两种交叉的情况。
先是 oldStartVnode 与 newEndVnode 符合 sameVnode 的时候,也就是老 VNode 节点的头部与新 VNode 节点的尾部是同一节点的时候,将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。

同理,oldEndVnode 与 newStartVnode 符合 sameVnode 时,也就是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时候,将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。同样的,oldEndIdx 向前移动一位,newStartIdx 向后移动一位。

最后是当以上情况都不符合的时候,这种情况怎么处理呢?
查找对比
那就是查找对比。
首先通过createKeyToOldIdx方法生成oldVnode的key 与 index 索引对应的一个 map 表。
然后我们根据newStartVnode.key,可以快速地从 oldKeyToIdx(createKeyToOldIdx 的返回值)中获取相同 key 的节点的索引 idxInOld,然后找到相同的节点。
这里又分三种情况
如果没有找到相同的节点,则通过
createElm创建一个新节点,并将newStartIdx向后移动一位。如果找到了节点,同时它符合
sameVnode,则将这两个节点进行patchVnode,将该位置的老节点赋值undefined(之后如果还有新节点与该节点key相同可以检测出来提示已有重复的 key ),同时将newStartVnode.elm插入到oldStartVnode.elm的前面。同理,newStartIdx往后移动一位。

如果不符合
sameVnode,只能创建一个新节点插入到parentElm的子节点中,newStartIdx往后移动一位。

添加、删除节点
最后一步就很容易啦,当 while 循环结束以后,如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。

同理,如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes 批量删除即可。

总结
Diff算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM,进而提高效率和性能。
精准主要体现在,diff 算法首先就是找到可复用的节点,然后移动到正确的位置。当元素没有找到的话再来创建新节点。
扩展
vue中为什么需要使用key,它的作用是什么?
key 是 Vue 中 vnode 的唯一标记,通过这个 key,diff 操作可以更准确、更快速。
- 更准确:因为带
key就不是就地复用了,在sameNode函数a.key === b.key对比中可以避免就地复用的情况。所以会更加准确。 - 更快速:利用
key的唯一性生成map对象来获取对应节点,比遍历方式更快。
为什么不推荐使用index作为key
当我们的列表只涉及到 展示,不涉及到排序、删除、添加的时候使用index作为key是没什么问题的。因为此时的index在每个元素上是唯一的。
但是如果涉及到排序、删除、添加的时候就不能再使用index作为key了,因为每个元素key不再唯一了。不唯一的key,对diff算法没有任何帮助,写和没写是一样的。
(学习视频分享:web前端开发、编程基础视频)
以上就是深入理解vue2中的VNode和diff算法的详细内容,转载自php中文网


发表评论 取消回复