Vue源碼閱讀(六):數據更新算法--patch算法

數據更新的過程回顧

在組件實例初始化的過程當中,Watcher實例與updateComponent建立了關聯。node

let updateComponent = () => {
      //更新 Component的定義,主要作了兩個事情:render(生成vdom)、update(轉換vdom爲dom)
      vm._update(vm._render(), hydrating)
  }
複製代碼

重點關注vm._update()。查看/core/instance/lifecycle.js:web

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    ...//省略
    if (!prevVnode) {
      //初始化渲染
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    ...//省略
  }
複製代碼

此處的vm.__patch__()是在源碼入口階段的platforms/web/runtime/index.js中定義:算法

import { patch } from './patch'
//定義補丁函數,這是將虛擬DOM轉換爲真實DOM的操做,很是重要
Vue.prototype.__patch__ = inBrowser ? patch : noop
複製代碼

進入到platforms/web/runtime/patch.js:數組

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'

const modules = platformModules.concat(baseModules)

//引入平臺特有代碼,其中: nodeOps是節點操做,modules是屬性操做
export const patch: Function = createPatchFunction({ nodeOps, modules })
複製代碼

createPatchFunction({nodeOps, modules})中的兩個參數nodeOps與modules都是和特定平臺相關的代碼。其中:瀏覽器

nodeOps是節點操做,定義各類原生dom基礎操做方法。 modules 是屬性操做,定義屬性更新實現。bash

createPatchFunction在文件core/vdom/patch.js中。該文件是整個Vue項目中最大的文件,詳細記錄了patch的過程。下面咱們來詳細看看patch算法的原理與實現。dom

patch算法

patch算法經過新老VNode樹的對比,根據比較結果進行最小單位地修改視圖(打補丁的由來),而不是將整個視圖根據新的VNode重繪。patch的核心在於diff算法。async

此處輸入圖片的描述

diff算法使用同層的樹節點進行比較(而非對整棵樹進行逐層搜索遍歷),時間複雜度只有O(n),是一種至關高效的算法。示意以下圖:兩棵樹之間,相同顏色的方塊表明互相進行比較的VNode節點。函數

此處輸入圖片的描述

patch()

看看patch算法的源碼:oop

return function patch (oldVnode, vnode, hydrating, removeOnly) {
    //新節點不存在,老節點存在,直接移除老節點
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      //新節點存在,老節點不存在,新增該節點
      createElm(vnode, insertedVnodeQueue)
    } else {
      //判斷是不是真實DOM,用於區分是不是初始化邏輯
      const isRealElement = isDef(oldVnode.nodeType)
      //不是真實DOM,而且是相同的VNode元素
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        //更新操做
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        //真實DOM,即初始化邏輯
        if (isRealElement) {
          
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            //當舊的VNode是服務端渲染的元素,hydrating記爲true
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
            //須要合併服務端渲染的頁面到真實DOM上
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } 
          }
          // 不是服務端渲染節點,或者服務端渲染合併到真實DOM失敗,返回一個空節點
          oldVnode = emptyNodeAt(oldVnode)
        }
        
        // 取代現有元素
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        //生成真實DOM
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm, //指定父節點
          nodeOps.nextSibling(oldElm) //指定插入位置:現有元素的旁邊
        )
        ...//省略
        // 移除老節點
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
複製代碼

上面代碼的邏輯不算難,主要進行的是同層的樹節點進行比較。其中包含的操做分別是:增長新節點、刪除舊節點、更新節點。

查看源碼發現,只有當斷定新舊節點是同一個節點的時候,纔會執行patchVnode()更新操做。怎樣纔算同一個節點呢?咱們來看sameVnode()

sameVnode()

