什么是Diff算法
众所周知,像VUE这类MVVM架构的框架,在VM层面实现View和Model的双向绑定,通过构建虚拟DOM作为View的映射。当Model数据改变触发虚拟DOM发生改变,VM层通过优化算法,用成本较小的方式对比出虚拟DOM和真实DOM的差异,作用于真实DOM中,以完成视图更新,这个过程便是Diff
为什么要使用Diff
当数据发生改变时,我们期望的是视图也对应发生变化,但如果无论是触发任何数据变化,都会将视图所有真实DOM树中的节点都进行更新,这在用户体验上是开发者不想看到的,Diff的好处是只对虚拟DOM和真实DOM的差异部分进行操作更新。
事实上虚拟DOM并不一定比直接操作DOM来的快(参考尤大的解答:www.zhihu.com/question/31…), 虚拟DOM 最重要的价值不是性能,它使开发者脱离DOM操作,将精力放在业务上,框架替开发者完成相应的DOM操作,使用虚拟DOM可以使平台不再局限于web,更多如weex、React Native等,而Diff就是对DOM操作过程的优化
查看源码前提
vue源码中存在大量工具函数如isUndef,isDef,isTrue等,是用来做类型判断的函数,接下来文章中出现的源码也使用到了这三个函数
export function isUndef (v: any): boolean %checks {
return v === undefined || v === null
}
export function isDef (v: any): boolean %checks {
return v !== undefined && v !== null
}
export function isTrue (v: any): boolean %checks {
return v === true
}
触发流程
那么从新建vue实例到数据更新,是在哪里使用了Diff呢,我们一起往下看:
当vue新建实例时会调用mountComponent
// src\platforms\web\runtime\index.js
import { mountComponent } from 'core/instance/lifecycle'
Vue.prototype.$mount = function () {
return mountComponent(this, el, hydrating)
}
// src\core\instance\lifecycle.js
export function mountComponent () {
// ...
updateComponent = () => {
vm._update(vm._render())
}
new Watcher(vm, updateComponent, noop, ...)
}
mountComponent会为实例创建Watcher,并将回调函数vm._update(vm._render())传入Watcher,这样数据更新后Watcher便可以通过_update更新视图,其中_render是用于生成对应Vnode树
// src\core\instance\render.js
Vue.prototype._render = function () {
// ...
return vnode
}
_update函数用于将新的Vnode更新至视图
// src\core\instance\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
// ...
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
其中__patch__就是将oldVnode和newVnode进行对比,并输出结果挂载至$el更新视图。从上面的源码我们可以看到,无论是初次加载还是数据变化引发的视图更新,都会调用__patch__ ,__patch__是Diff过程的主要使用场景
patch函数
那么__patch__函数是从哪里来的呢? __patch__其实就是patch函数挂载在Vue.prototype上的别名
patch函数是Diff过程的主要入口函数,是通过createPatchFunction创建新的patch函数并挂载到Vue.prototype上
// src\platforms\web\runtime\patch.js
export const patch: Function = createPatchFunction({ nodeOps, modules })
// src\platforms\web\runtime\index.js
import { patch } from './patch'
Vue.prototype.__patch__ = inBrowser ? patch : noop
patch函数是对oldVnode和newVnode的差异进行区分处理,并返回处理后的DOM元素
// src\core\vdom\patch.js
export function createPatchFunction (backend) {
// ...
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 如果新节点不存在,销毁旧节点
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
// 如果旧节点不存在,为新节点创建节点
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
// 新节点和旧节点都存在
const isRealElement = isDef(oldVnode.nodeType) // 是否为真实DOM元素
if (!isRealElement && sameVnode(oldVnode, vnode)) { // 如果新旧节点为同一类型,且旧节点是Vnode
// 进行更深层次的比较
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else { // oldVnode 为真实DOM
// 替换已有的元素
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// 创建新的DOM元素
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 递归更新父节点
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) {
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}
// 销毁旧的节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
// 返回新节点对应的DOM元素
return vnode.elm
}
}
patch函数中对于新旧节点为同一类型的Vnode,使用了patchVnode函数进行更称层次的处理
patchVnode 函数
// src\core\vdom\patch.js
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
const elm = vnode.elm = oldVnode.elm // 获取真实DOM
// 如果新旧节点都为静态且是被克隆或v-once只加载一次,直接赋值
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
const oldCh = oldVnode.children // 获取子节点
const ch = vnode.children
if (isUndef(vnode.text)) { // 新节点不是文本节点
if (isDef(oldCh) && isDef(ch)) { // 新旧节点的子节点都存在
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) // 不相同便更新
} else if (isDef(ch)) { // 旧子节点不存在
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') // 文本节点直接setTextContent
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) // 添加DOM
} else if (isDef(oldCh)) { // 新子节点不存在
removeVnodes(oldCh, 0, oldCh.length - 1) //移除DOM
} else if (isDef(oldVnode.text)) { // 新旧子节点都不存在
nodeOps.setTextContent(elm, '') // 删除文本
}
} else if (oldVnode.text !== vnode.text) { // 新节点是文本节点且文本不同
nodeOps.setTextContent(elm, vnode.text)
}
}
patchVnode 函数对新旧节点的子节点进行处理。如果两者都有子节点,则用updateChildren函数进行处理
updateChildren 函数
updateChildren 函数是Diff算法最重要的实现函数,对新旧节点的子节点进行对比和处理,使用首尾指针法,双指针的方式进行对比,以减少对比次数,提升性能
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // old首指针
let newStartIdx = 0 // new首指针
let oldEndIdx = oldCh.length - 1 // old尾指针
let oldStartVnode = oldCh[0] // old首指针当前指向的节点
let oldEndVnode = oldCh[oldEndIdx] // old尾指针当前指向的节点
let newEndIdx = newCh.length - 1 // new尾指针
let newStartVnode = newCh[0] // new首指针当前指向的节点
let newEndVnode = newCh[newEndIdx] // new尾指针当前指向的节点
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) // 检查key是否相同
}
// 循环对比首尾节点
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 对比过程 (*文章后面会对这部分详解*)
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, ...)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, ...)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, ...)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode, ...)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// ...
}
}
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)
}
}
查看updateChildren 函数的对比过程我们可以发现vue是如何应用首尾指针法的,看源码比较抽象,我们用图片简单概括一下:
通过每一轮四个指针的两两对比发现是否有可复用节点,再根据不同情况进行节点处理,下面我们看看updateChildren 函数中对于每一种情况是如何做处理的:
1、oldStartVnode节点不存在,oldStartIdx向后移动,进行下轮比较
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]
}
2、oldEndVnode节点不存在,oldEndIdx向前移动,进行下轮比较
else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
}
3、首首对比,oldStartVnode 和 newStartVnode 为相同节点 ,递归对比这两个节点的子节点,oldStartIdx和newEndIdx同时向后移动,进行下轮比较
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, ...)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
4、尾尾对比,oldEndVnode 和 newEndVnode 为相同节点 ,递归对比这两个节点的子节点,oldEndIdx和newEndIdx同时向前移动,进行下轮比较
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, ...)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
5、首尾对比,oldStartVnode 和 newEndVnode 为相同节点 ,递归对比这两个节点的子节点,newEndIdx向前移动,oldStartIdx向后移动,进行下轮比较
else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, ...)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
这里与之前步骤操作不同的是在递归处理子节点后做了移动处理,nodeOps为集成的真实DOM操作方法
// src\platforms\web\runtime\node-ops.js
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
parentNode.insertBefore(newNode, referenceNode)
}
export function nextSibling (node: Node): ?Node {
return node.nextSibling
}
export function createComment (text: string): Comment {
return document.createComment(text)
}
6、尾首对比,oldEndVnode 和 newStartVnode 为相同节点 ,递归对比这两个节点的子节点,同步骤5做移动处理,oldEndIdx向前移动,newStartIdx向后移动,进行下轮比较
else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode, ...)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
7、四个节点都不相同,使用createKeyToOldIdx将所有oldCh介于oldStartIdx和oldEndIdx之间的所有节点的key和index做一个映射,再去寻找是否有相同节点,做对应的处理
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// oldKeyToIdx => oldCh 中节点 key 和 index的集合
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// idxInOld => newStartVnode 在 oldCh 中相同节点(只是key相同)的index
if (isUndef(idxInOld)) { // 如果找不到相同节点
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] // 移动指针进行下一步
sameVnode函数
在查看updateChildren 函数源码时,我们不难发现,判断节点是否相同的方法就是sameVnode,这个方法对于我们日常开发很重要,为什么呢?让我们好好观察一下
function sameVnode (a, b) {
return (
// key 是否相同
a.key === b.key &&
// 异步组件
a.asyncFactory === b.asyncFactory && (
(
// tag标签是否相同
a.tag === b.tag &&
// 注释节点
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
// 是否为相同类型的input元素
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
其中最重要的判断依据就是key和tag,如果某个节点key值不是固定的,当数据发生更新时,即使这个节点不需要变化,很大概率会被重新渲染。在开发中我们经常会使用v-for,当我们使用数组的index作为key值时,如果数据发生变化导致,比如在数组某个下标中插入一条数据,我们只希望页面插入对应这条数据的DOM,但现实是这个下标后的节点都会被重新渲染。举个例子
<li v-for="(item, index) in list" :key="index">
{{item}}
</li>
...
data () {
return {
list: ['张三', '李四', '王五']
}
}
这时渲染对应的结果是
<li key="0">张三</li>
<li key="1">李四</li>
<li key="2">王五</li>
当向list中添加一个元素时
list.unshift('六六')
渲染对应的结果是
<li key="0">六六</li>
<li key="1">张三</li>
<li key="2">李四</li>
<li key="3">王五</li>
'张三','李四','王五'因为key值改变导致对应DOM被重新渲染了!,这个不是我们想看到的,所以在开发中尽量不要使用index作为key去使用。
另外如果开发遇到这样一种情况,统一页面下相同的组件或节点使用v-if去切换使用,但页面并未重新渲染,是v-if失效了吗?并不是,是被当作可复用节点了,这种情况只需要手动加上不同的key值,vue就不会当作相同节点了