揭祕,Vue3 compile 和 runtime 結合的 patch 過程(源碼分析)

前言

在上一篇文章中,咱們分析了在編譯過程靜態節點的提高。而且,在文章的結尾也說了,下一篇文章將會介紹 patch 過程。javascript

提及「Vue3」的 patch 過程,其中最爲津津樂道的就是靶向更新。靶向更新,顧名思義,即更新的過程是帶有目標性的直接性的。而,這也是和靜態節點提高同樣,是「Vue3」針對 VNode 更新性能問題的一大優化。vue

那麼,今天,咱們就來揭祕「Vue3」compile 和 runtime 結合的 patch過程 到底是如何實現的!java

什麼是 shapeFlag

提及「Vue3」的 patch,老生常談的就是 patchFlag。因此,對於 shapeFlag 我想你們可能有點蒙,這是啥?node

ShapeFlag 顧名思義,是對具備形狀的元素進行標記,例如普通元素、函數組件、插槽、keep alive 組件等等。它的做用是幫助 Rutime 時的 render 的處理,能夠根據不一樣 ShapeFlag 的枚舉值來進行不一樣的 patch 操做。數組

在「Vue3」源碼中 ShapeFlagpatchFlag 同樣被定義爲枚舉類型,每個枚舉值以及意義會是這樣:
緩存

組件建立過程

瞭解過「Vue2.x」源碼的同窗應該知道第一次 patch 的觸發,就是在組件建立的過程。只不過此時,oldVNodenull,因此會表現爲掛載的行爲。所以,在認知 pathc 的過程以前,不可或缺地是咱們須要知道組件是怎麼建立的微信

既然說 patch 的第一次觸發會是組件的建立過程,那麼在「Vue3」中組件的建立過程會是怎麼樣的?它會經歷這麼三個過程svg

在以前,咱們講過 compile 編譯過程會將咱們的 template 轉化爲可執行代碼,即 render 函數。而,compiler 生成的 render 函數會綁定在當前組件實例render 屬性上。例如,此時有這樣的 template 模板:函數

<div><div>hi vue3</div><div>{{msg}}</div></div>

它通過 compile 編譯處理後生成的 render 函數會是這樣:源碼分析

const _Vue = Vue
const _hoisted_1 = _createVNode("div", null, "hi vue3", -1 /* HOISTED */)

function render(_ctx, _cache) {
  with (_ctx) {
    const { createVNode: _createVNode, toDisplayString: _toDisplayString, Fragment: _Fragment, openBlock: _openBlock, createBlock: _createBlock } = _Vue

    return (_openBlock(), _createBlock(_Fragment, null, [
      _createVNode("div", null, [
        _hoisted_1,
        _createVNode("div", null, _toDisplayString(msg), 1 /* TEXT */)
      ])
    ]))
  }
}

這個 render 函數真正執行的時機是在安裝全局的渲染函數對應 effect 的時候,即 setupRenderEffect。而渲染 effect 會在組件建立時更新時觸發

這個時候,可能又有同窗會問什麼是 effecteffect 並非「Vue3」的新概念,它的本質是「Vue2.x」源碼中的 watcher,一樣地,effect也會負責依賴收集派發更新

有興趣瞭解「Vue3」依賴收集和派發更新過程的同窗能夠看一下這篇文章 4k+ 字分析 Vue 3.0 響應式原理(依賴收集和派發更新)

setupRenderEffect 函數對應的僞代碼會是這樣:

function setupRenderEffect() {
    instance.update = effect(function componentEffect() {
      // 組件未掛載
      if (!instance.isMounted) {
        // 建立組件對應的 VNode tree
        const subTree = (instance.subTree = renderComponentRoot(instance))
        ...
        instance.isMounted = true
      } else {
        // 更新組件
        ...
      }
  }

能夠看到,組件的建立會命中 renderComponentRoot(instance) 的邏輯,此時 renderComponentRoot(instance) 會調用 instance 上的 render 函數,而後爲當前組件實例構造整個 VNode Tree,即這裏的 subTreerenderComponentRoot 函數對應的僞代碼會是這樣:

function renderComponentRoot(instance) {
  const {
    ...
    render,
    ShapeFlags,
    ...
  } = instance
  if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    ...
    result = normalizeVNode(
      render!.call(
        proxyToUse,
        proxyToUse!,
        renderCache,
        props,
        setupState,
        data,
        ctx
      )
    )
    ...
  }
}

能夠看到,在 renderComponentRoot 中,若是當前 ShapeFlagsSTATEFUL_COMPONENT 時會命中調用 render 的邏輯。這裏的 render 函數,就是上面咱們所說的 compile 編譯後生成的可執行代碼。它最終會返回一個 VNode Tree,它看起來會是這樣:

{
  ...
  children: (2) [{…}, {…}],
  ...
  dynamicChildren: (2) [{…}, {…}],
  ...
  el: null,
  key: null,
  patchFlag: 64,
  ...
  shapeFlag: 16,
  ...
  type: Symbol(Fragment),
  ...
}

靶向更新爲例,瞭解過的同窗應該知道,它的實現離不開 VNode Tree 上的 dynamicChildren 屬性,dynamicChildren 則是用來承接整個 VNode Tree 中的全部動態節點, 而標記動態節點的過程又是在 compile 編譯的 transform 階段,能夠說是環環相扣,因此,這也是咱們常說的「Vue3」RuntimeCompile巧妙結合

顯然在「Vue2.x」是不具有構建 VNodedynamicChildren 屬性的條件。那麼,「Vue3」又是如何生成的 dynamicChildren

Block VNode 建立過程

Block VNode

Block VNode 是「Vue3」針對靶向更新而提出的概念,它的本質是動態節點對應的 VNode。而,VNode 上的 dynamicChildren 屬性則是衍生於 Block VNode,所以,它也就是充當着靶向更新中的靶的角色

這裏,咱們再回到前面所提到的 compiler 編譯時生成 render 函數,它返回的結果:

(_openBlock(), _createBlock(_Fragment, null, [
  _createVNode("div", null, [
    _hoisted_1,
    _createVNode("div", null, _toDisplayString(msg), 1 /* TEXT */)
  ])
]))
須要注意的是 openBlock 必須寫在 createBlock 以前,由於在 Block Tree 中的 Children 老是會在 createBlock 以前執行。

能夠看到有兩個和 Block 相關的函數:_openBlock()_createBlock()。實際上,它們分別對應着源碼中的 openBlock()createBlock() 函數。那麼,咱們分別來認識一下這二者:

openBlock

openBlock 會爲當前 Vnode 初始化一個數組 currentBlock 來存放 BlockopenBlock 函數的定義十分簡單,會是這樣:

function openBlock(disableTracking = false) {
    blockStack.push((currentBlock = disableTracking ? null : []));
}

openBlock 函數會有一個形參 disableTracking,它是用來判斷是否初始化 currentBlock。那麼,在什麼狀況下不須要建立 currentBlock

當存在 v-for 造成的 VNode 時,它的 render 函數中的 openBlock() 函數形參 disableTracking 就是 true。由於,它不須要靶向更新,來優化更新過程,即它在 patch 時會經歷完整的 diff 過程。

換個角理解,爲何這麼設計?靶向更新的本質是爲了從一顆存在動態、靜態節點的 VNode Tree 中篩選出動態的節點造成 Block Tree,即 dynamicChildren,而後在 patch 時實現精準、快速的更新。因此,顯然 v-for 造成的 VNode Tree 它不須要靶向更新

這裏,你們可能還會有一個疑問,爲何建立好的 Block VNode 又被 push 到了 blockStack 中?它又有什麼做用?有興趣的同窗能夠去試一下 v-if 場景,它最終會構造一個 Block Tree,有興趣的同窗能夠看一下這篇文章 Vue3 Compiler 優化細節,如何手寫高性能渲染函數

createBlock

createBlock 則負責建立 Block VNode,它會調用 createVNode 方法來依次建立 Block VNodecreateBlock 函數的定義:

function createBlock(type, props, children, patchFlag, dynamicProps) {
    const vnode = createVNode(type, props, children, patchFlag, dynamicProps, true);
    // 構造 `Block Tree`
    vnode.dynamicChildren = currentBlock || EMPTY_ARR;
    closeBlock();
    if (shouldTrack > 0 && currentBlock) {
        currentBlock.push(vnode);
    }
    return vnode;
}

能夠看到在 createBlock 中仍然會調用 createVNode 建立 VNode。而 createVNode 函數本質上調用的是源碼中的 _createVNode 函數,它的類型定義看起來會是這樣:

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  dynamicProps: string[] | null = null,
  isBlockNode = false
): VNode {}