/*
判斷兩個VNode節點是不是同一個節點,須要知足如下條件:
key相同
tag(當前節點的標籤名)相同
isComment(是否爲註釋節點)相同
是否data(當前節點對應的對象,包含了具體的一些數據信息,是一個VNodeData類型,能夠參考VNodeData類型中的數據信息)都有定義
當標籤是<input>的時候,type必須相同
*/
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)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
/*
判斷當標籤是<input>的時候,type是否相同
某些瀏覽器不支持動態修改<input>類型,因此他們被視爲不一樣類型
*/
function sameInputType (a, b) {
  if (a.tag !== 'input') return true
  let i
  const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}
複製代碼

當兩個VNode的tag、key、isComment都相同,而且同時定義或未定義data的時候,且若是標籤爲input則type必須相同。這時候這兩個VNode則算sameVnode,能夠直接進行patchVnode操做。

patchVnode()

function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    //兩個VNode節點相同則直接返回
    if (oldVnode === vnode) {
      return
    }
    
    const elm = vnode.elm = oldVnode.elm

    ...//省略
    /*
    若是新舊VNode都是靜態的,同時它們的key相同(表明同一節點),
    而且新的VNode是clone或者是標記了once(標記v-once屬性,只渲染一次),
    那麼只須要替換componentInstance便可。
    */
    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 = data.hook.prepatch,若是存在,見"./create-component componentVNodeHooks"
      i(oldVnode, vnode)
    }

    const oldCh = oldVnode.children
    const ch = vnode.children
    if (isDef(data) && isPatchable(vnode)) {
        //調用update回調以及update鉤子
      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)
    }
    //vnode的text屬性與children屬性是互斥關係,若沒有text屬性,必有children
    if (isUndef(vnode.text)) {
      
      if (isDef(oldCh) && isDef(ch)) {
        //老的有子節點,新的也有子節點,對子節點進行diff操做
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        //新的有子節點,老的沒有子節點,先清空老節點的text內容
        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)
    }
    if (isDef(data)) {
      //i = data.hook.postpatch,若是存在,見"./create-component componentVNodeHooks"
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }
複製代碼

代碼不難理解。整個patchVnode的規則是這樣的:

1.若是新舊VNode都是靜態的,同時它們的key相同(表明同一節點),而且新的VNode是clone或者是標記了once(標記v-once屬性,只渲染一次),那麼只須要替換elm以及componentInstance便可。

2.新老節點均有children子節點,則對子節點進行diff操做,調用updateChildren,這個updateChildren也是diff的核心。

3.若是老節點沒有子節點而新節點存在子節點,先清空老節點DOM的文本內容,而後爲當前DOM節點加入子節點。

4.當新節點沒有子節點而老節點有子節點的時候,則移除該DOM節點的全部子節點。

5.當新老節點都無子節點的時候,只是文本的替換。

