likes
comments
collection
share

vue2渲染更新原理 - diff算法

作者站长头像
站长
· 阅读数 22

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算法流程分析

  1. 采用了四个指针,进行向中间逼近对比

    vue2渲染更新原理 - diff算法

  2. 首首对比成功则,复用节点且节点的dom不用移动,首部新旧指针均向右移一位,继续直到首首不一致

  3. 然后开始尾尾对比,尾尾对比成功则,复用节点且节点的dom不用移动,尾部新旧指针均向左移一位,继续直到尾尾也不一致

  4. 然后开始旧首新尾对比,对比成功则,复用节点但节点的dom要移动到剩余节点的对尾,然后首部旧指针右移一位,尾部新指针左移一位,此旧节点的首部变了新节点的尾部变了但首部没变,就又可以来一波首首、尾尾的对比了。

  5. 继续以上3种都没对比上,则开始旧尾新首对比,对比成功则,复用节点但节点的dom需移动到剩余节点的对首,然后尾部旧指针左移一位,首部新指针右移一位,同样首部节点变化了,就又可以来一波首首、尾尾的对比了。

  6. 上面比对全部没对比上,那么就用剩下的新首节点去剩下的旧节点中找sameVnode一致的节点,找到则,复用节点且将节点dom移动到剩余节点对首,并置空节点,找不到则直接拿新首节点去创建一个dom插入到剩余节点对首。最后首部新指针想右移一位,同样首部节点变化了,就又可以来一波首首、尾尾的对比了,直至一方循环完毕

  7. 如果旧节点循环完毕,新节点还有则剩余新节点创建dom并插入到之前剩余的队尾

  8. 如果新节点循环完毕,旧节点还有则移除剩余的旧节点

至此vue2的渲染流程完毕;

总结下就是:

响应式数据变动 -> 渲染wather 触发更新 -> patch 组件根节点 -> 不同直接销毁重建 -> 相同则 调用 patchVnode复用节点并更新属性 -> 如果都有子节点则调用 updateChildern 使用双端对比算法确定子节点的复用情况