當咱們調用 _createVNode() 建立 Block VNode 時,須要傳入的 isBlockNodetrue,它用來標識當前 VNode 是否爲 Block VNode,從而避免 Block VNode 依賴本身的狀況發生,即就不會將當前 VNode 加入到 currentBlock 中。其對應的僞代碼會是這樣:

function _createVNode() {
  ...
  if (
    shouldTrack > 0 &&
    !isBlockNode &&
    currentBlock &&
    patchFlag !== PatchFlags.HYDRATE_EVENTS &&
    (patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT)
  ) {
    currentBlock.push(vnode)
  }
  ...
}

因此,只有知足上面的 if 語句中的全部條件的 VNode,才能做爲 Block Node,它們對應的具體含義會是這樣:

  • sholdTrack 大於 0,即沒有 v-once 指令下的 VNode
  • isBlockNode 是否爲 Block Node
  • currentBlock 爲數組時才建立 Block Node,對於 v-for 場景下,curretBlocknull,它不須要靶向更新。
  • patchFlag 有意義且不爲 32 事件監聽,只有事件監聽狀況時事件監聽會被緩存。
  • shapeFlags 是組件的時候,必須爲 Block Node,這是爲了保證下一個 VNode 的正常卸載。
至於,再深一層次探索爲何?有興趣的同窗能夠自行去了解。

小結

講完 VNode 的建立過程,我想你們都會意識到一點,若是使用手寫 render 函數的形式開發,咱們就須要對 createBlockopenBlock 等函數的概念有必定的認知。由於,只有這樣,咱們寫出的 render 函數才能充分利用好靶向更新過程,實現的應用更新性能也是最好的

patch 過程

對比 Vue2.x 的 patch

前面,咱們也說起了 patch 是組件建立和更新的最後一步,有時候它也會被稱爲 diff。在
「Vue2.x」中它的 patch 過程會是這樣:

  • 同一級 VNode 間的比較,判斷這兩個新舊 VNode 是否屬於同一個引用,是則不進行後續比較,不是則對比每一級的 VNode
  • 比較過程,分別定義四個指針指向新舊VNode 的首尾,循環條件爲頭指針索引小於尾指針索引
  • 匹配成功則將舊 VNode 的當前匹配成功的真實 DOM 移動到對應新 VNode 匹配成功的位置。
  • 匹配不成功,則將新 VNode 中的真實 DOM 節點插入到舊 VNode 的對應位置中,即,此時是建立舊 VNode 中不存在的 DOM 節點。
  • 不斷遞歸,直到 VNodechildren 不存在爲止。

粗略一看,就能明白「Vue2.x」patch 是一個硬比較的過程。因此,這也是它的缺陷所在,沒法合理地處理大型應用狀況下的 VNode 更新。

Vue3 的 patch

雖然「Vue3」的 patch 沒有像 compile 同樣會從新命名一些例如 baseCompiletransform 階段性的函數。可是,其內部的處理相對於「Vue2.x」變得更爲智能

它會利用 compile 階段的 typepatchFlag 來處理不一樣狀況下的更新,這也能夠理解爲是一種分而治之的策略。其對應的僞代碼會是這樣:

function patch(...) {
  if (n1 && !isSameVNodeType(n1, n2)) {
    ...
  }
  if (n2.patchFlag === PatchFlags.BAIL) {
    ...
  }
  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text:
      processText(n1, n2, container, anchor)
      break
    case Comment:
      processCommentNode(n1, n2, container, anchor)
      break
    case Static:
      if (n1 == null) {
        mountStaticNode(n2, container, anchor, isSVG)
      } else if (__DEV__) {
        patchStaticNode(n1, n2, container, isSVG)
      }
      break
    case Fragment:
      processFragment(...)
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(...)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(...)
      }else if (shapeFlag & ShapeFlags.TELEPORT) {
        ;(type as typeof TeleportImpl).process(...)
      } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
        ;(type as typeof SuspenseImpl).process(...)
      } else if (__DEV__) {
        warn('Invalid VNode type:', type, `(${typeof type})`)
      }
}

能夠看到,除開文本、靜態、文檔碎片、註釋等 VNode 會根據 type 處理。默認狀況下,都是根據 shapeFlag 來處理諸如組件、普通元素、TeleportSuspense 組件等。因此,這也是爲何文章開頭會介紹 shapeFlag 的緣由。

