前言
本文比较长,主要篇幅都是在介绍 diff 算法,在了解 diff 算法之前,有必要了解一下 Vue 为什么需要 diff 算法,以及 Vue 在什么时刻会使用到 diff 算法,我们先来看看这两个问题。
Vue 为什么需要 diff 算法?
diff 算法是 虚拟 DOM 的必然产物,通过对新旧虚拟 DOM 的比对,将变化的地方更新到真实 DOM 上,可以尽可能减少不必要的 DOM 操作,提升性能。
前文 介绍响应式原理说到,一个组件对应一个 Watcher 实例,当状态被修改时,相关的 Watcher 实例会被通知更新,此时对应的组件都会重新 render 并生成新的 vnode,一个组件有大有小,总会有不需要更新的地方,若整个组件都进行 DOM 更新,对性能影响将是极大的,因此需要使用 diff 算法,通过新旧 vnode 比对,从而找到实际需要更新的结点,再进行更新,这个过程称为 patch。
在 Vue 内部,组件的挂载、更新、移除都是经过这个 patch 的阶段,介绍 patch 之前先来了解一下 vue 会在什么时刻执行 patch。
执行 patch 的时刻
Vue.prototype.$mount
执行 new Vue 时传入 el 参数,内部会调用 $mount,或直接通过 new Vue().$mount('#app') 挂载组件,Vue.prototype.$mount() 实际上是调用了 mountComponent
// core/stance/init.js
Vue.prototype._init = function (options) {
// ...
const vm = this;
if (vm.$options.el) {
vm.$mount(vm.$options.el);
}
}
// platforms/web/runtime/index.js
Vue.prototype.$mount = function (el, hydrating) {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
mountComponent
从函数名字就知道这个函数是用于挂载组件的,执行这个函数时做了以下几件事
- 调用生命周期函数
beforeMount - 定义一个函数
updateComponent,这个函数内部会执行vm._update,而vm._update的参数是vm._render()执行后的结果,即生成新的 vnode - 创建一个
Watcher实例,并将updateComponent作为参数,在组件挂载和更新时会调用这个函数
下面是 mountComponent 和 Watcher 涉及到的主要代码
function mountComponent (vm, el) {
callHook(vm, 'beforeMount')
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
}
// src/core/observer/watcher.js
export default class Watcher {
constructor(vm, expOrFn, cb, options, isRenderWatcher) {
this.getter = expOrFn // updateComponent 作为 expOrFn 参数保存在 getter 属性
this.value = this.lazy ? undefined : this.get() // 挂载时会调用一次
}
get () {
// ...
value = this.getter.call(vm, vm)
// ...
}
// 通知更新,会调用 getter
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this) // 异步队列更新的方式,最后也会调用到 run,此处不再赘述
}
}
run () {
// ...
const value = this.get()
}
}
vm._update
现在我们知道组件挂载和更新都会调用 _update 方法,它接收重新 render 生成的 vnode 作为参数,在内部可以获取到当前的 vnode (preVnode),然后分下面两种情况进行处理
- 没有 preVnode:当前没有旧 vnode,是挂载阶段,调用
__patch__传入 DOM 元素$el和新的vnode - 有 preVnode:代表当前是更新阶段,调用
__patch__传入preVnode和新的vnode
两种情况都调用同一个函数,只是传入的参数不一样,因此在 __patch__ 内部还需要判断分情况处理
Vue.prototype._update = function (vnode, hydrating) {
var vm = this;
var prevEl = vm.$el;
var prevVnode = vm._vnode;
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode);
}
}
vm.__patch__
vm.__patch__ 也是原型对象上的方法,定义如下
在浏览器才需要 patch,服务端渲染没有 DOM 操作和挂载的概念,不需要 patch
// src/platforms/web/runtime/index.js
Vue.prototype.__patch__ = inBrowser ? patch : noop
继续查找,patch 函数是调用 createPatchFunction 返回的
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'
// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })
createPatchFunction 接收两个参数,这里大概了解一下分别是什么
nodeOps是web平台下一些 DOM API 的封装,在 patch 阶段,自然少不了要操作DOMmodules是一些核心的模块,如 attr, class, style, event, directive, ref 等,这些模块对外暴露 create, update, remove, destory 等API,在 patch 阶段会被调用
createPatchFunction 代码很多,声明了很多函数,在 patch 的各个阶段方便调用,可见 patch 逻辑是很复杂的,但是这里我们只关注 diff 算法的实现,其他就不过多介绍了,下面来看 patch 函数的定义
export function createPatchFunction (backend) {
return function patch (oldVnode, vnode, hydrating, removeOnly) { }
}
patch 接收 oldVnode 和 vnode,然后根据两个参数的值分不同的情况进行处理,终于要探究 patch 的实现原理了,接下来就来逐步分析代码,了解 vue 如何实现 diff 算法。
patch 阶段工作原理
patch 方法的代码很多,但基本上只是条件判断,再执行不同的操作,diff 算法的核心代码主要是当新旧 vnode 是对应相同元素时,继续比较它们的子结点及如何实现高效的对比,因此这里只需了解一下大概的流程即可,下面是 patch 方法执行的流程图
graph TD
patch[Start] --> vnode{vnode is undef?};
vnode -->|undefined| oldVnode1{oldVnode is undef?};
oldVnode1 -->|No undef| 移除oldVnode --> return;
oldVnode1 -->|undefined| return;
vnode -->|No undef| oldVnode2{oldVnode is undef};
oldVnode2 -->|undefined| createEl[createElm];
oldVnode2 -->|vnode && oldVnode| condition{条件判断};
condition -->|ov 是 VNode 实例且 samenode| patchVnode;
condition -->|ov 是$el| mount;
condition -->|ov 是 VNode 实例且 !samenode| cr[创建新DOM移除旧节点];
下面是涉及到的主要代码和注释,核心的流程就是符合条件 sameVnode(oldVnode, vnode) 然后调用 patchVnode,后面会介绍
function patch (oldVnode, vnode) {
// 如果没有新vnode,则删除旧vnode(如果有),直接return
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
// 没有 oldVnode,无需diff,直接新建元素
if (isUndef(oldVnode)) {
createElm(vnode, insertedVnodeQueue)
} else {
// oldVnode、vnode 都不是 undefined/null 的情况
// 由前面调用 `__patch__` 可知 oldVnode 可能是真实DOM元素,或者是 Vnode 实例
const isRealElement = isDef(oldVnode.nodeType)
// diff算法的核心,若 oldVnode 不是真实 DOM,则调用 sameVnode 判断是否对应同一元素,是则调用 patchVnode
if (!isRealElement && sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 没得对比,肯定是要新建元素插入了
if (isRealElement) { /* ... */ }
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)
}
}
}
}
sameVnode
sameVnode 用于判断新旧 vnode 是否对应同一元素,如果是,则可以考虑原地复用,如直接修改 textContent,或者有子结点的情况就继续递归比较子结点,否则不会继续比较,直接替换
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
)
)
)
}
sameVnode 核心代码如上面,当以下条件全部满足时,则判定为 sameVnode
- key 虚拟DOM中使用唯一的key来区分元素,代表元素是完全独立的,若key不同,则不是 sameVnode,在使用
v-for渲染列表时总要添加 key,防止列表增删时由于元素被复用导致显示错乱 - tag 标签名必须相同
- isComment 是否注释结点,必须同为是或同为否,出现注释结点的情况,比如使用动态组件
component时,is的值如果变成 false、null、undefined,vue 就会生成一个注释结点<!----> - data 包含 style、attrs 等具体的绑定数据
- sameInputType 如果同为
input元素,还要满足type相同
patchVnode
来到 patchVnode,则表示新旧结点已是 sameVnode 了,但还需要分多种情况进行处理,下面是主要代码
function patchVnode (oldVnode, vnode) {
if (oldVnode === vnode) return
const elm = vnode.elm = oldVnode.elm
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, ''); }
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) {
nodeOps.setTextContent(elm, vnode.text);
}
}
这个函数主要做了以下几件事
- 若
oldVnode和vnode是同一个对象,说明无需更新,直接 return - 接下来要进行条件判断,先将 vnode 对应的元素保存在
elm - 若
vnode有文本结点,则直接更新 text 即可 - 若
vnode没有文本结点,分以下几种情况- 若新旧
vnode都有children,且不是指向同一数组,则调用updateChildren,优先对子层级进行比较 - 若只有 vnode 有
children,则调用addVnodes新建子结点,若存在oldVnode.text,要将其置空 - 反之,若只有 oldVnode 有
children,则执行removeVnodes移除子结点 - 若新旧 vnode 都没有
children,只有 oldVnode 有文本节点,则文本修改为空
- 若新旧
这里分多种情况,主要是为了区分元素是新增、更新或删除,以及判断元素是文本结点还是有子结点,都比较好理解,diff 算法核心部分在 updateChildren,接下来就来分析这个函数的代码
updateChildren
updateChildren 是在新旧 vnode 的子层级中,分别找到无需更新、可复用、增删的结点,如果直接在两个数组中查找,时间复杂度是 O(n^2),直接遍历比较显然是不可取的,为了提升效率,Vue 依次采用了以下几种策略优化算法
头尾两两比较
对于列表组件中的重新排序、修改某一项这些操作,并不会替换整个列表,其中头尾结点可能位置不变,或者元素移动到其他位置了,或者元素可以原地复用,结合这种场景,vue 采用了头尾两两比较的策略,下面是主要的代码
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]
// ...
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 {
// ....
}
}
}
这部分代码做了什么呢?
- 创建 4 个索引下标,初始化指向新旧 children 的头尾共 4 个位置
- 用 4 个变量来存储每次遍历 4 个索引下标所在的结点
- 当两个数组都还有待比较的结点时,则循环进行以下判断,以下条件若成立则跳过后面的判断直接开始下一轮循环
- 没有 oldStartVnode,直接将 oldStartIdx 向后移动一个位置并更新 oldStartVnode 的值
- 没有 oldEndVnode,直接将 oldEndIdx 向前移动一个位置并更新 oldEndVnode 的值
- 判断新旧头 vnode 是否 sameVnode,是则调用
patchVnode,将两个头指针向后移并更新对应变量的值 - 判断新旧尾 vnode 是否 sameVnode,是则调用
patchVnode,将两个尾指针向前移并更新对应变量的值 - 判断oldStartVnode 与 newEndVnode是否 sameVnode,是则表示该节点跑到后面了,调用
patchVnode,然后将节点移动到新的位置,将 oldStartIdx 向后移,newEndIdx 向前移 - 同上,判断 oldEndVnode 与 newStartVnode
下面结合例子来说明,p 节点有 a、b、c 三个子元素,重新排序(不考虑增删的情况)后触发组件更新,此时通过以下几个步骤来对比子层级
oldStartVnode与newStartVnode值都是 a,符合 sameVnode 直接调用 patchVnode,将 oldStartVnode 修改为 b,newStartVnode 修改为 c,此时 oldStartIdx = 1,newStartIdx = 1,其他不变- 第二次循环,发现符合
sameVnode(oldStartVnode, newEndVnode),说明 b 更新后会跑到后面了,先调用 patchVnode 优先比较子层级,子层级处理完成后,移动 b 到新的位置,再将 oldStartVnode 修改为 c,newEndVnode 修改为 c,此时 oldStartIdx = 2,newEndIdx = 1 - 第三次循环,发现符合
sameVnode(oldStartVnode, newStartVnode),调用 patchVnode,此时 oldStartIdx = 3,newStartIdx = 2 - 退出循环,到此完成 diff 操作
p p
/ | \ ==> / | \
a b c a c b
利用 key 建立映射
因为每次只能对头尾4个节点进行比较,若是结点移动到中间的位置,上面的方法就查找不到匹配的结点了,只能在 oldch 中查找与 newStartVnode 匹配的结点,我们通常会添加 key,这个时候作用就凸显出来了,建立 oldch key to idx 的 map,直接通过 newStartVnode 的 key 就能找到对应的 oldVnode,其他 key 不同的结点自然不需要考虑
通过 key 找到对应 oldVnode,再比较是否 sameVnode,是则调用 patchVnode,待完成子树的 diff 之后,移动oldVnode到新的位置,否则创建新结点
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if {
// ...
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key] // 直接利用key在映射表查找
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) // 没有 key,要遍历查找
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]
}
}
}
再举个例子进行说明,p 节点有 a、b、c、d 四个子元素,重新排序后触发组件更新,此时通过以下几个步骤来对比子层级
- 此时头尾两两比较都无法找到符合条件的
- 接下来利用 key 来查找,
newStartVnode此时是 c,在 oldch 中的索引是 2,然后判断是否符合 sameVnode,是则处理之,然后将newStartVnode修改为 a,下一轮又可以通过两两比较的方式很快找到 oldch 中 a 的位置了 - 依次比较直到完成 diff 操作退出循环
p p
/ / \ \ ==> / / \ \
a b c d c a d b
遍历查找
若没有 key,就只能遍历 oldch 逐个比较了,调用 findIdxInOld 进行查找,这里时间复杂度会比较高,但是有添加 key 就不会走到这一步,只是做一个兜底
新增或删除的情况
如果有增删的情况,oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx 中必然有一个条件不成立退出循环,若是 oldStartIdx > oldEndIdx,则代表有新增结点,调用 addVnodes 批量新建结点;反之代表要将某些旧结点移除。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {}
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)
}
}
到这里,一个组件更新过程的 diff 操作就结束了,对应更新、新增、删除的结点都会一一更新到真实 DOM,然后继续下一个 Watcher 调用更新,直到所有 watcher 都更新完成,就会执行 nextTick 中的回调函数了。。。
总结
diff 算法是在 全量更新dom 和 O(n^3)的精确查找 两个极端之间权衡取最优的方案,既要尽可能找到需要更新的结点,并且排除不必要的 DOM 更新,又要兼顾算法复杂度,避免带来性能问题。diff 算法具有下面两个特点:
- 深度优先:若新旧vnode是sameVnode,则优先比较子结点,这个操作是递归的,若不是 sameVnode,则不会继续往下比较了,因为存在可复用子结点的概率较低,即使存在,继续比较也是得不偿失的
- 同层比较:只会比较同层的结点,如果更新后中间多了一层,这种情况是直接判断为不可比较,直接全部更新或删除,不过一般我们也不会写出这样的代码
学习完 diff 算法的原理,我们可以有意识地编写更高质量的代码,帮助 diff 算法更加高效地执行,比如
- 使用
v-for渲染列表,要添加 key 提高判断、查找的效率,也能防止元素被错误复用出现bug - 使用
v-if/v-else控制元素切换时,不添加key可以让vue高效复用元素 - 结合深度优先,同层比较的概念,优化结点层级关系,尽可能提高 patch 的效率