vue2渲染更新原理 - diff算法
vue2渲染更新原理
上一章我们介绍了vue2首次渲染的基本流程,这次主要介绍vue2更新渲染的原理。
流程
先讲更新渲染基本流程,接上一章,vue在首次渲染时,在mountCompent
创建了 渲染watcher
。那么根据之前讲的响应式原理,data
的响应式数据发生变动 -> 触发渲染watcher
更新 ->调用 updateComponent
-> 调用_render
生成 VNode
-> 调用 _update
// core/instance/lifecycle.js
// 省略部分其他代码
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
// 缓存旧节点
const prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
// 首次渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 更新
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
patch
调用 patch
方法 传入新旧节点对比
// core/vdom/patch.js
// 省略部分代码
function patch (oldVnode, vnode, hydrating, removeOnly) {
// 只有旧节点,没有新节点,则毁旧节点
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
// 没有旧节点, 直接渲染新节点
if (isUndef(oldVnode)) {
createElm(vnode)
} else {
// oldVnode是否是真实节点,存在即上面首次渲染时直接传$el的情况
const isRealElement = isDef(oldVnode.nodeType)
// oldVnode是虚拟节点,且和新建节点是同一节点
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 对比新旧节点
patchVnode(oldVnode, vnode)
} else {
// oldVnode是真实节点,即根节点首次渲染
if (isRealElement) {
// 根据oldVnode的真实dom,生成一个无子节点的空节点赋给oldVnode
oldVnode = emptyNodeAt(oldVnode)
}
// 获取旧真实dom,和其父dom
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// 无论是首次渲染还是,新旧节点不一致的情况,都会根据新vnode创建新的dom
createElm(
vnode,
parentElm,
nodeOps.nextSibling(oldElm)
)
// 最后销毁旧节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
// 返回生成好的新真实dom
return vnode.elm
}
更新渲染时,分两种情况,
第一种是新旧节点不一致了,那么就直接根据新的VNode
生成真实dom,然后销毁旧节点。
第二种是新旧节点是同一类型的,那么就不销毁旧节点,而是调patchVnode
做进一步对比。
sameVnode
先看 sameVnode
怎么判断节点是否是一致的:
// core/vdom/patch.js
function sameVnode (a, b) {
return (
a.key === b.key && // key相同, 未设置就都是 undefined
a.asyncFactory === b.asyncFactory && ( // 是否都是异步组件或都不是
(
a.tag === b.tag && // tag相同
a.isComment === b.isComment && // 是否都是注释节点或都不是
isDef(a.data) === isDef(b.data) && // 是否都定义了data
sameInputType(a, b) // 如果都是input标签,则input的type也要相同,这是个特殊处理
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error) // 也是异步组件相关的,代表 旧节点是异步组件的占位节点, 新节点是异步组件加载完成且未出错的情况
)
)
)
}
其实简单点就是 新旧节点 key和tag要相同,就判定为 是同一类型的节点,旧节点不用销毁,其他的就是各种特殊情况处理。这里就是要求我们节点要写key的原因了
上面说的第一种情况就是创建新节点,那就和首次渲染没区别了,这里就不在讲了,
看第二种情况,新旧节点类型一致,调用 patchVnode
更仔细的处理
patchVnode
// core/vdom/patch.js
// 省略部分其他逻辑代码
function patchVnode (oldVnode,vnode) {
if (oldVnode === vnode) {
return
}
// 新节点复用旧节点的dom, 就是说sameVnode一致后,当前dom节点就会复用,然后只会去更新
const elm = vnode.elm = oldVnode.elm
let i
const data = vnode.data
// 组件类型节点对比前的操作,主要逻辑是复用旧组件的实例,同时更新 props,attrs,listeners,slot等相关属性
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)) {
// 更新dom节点的各种属性
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)) {
// 对比新旧节点的子节点, 大名鼎鼎的diff算法就在这里,就是新旧节点的子节点处理
if (oldCh !== ch) updateChildren(elm, oldCh, ch)
// 只有新节点存在子节点时
} else if (isDef(ch)) {
// 旧节点没有子节点,但它可能是文本节点,所以这里要删除文本
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
// 旧节点没有子节点,新节点有,就直接新建子节点dom并插入到当前dom下
addVnodes(elm, null, ch, 0, ch.length - 1)
// 只有旧节点存在子节点时
} else if (isDef(oldCh)) {
// 删除所以子节点
removeVnodes(oldCh, 0, oldCh.length - 1)
// 都没有子节点且旧节点是文本节点
} else if (isDef(oldVnode.text)) {
// 删除旧节点的文本,即清空旧节点
nodeOps.setTextContent(elm, '')
}
// 新节点是文本节点,则直接给dom设置文本内容
} 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)
}
}
可以看到调patchVnode
精细对比两节点时,会直接复用旧节点的dom,然后分各种情况对比操作dom更新,如果是组件节点,还会复用组件实例,调用对应的钩子。然后就是处理子节点的更新。只有在都存在子节点则会调用updateChildren
对比子节点,否则就根据情况直接删除旧节点的子节点或者新建新节点的子节点。
updateChildren
updateChildren
就是diff算法所在的地方了
为啥需要diff算法?
因为直接操作dom是一件很费性能的事,而虚拟dom(VNode)就是个简单的js对象,直接操作js对象运算一件很轻松的事,所以尽可能的减少dom操作就能有效的提升性能,所以diff算法主要就是为了用更少的dom操作去完成子节点的复用以及删除或新建,以节约性能。
vue2采用的双端对比的算法来实现子节点diff
// core/vdom/patch.js
// 省略部分其他逻辑代码
function updateChildren (parentElm, oldCh, newCh ) {
let oldStartIdx = 0 // 旧子节点起始下标
let newStartIdx = 0 // 新子节点起始小白
let oldEndIdx = oldCh.length - 1 // 旧子节点结尾下标
let newEndIdx = newCh.length - 1 // 新子节点结尾下标
let oldStartVnode = oldCh[0] // 第一个旧子节点
let oldEndVnode = oldCh[oldEndIdx] // 最后一个旧子节点
let newStartVnode = newCh[0] // 第一个新子节点
let newEndVnode = newCh[newEndIdx] // 最后一个新子节点
let oldKeyToIdx, idxInOld // 下面会讲
// 只有当新旧子节点的指标的起始位置都不大于其结束位置的时候,才能循环;
// 一方的开始位置大于结束位置,说明该方循环完毕,需要结束循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 如果旧子节点被置空了,dom被移走了,直接跳过
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) // 复用子节点并递归对比子节的节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
//首首对比完成后,开始新旧子节点尾尾对比,相同则新旧子节点尾部指针向左进一,接着对比
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode) // 复用子节点并递归对比子节的节点
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// 首首尾尾对比完成后,
// 再开始首尾对比,
// 相同则新子节点复用旧子节点并更新好属性,递归完孙节点后
// 将复用后的真实dom,移动到dom节点的对尾,也不是真实的队尾就是去除调前面首首尾尾对比后剩下节点的队尾, 细品
// 旧子节点向右进一, 新子节点向左进一
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode) // 复用子节点并递归对比子节的节点
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
// 对比完首尾后再进行尾首对比
// 相同则新子节点复用旧子节点并更新好属性,递归完孙节点后
// 将复用后的真实dom,移动到dom节点的对首,同样不是真实的队首就是去除调前面对比后剩下节点的队首
// 旧子节点向左进一, 新子节点向右进一
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode)
parentElm.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
// 最后都没对比上,就手动根据key找节点复用
} else {
// 创建一个剩余旧子节点的,key 和 index 的 映射, 方便新子节点直接根据key找到其在旧子节点中的位置
if (isUndef(oldKeyToIdx)) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// 新子节点在旧子节点中的位置
idxInOld = isDef(newStartVnode.key)
// 新子节点有key,则直接到刚刚建的key到index的映射中拿
? oldKeyToIdx[newStartVnode.key]
// 新子节点要是没有key, 则拿新子节点到剩余的旧子节点中去循环调sameVnode对比找
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 新子节点在旧子节点中找不到,则直接新建新子节点的dom并插入剩下节点的队首
if (isUndef(idxInOld)) {
parent.insertBefore(createElm(newStartVnode), oldStartVnode.elm);
} else {
vnodeToMove = oldCh[idxInOld]
// 对于刚刚找到的新旧子节点,之前只是用key去找的,sameVnode判定里还有tag之类的条件
// 如果相同,则复用旧子节点
// 将旧子节点dom移动到剩余节点对首
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode)
oldCh[idxInOld] = undefined // 老节点对应位置置空
parentElm.insertBefore(vnodeToMove.elm, oldStartVnode.elm)
} else {
// 相同 key 但是 tag 之类不同的话也不能复用
parent.insertBefore(createElm(newStartVnode), oldStartVnode.elm);
}
}
// 将新子节点像右移一步, 再回去 首首、尾尾。。。。循环的对比
newStartVnode = newCh[++newStartIdx]
}
}
// 如果旧子节点循环完毕了,但是新子节点还有
if (newStartIdx <= newEndIdx) {
// 此时newStartIdx并非为0,而是等于oldCh比对完时,newCh所处的位置
// 遍历newCh剩余的节点,生成真实dom,插入到parent中
for (let i = newStartIdx; i <= newEndIdx; i++) {
// 这是一个优化写法 insertBefore的第二个参数是null等同于appendChild作用
const anchor =
!!newCh[newEndIdx + 1] ? null : newCh[newEndIdx + 1].el;
parent.insertBefore(createElm(newCh[i]), anchor);
}
}
// 如果新子节点循环完毕,旧子节点还有
if (oldStartIndex <= oldEndIndex) {
// 遍历oldCh剩余的节点,将他们从parent中删除
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
let child = oldCh[i];
// 老节点的dom可能被移走,自身被置空了,所以要判断下
if (child) {
parent.removeChild(child.el);
}
}
}
}
// 创建key到index的映射
function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}
// 去剩余的老子节点里,循环查找 sameVnode 一致的节点
function findIdxInOld (node, oldCh, start, end) {
for (let i = start; i < end; i++) {
const c = oldCh[i]
if (isDef(c) && sameVnode(node, c)) return i
}
}
diff算法流程分析
-
采用了四个指针,进行向中间逼近对比
-
首首对比成功则,复用节点且节点的dom不用移动,首部新旧指针均向右移一位,继续直到首首不一致
-
然后开始尾尾对比,尾尾对比成功则,复用节点且节点的dom不用移动,尾部新旧指针均向左移一位,继续直到尾尾也不一致
-
然后开始旧首新尾对比,对比成功则,复用节点但节点的dom要移动到剩余节点的对尾,然后首部旧指针右移一位,尾部新指针左移一位,此旧节点的首部变了新节点的尾部变了但首部没变,就又可以来一波首首、尾尾的对比了。
-
继续以上3种都没对比上,则开始旧尾新首对比,对比成功则,复用节点但节点的dom需移动到剩余节点的对首,然后尾部旧指针左移一位,首部新指针右移一位,同样首部节点变化了,就又可以来一波首首、尾尾的对比了。
-
上面比对全部没对比上,那么就用剩下的新首节点去剩下的旧节点中找sameVnode一致的节点,找到则,复用节点且将节点dom移动到剩余节点对首,并置空节点,找不到则直接拿新首节点去创建一个dom插入到剩余节点对首。最后首部新指针想右移一位,同样首部节点变化了,就又可以来一波首首、尾尾的对比了,直至一方循环完毕
-
如果旧节点循环完毕,新节点还有则剩余新节点创建dom并插入到之前剩余的队尾
-
如果新节点循环完毕,旧节点还有则移除剩余的旧节点
至此vue2的渲染流程完毕;
总结下就是:
响应式数据变动 -> 渲染wather
触发更新 -> patch
组件根节点 -> 不同直接销毁重建 -> 相同则 调用 patchVnode
复用节点并更新属性 -> 如果都有子节点则调用 updateChildern
使用双端对比算法确定子节点的复用情况
转载自:https://juejin.cn/post/7189579653144084536