而且,從 render 階段建立 Block VNodepatch 階段根據特定 shapeFlag 的不一樣處理,在必定程度上,shapeFlag 具備和 patchFlag 同樣的價值

這裏取其中一種狀況,當 ShapeFlagELEMENT 時,咱們來分析一下 processElement 是如何處理 VNodepatch 的。

processElement

一樣地 processElement 會處理掛載的狀況,即 oldVNodenull 的時候。processElement 函數的定義:

const processElement = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    isSVG = isSVG || (n2.type as string) === 'svg'
    if (n1 == null) {
      mountElement(
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    } else {
      patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
    }
  }
其實,我的認爲 oldVNode 改成 n1newVNode 改成 n2,這命名是否有點倉促?

能夠看到,processElement 在處理更新的狀況時,實際上會調用 patchElement 函數。

patchElement

patchElement 會處理咱們所熟悉的 props、生命週期、自定義事件指令等。這裏,咱們不會一一分析每一種狀況會發生什麼。咱們就以文章開頭提的靶向更新爲例,它是如何處理的?

其實,對於靶向更新的處理非常簡單,即若是此時 n2newVNode) 的 dynamicChildren 存在時,直接"梭哈",一把更新 dynamicChildren,不須要處理其餘 VNode。它對應的僞代碼會是這樣:

function patchElement(...) {
  ...
  if (dynamicChildren) {
    patchBlockChildren(
      n1.dynamicChildren!,
      dynamicChildren,
      el,
      parentComponent,
      parentSuspense,
      areChildrenSVG
    )
    ...
  }
  ...
}

因此,若是 n2dynamicChildren 存在時,則會調用 patchBlockChildren 方法。而,patchBlockChildren 方法其實是基於 patch 方法的一層封裝。

patchBlockChildren

patchBlockChildren 會遍歷 newChildren,即 dynamicChildren 來處理每個同級別的 oldVNodenewVNode,以及它們做爲參數來調用 patch 函數。以此類推,不斷重複上述過程。

const patchBlockChildren: PatchBlockChildrenFn = (
    oldChildren,
    newChildren,
    fallbackContainer,
    parentComponent,
    parentSuspense,
    isSVG
  ) => {
    for (let i = 0; i < newChildren.length; i++) {
      const oldVNode = oldChildren[i]
      const newVNode = newChildren[i]

      const container =
        oldVNode.type === Fragment ||
        !isSameVNodeType(oldVNode, newVNode) ||
        oldVNode.shapeFlag & ShapeFlags.COMPONENT ||
        oldVNode.shapeFlag & ShapeFlags.TELEPORT
          ? hostParentNode(oldVNode.el!)!
          : fallbackContainer
      patch(
        oldVNode,
        newVNode,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        true
      )
    }
  }

你們應該會注意到,此時還會獲取當前 VNode 須要掛載的容器,由於 dynamicChildren 有時候會是跨層級的,並非此時的 VNode 就是它的 parent。具體會分爲兩種狀況:

1. oldVNode 的父節點做爲容器

  • 當此時 oldVNode 的類型爲文檔碎片時。
  • oldVNodenewVNode 不是同一個節點時。
  • shapeFlagteleportcomponent 時。

2. 初始調用 patch 的容器

  • 除開上述狀況,都是以最初的 patch 方法傳入的VNode 的掛載點做爲容器。
具體每一種狀況爲何須要這樣處理,講起來又將是 長篇大論,預計會放在下一篇文章中和你們見面。

寫在最後

原本初衷是想化繁爲簡,沒想到最後仍是寫了 3k+ 的字。由於,「Vue3」將 compileruntime 結合運用實現了諸多優化。因此,已經不可能出現如「Vue2.x」同樣分析 patch 只須要關注 runtime,不須要關注在這以前的 compile 作了一些奠基基調的處理。所以,文章總會不可避免地有點晦澀,這裏建議想加深印象的同窗能夠結合實際栗子單步調式一番。

往期文章回顧

從編譯過程,理解 Vue3 靜態節點提高(源碼分析)

從零到一,帶你完全搞懂 vite 中的 HMR 原理(源碼分析)

❤️ 愛心三連擊

經過閱讀,若是你以爲有收穫的話,能夠愛心三連擊!!!

微信公衆號: Code center
相關文章
相關標籤/搜索