前言
我们知道,vue的响应式基础是建立在数据拦截和发布订阅模式上的,创建vue实例时,先遍历数据的属性,为每一个对象和子对象添加_ob_,达到数据拦截,初次渲染时,扫描树中每一个节点,计算值,如果用到了data某个属性,则生成一个watcher,并触发get,将watcher放入dep的subs中。后续数据发生变化,触发set函数,dep的notify执行,通知所有监听此数据的watcher,进行更新。
但是,这样的做法其实是1.x时采用的策略模式。这种做法存在一个很大的弊端,就是每一个用到数据,可能产生动态变化的地方都生成了一个watcher,虽然每个数据变化都可以通知到页面的每一个地方进行及时更新,但是当页面的规模变大,变复杂,对应的watcher将变得十分庞大,带来的性能问题也就比较明显了
Vnode 和 Diff ----- 减少页面更新开销
在vue2中,正式引入了vnode和diff,同时将watcher的颗粒度放大到组件,一个组件对应着一个watcher,当数据变化,dep通知watcher,此时watcher是一个组件了,这时候怎么知道组件的哪个部分使用了数据呢?vnode和diff便应运而生!当dep通知到watcher时,watcher实例化时传递的updateComponent
函数将会被调用,我们看看这个过程发生了什么
updateComponent
/src/core/instance/lifecycle.js
//在实例化watcher时丢入updateComponent
new Watcher(
vm,
updateComponent,
noop,
watcherOptions,
true /* isRenderWatcher */
)
let updateComponent
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
updateComponent = () => {
```
}
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
可以看到,这里有一个vm,vm就是组件实例,是在实例化vue组件时传进来的。vm.render()
做的事情,就是通过vue实例生成VNode,将 VNode 传递给 _update 方法
_update
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el //先前的元素
const prevVnode = vm._vnode //如果是第一次进入,则先前vm为空,如果是后续渲染更新,之前的vm一定经历过_render,则一定存在_vnode,即先前的vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode //先用新vnode替换原本的vnode(因为无论后续diff如何查找,旧的总是要被新的替换)
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// initial render
//初次渲染,先前没有元素
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
//更新,走这里
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// if parent is an HOC, update its $el as well
let wrapper: Component | undefined = vm
while (
wrapper &&
wrapper.$vnode &&
wrapper.$parent &&
wrapper.$vnode === wrapper.$parent._vnode
) {
wrapper.$parent.$el = wrapper.$el
wrapper = wrapper.$parent
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}
patch
从上面可以看到,无论是初次渲染,还是后续更新渲染,都是要经过_update的。这个函数首先更新vnode,然后判断是否是第一次,执行不同的_patch_
函数。这个函数就是我们一直想要介绍的diff算法所在的地方。这个函数实际上和dom操作有关,所以中间转了几次,最后只需要关注
patch函数就好了。在这之前,先看一下一些工具函数的定义,以便阅读
src/core/vdom/patch.js
//参数分别代表,老vnode,新vnode,是否服务端渲染,只移除操作
return function patch(oldVnode, vnode, hydrating, removeOnly) {
//如果新节点不存在,直接把老节点删掉
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue: any[] = []
if (isUndef(oldVnode)) {
//如果新vnode存在,老节点不存在,那么直接用新vnode创建新节点插入,这种情况会在第一次渲染出现
// empty mount (likely as component), create new root element
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
//这一步实际上是判断是否是第一次渲染,第一次渲染传进来是真实元素
const isRealElement = isDef(oldVnode.nodeType)
//如果不是真实元素且两者是同一节点,执行patchVnode函数
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
//初次渲染 ,后续一大段都是初始化,还要判断是否是服务端渲染
if (isRealElement) {
// mounting to a real element
// check if this is server-rendered content and if we can perform
// a successful hydration.
// 挂载到真实元素以及处理服务端渲染的情况
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
} else if (__DEV__) {
warn(
'The client-side rendered virtual DOM tree is not matching ' +
'server-rendered content. This is likely caused by incorrect ' +
'HTML markup, for example nesting block-level elements inside ' +
'<p>, or missing <tbody>. Bailing hydration and performing ' +
'full client-side render.'
)
}
}
// either not server-rendered, or hydration failed.
// create an empty node and replace it
oldVnode = emptyNodeAt(oldVnode)
}
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
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)
)
// update parent placeholder node element, recursively
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)
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
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
}
}
// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
从上面源码我们可以得到以下结论
- 如果没有新节点,直接把老节点销毁
- 如果有新节点没有老节点,直接根据vnode创建新节点插入dom树
- 老节点有了,就判断是否是真实元素,代表着是否是初次渲染
- 如果不是真实元素,且新旧节点一致,则进行patch对比
- 否则,则代表初次渲染或者节点直接变了,判断是否是服务端渲染等等
patchVnode
接下来,就是diff算法了,我们看看patchVnode
函数
function patchVnode(
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly?: any
) {
//新老节点(对象)一致,则完全无差异,直接返回
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
}
//跳过静态节点的更新
// reuse element for static trees.
// note we only do this if the vnode is cloned -
// if the new node is not cloned it means the render functions have been
// reset by the hot-reload-api and we need to do a proper re-render.
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
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)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch)
//最核心的diff算法!!!
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
//老节点没有子元素,那么,创建这些新孩子节点
if (__DEV__) {
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)
}
if (isDef(data)) {
if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode)
}
}
总结:
- 如果新老vnode完全一致,直接返回
- 跳过静态节点
- 判断子元素:
- 如果新节点有孩子,老节点没孩子,则新增新节点的这些孩子节点
- 如果老节点有孩子,新节点没孩子,则删除老节点的这些孩子
- 两者都有孩子且至少一方是文本节点,则直接替换
- 两者都有孩子且均不为文本节点,进入最核心的diff算法
updateChildren
这里最核心的diff算法,就是 updateChildren
函数!我们熟悉的旧前旧后,新前新后就是在这
/**
* diff 过程:
* diff 优化:做了四种假设,假设新老节点开头结尾有相同节点的情况,一旦命中假设,就避免了一次循环,以提高执行效率
* 如果不幸没有命中假设,则执行遍历,从老节点中找到新开始节点
* 找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置
* 如果老节点先于新节点遍历结束,则剩余的新节点执行新增节点操作
* 如果新节点先于老节点遍历结束,则剩余的老节点执行删除操作,移除这些老节点
*/
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是一个特殊的标志,仅由 <transition-group> 使用,以确保被移除的元素在离开转换期间保持在正确的相对位置
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
// 检查新节点的 key 是否重复
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]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 老开始节点和新开始节点是同一个节点,执行 patch
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// patch 结束后老开始和新开始的索引分别加 1
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 老结束和新结束是同一个节点,执行 patch
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// patch 结束后老结束和新结束的索引分别减 1
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 老开始和新结束是同一个节点,执行 patch
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 处理被 transtion-group 包裹的组件时使用
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// patch 结束后老开始索引加 1,新结束索引减 1
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
// 老结束和新开始是同一个节点,执行 patch
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// patch 结束后,老结束的索引减 1,新开始的索引加 1
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 如果上面的四种假设都不成立,则通过遍历找到新开始节点在老节点中的位置索引
// 找到老节点中每个节点 key 和 索引之间的关系映射 => oldKeyToIdx = { key1: idx1, ... }
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)) {
// 如果这两个节点是同一个,则执行 patch
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// patch 结束后将该老节点置为 undefined
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 最后这种情况是,找到节点了,但是发现两个节点不是同一个节点,则视为新元素,执行创建
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)
}
}
总结:
- 循环新老孩子
- 新前与新前比较,如果相同,执行patch,同时移动startIndex
- 新后与旧后比较,如果相同,执行patch,则同时移动endIndex
- 新前与旧后比较,如果相同,执行patch,new startIndex和old endIndex移动
- 新后与旧前比较,如果相同,执行patch,new endIndex 和 old startIndex 移动
- 上述四条任何一条成立,退出当前循环进入下一次
- 上述四条均不成立,则新节点的头节点去老节点孩子数组中查找,找不到代表是新的,直接添加;找到了但是不相同,仍然创建添加;找到了并且相同,执行patch,startIndex移动
- 当循环结束,如果老孩子还有剩余,删除;如果新孩子还有剩余,添加;