updateChildren()

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 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)
    }

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      /*
      前四種狀況實際上是指定key的時候,斷定爲同一個VNode,則直接patchVnode便可
      分別比較oldCh以及newCh的兩頭節點2*2=4種狀況
      */
      } 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)) {   //頭尾相同
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        //挪動oldStartVnode到oldEndVnode前面
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) {   //尾頭相同
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        //挪動oldEndVnode到oldStartVnode前面
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        //在OldCh中,建立一張key<==>idx對應的map表,可加快查找newStartVnode是否在OldCh中
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { //若是不存在OldCh中,那麼認定newStartVnode是新元素,建立
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          /*
          對比OldCh中找到的【相同key】的Vnode與newStartVnode,是不是相同的節點
          判斷是不是相同節點,除了key相同外,還有:
            tag(當前節點的標籤名)相同
            isComment(是否爲註釋節點)相同
            是否data都有定義
            當標籤是<input>的時候,type必須相同
          */
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 若是key相同,可是其餘不一樣,那麼認爲這是新元素,建立
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      //若是oldCh數組已經遍歷完,newCh數組還有元素,那麼將剩餘元素都建立
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      //若是newCh數組已經遍歷完,oldCh數組還有元素,那麼將剩餘元素都刪除
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }
複製代碼

這個過程不算複雜。經過畫圖的方式會更加清晰一些。

此處輸入圖片的描述

4種簡單比較

在新老兩個VNode節點的左右頭尾兩側都有一個變量標記,在遍歷過程當中這幾個變量都會向中間靠攏。當oldStartIdx <= oldEndIdx或者newStartIdx <= newEndIdx時結束循環。 首先,oldStartVnode、oldEndVnode與newStartVnode、newEndVnode兩兩比較一共有2*2=4種比較方法。

頭頭相等或者尾尾相等

當新老VNode節點的start或者end知足sameVnode()時,直接將對應VNode節點進行patchVnode便可。

此處輸入圖片的描述

頭尾相等

若是oldStartVnode與newEndVnode知足sameVnode(),即sameVnode(oldStartVnode, newEndVnode)。這時候說明oldStartVnode已經跑到oldEndVnode後面了。進行patchVnode的同時,還須要將oldStartVnode移動到oldEndVnode的後面。

此處輸入圖片的描述

尾頭相等

若是oldEndVnode與newStartVnode知足sameVnode(),即sameVnode(oldEndVnode, newStartVnode)。這說明oldEndVnode跑到了oldStartVnode的前面。進行patchVnode()的同時,還須要將oldEndVnode移動到oldStartVnode的前面。

此處輸入圖片的描述

非簡單比較

若是不符合以上4種簡單狀況,那麼在OldCh中,建立一張key<==>idx對應的map表,可加快查找newStartVnode是否在OldCh中。從這個map表中能夠找到是否有與newStartVnode一致key的舊的VNode節點。若是知足sameVnode()patchVnode()的同時會將這個真實DOM(elmToMove)移動到oldStartVnode對應的真實DOM的前面。

此處輸入圖片的描述

固然,知足相同節點的狀況是美好的,更多的狀況是不知足相同節點的時候:key不相同,或者key相同但依然不知足sameVnode()。這時,須要建立新節點:

此處輸入圖片的描述

固然,還有一些終止條件的判斷。

若是oldCh數組已經遍歷完,newCh數組還有元素,那麼將剩餘元素都建立。

此處輸入圖片的描述
若是newCh數組已經遍歷完,oldCh數組還有元素,那麼將剩餘元素都刪除
此處輸入圖片的描述

patch算法回顧

整個patch算法很長。總體一遍理下來,會對其實現過程有一個更加深入的理解。

接下來想要問一個問題:updateChildren()的時候,爲何會產生頭尾交叉的4種比較的方式呢?它有什麼好處麼?

結合實際平常網頁操做的習慣:咱們可能會是隻拖拖拽拽一個DOM元素,其餘元素都不改變;或者點擊一個讓展現元素逆向排序的按鈕;或者清除了一個DOM元素。

這些操做,都只是簡單的改變DOM樹的同層結構而已。頭尾交叉的比較方式,徹底可以高效應對這些平常操做。

這也能夠做爲一個面向工程化的算法優化案例了~

MORE

關於DOM的操做更新,patch算法已經向咱們展現了所有過程。不過依然還有細節沒有處理完:patch的過程只是將虛擬DOM映射成了真實的DOM。那如何給這些DOM加入attr、class、style等DOM屬性呢?

實際上,源碼中已經有答案,只是還不夠明細。它的實現依賴於VDOM的生命週期函數。在createPatchFunction()中,有這麼一段:

//VDOM生命週期鉤子函數
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

  //引入DOM屬性相關的生命週期鉤子函數
  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    //modules包含[attrs,klass,events,domProps,style,transition,ref,directives]等DOM屬性
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
複製代碼

這個cbs數組中的函數在哪裏被調用了呢?來看patchVNode()中容易被忽視的一行代碼:

if (isDef(data) && isPatchable(vnode)) {
      //調用各個DOM屬性相關的update鉤子函數
      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)
    }
複製代碼

此處正式更新對應DOM屬性的所在。實際上,也就意味着,在patchVNode()過程當中,DOM屬性對應的更新也已經悄悄作完了。

相關文章
相關標籤/搜索