【vue3源碼】十三、認識Block

語言: CN / TW / HK

什麼是Block?

Block 是一種特殊的 vnode ,它和普通 vnode 相比,多出一個額外的 dynamicChildren 屬性,用來存儲動態節點。

什麼是動態節點?觀察下面這個 vnodechildren 中的第一個 vnodechildren 是動態的,第二個 vnodeclass 是動態的,這兩個 vnode 都是動態節點。動態節點都會有個 patchFlag 屬性,用來表示節點的什麼屬性時動態的。

const vnode = {
  type: 'div',
  children: [
    { type: 'span', children: ctx.foo, patchFlag: PatchFlags.TEXT },
    { type: 'span', children: 'foo', props: { class: normalizeClass(cls) }, patchFlag: PatchFlags.CLASS },
    { type: 'span', children: 'foo' }
  ]
}

作為 Block ,會將其所有子代動態節點收集到 dynamicChildren 中(子代的子代動態元素也會被收集到 dynamicChildren 中)。

const vnode = {
  type: 'div',
  children: [
    { type: 'span', children: ctx.foo, patchFlag: PatchFlags.TEXT },
    { type: 'span', children: 'foo', props: { class: normalizeClass(cls) }, patchFlag: PatchFlags.CLASS },
    { type: 'span', children: 'foo' }
  ],
  dynamicChildren: [
    { type: 'span', children: ctx.foo, patchFlag: PatchFlags.TEXT },
    { type: 'span', children: 'foo', props: { class: normalizeClass(cls) }, patchFlag: PatchFlags.CLASS }
  ]
}

哪些節點會作為Block?

模板中的根節點、帶有 v-forv-if/v-else-if/v-else 的節點會被作為 Block 。如下示例:

SFC Playground

dynamicChildren的收集

觀察 tempalte 被編譯後的代碼,你會發現在創建 Block 之前會執行一個 openBlock 函數。

// 一個block棧用於存儲
export const blockStack: (VNode[] | null)[] = []
// 一個數組,用於存儲動態節點,最終會賦給dynamicChildren
export let currentBlock: VNode[] | null = null

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

openBlock 中,如果 disableTrackingtrue ,會將 currentBlock 設置為 null ;否則創建一個新的數組並賦值給 currentBlock ,並 pushblockStack 中。

再看 createBlockcreateBlock 調用一個 setupBlock 方法。

export function createBlock(
  type: VNodeTypes | ClassComponent,
  props?: Record<string, any> | null,
  children?: any,
  patchFlag?: number,
  dynamicProps?: string[]
): VNode {
  return setupBlock(
    createVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      true /* isBlock: prevent a block from tracking itself */
    )
  )
}

setupBlock 接收一個 vnode 參數。

function setupBlock(vnode: VNode) {
  // isBlockTreeEnabled > 0時,將currentBlock賦值給vnode.dynamicChildren
  // 否則置為null
  vnode.dynamicChildren =
    isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
  // 關閉block
  closeBlock()
  // 父block收集子block
  // 如果isBlockTreeEnabled > 0,並且currentBlock不為null,將vnode放入currentBlock中
  if (isBlockTreeEnabled > 0 && currentBlock) {
    currentBlock.push(vnode)
  }
  // 返回vnode
  return vnode
}

closeBlock

export function closeBlock() {
  // 彈出棧頂block
  blockStack.pop()
  // 將currentBlock設置為父block
  currentBlock = blockStack[blockStack.length - 1] || null
}

在理解 dynamicChildren 的收集過程之前,我們應該先清楚對於嵌套 vnode 的創建順序是從內向外執行的。如:

export default defineComponent({
  render() {
    return createVNode('div', null, [
      createVNode('ul', null, [
        createVNode('li', null, [
          createVNode('span', null, 'foo')
        ])
      ])
    ])
  }
})

vnode 的創建過程為: span -> li -> ul -> div

在每次創建 Block 之前,都需要調用 openBlock 創建一個新數組賦值給 currentBlock ,並放入 blockStack 棧頂。接着調用 createBlock ,在 createBlock 中會先創建 vnode ,並將 vnode 作為參數傳遞給 setupBlock

創建 vnode 時,如果滿足某些條件會將 vnode 收集到 currentBlock 中。

// 收集當前動態節點到currentBlock中
if (
  isBlockTreeEnabled > 0 &&
  // 避免收集自己
  !isBlockNode &&
  // 存在parent block
  currentBlock &&
  // vnode.patchFlag需要大於0或shapeFlag中存在ShapeFlags.COMPONENT
  // patchFlag的存在表明該節點需要修補更新。
  // 組件節點也應該總是打補丁,因為即使組件不需要更新,它也需要將實例持久化到下一個 vnode,以便以後可以正確卸載它
  (vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
  vnode.patchFlag !== PatchFlags.HYDRATE_EVENTS
) {
  currentBlock.push(vnode)
}

接着在 setupBlock 中,將 currentBlock 賦值給 vnode.dynamicChildren 屬性,然後調用 closeBlock 關閉 block (彈出 blockStack 棧頂元素,並將 currentBlock 執行 blockStack 的最後一個元素,即剛彈出 block 的父 block ),接着將 vnode 收集到父 block 中。

示例

為了更清除 dynamicChildren 的收集流程,我們通過一個例子繼續進行分析。

<template>
  <div>
    <span v-for="item in data">{{ item }}</span>
    <ComA :count="count"></ComA>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
const data = reactive([1, 2, 3])
const count = ref(0)
</script>

以上示例,經過編譯器編譯後生成的代碼如下。SFC Playground

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, resolveComponent as _resolveComponent, createVNode as _createVNode } from "vue"

import { ref, reactive } from 'vue'

const __sfc__ = {
  __name: 'App',
  setup(__props) {

    const data = reactive([1, 2, 3])
    const count = ref(0)

    return (_ctx, _cache) => {
      const _component_ComA = _resolveComponent("ComA")

      return (_openBlock(), _createElementBlock("div", null, [
        (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(data, (item) => {
          return (_openBlock(), _createElementBlock("span", null, _toDisplayString(item), 1 /* TEXT */))
        }), 256 /* UNKEYED_FRAGMENT */)),
        _createVNode(_component_ComA, { count: count.value }, null, 8 /* PROPS */, ["count"])
      ]))
    }
  }

}
__sfc__.__file = "App.vue"
export default __sfc__

當渲染函數(這裏的渲染函數就是 setup 的返回值)被執行時,其執行流程如下:

  1. 執行 _openBlock() 創建一個新的數組(稱其為 div-block ),並 pushblockStack 棧頂
  2. 執行 _openBlock(true) ,由於參數為 true ,所以不會創建新的數組,而是將 null 賦值給 currentBlock ,並 pushblockStack 棧頂
  3. 執行 _renderList_renderList 會遍歷 data ,並執行第二個 renderItem 參數,即 (item) => { ... }
  4. 首先 item1 ,執行 renderItem ,執行 _openBlock() 創建一個新的數組(稱其為 span1-block ),並 pushblockStack 棧頂。此時 blockStackcurrentBlock 狀態如下如:
  5. 接着執行 _createElementBlock("span", null, _toDisplayString(item), 1 /* TEXT */) ,在 _createElementBlock 中會先調用 createBaseVNode 創建 vnode ,在創建 vnode 時因為這是個 block vnodeisBlockNode 參數為 true ),所以不會被收集到 currentBlock
  6. 創建好 vnode 後,執行 setupBlock ,將 currentBlock 賦值給 vnode.dynamicChildren
  7. 執行 closeBlock() ,彈出 blcokStack 的棧頂元素,並將 currentBlock 指向 blcokStack 中的最後一個元素。如下圖所示:
  8. 由於此時 currentBlocknull ,所以跳過 currentBlock.push(vnode)
  9. item = 2、item = 3 時,過程與 4-7 步驟相同。當 item = 3 時, block 創建完畢後的狀態如下:
  10. 此時, list 渲染完畢,接着調用 _createElementBlock(_Fragment)
  11. 執行 _createElementBlock 的過程中,因為 isBlockNode 參數為 truecurrentBlocknull ,所以不會被 currentBlock 收集
  12. 執行 setupBlock ,將 EMPTY_ARR (空數組)賦值給 vnode.dynamicChildren ,並調用 closeBlock() ,彈出棧頂元素,使 currentBlcok 指向最新的棧頂元素。由於此時 currentBlock 不為 null ,所以執行 currentBlock.push(vnode)
  13. 執行 _createVNode(_component_ComA) ,創建 vnode 過程中,因為 vnode.patchFlag === PatchFlag.PROPS ,所以會將 vnode 添加到 currentBlock 中。
  14. 執行 _createElementBlock('div') 。先創建 vnode ,因為 isBlockNodetrue ,所以不會收集到 currentBlock 中。
  15. 執行 setupBlock() ,將 currentBlock 賦給 vnode.dynamicChildren 。然後執行 closeBlock() ,彈出棧頂元素,此時 blockStack 長度為0,所以 currentBlock 會指向 null

最終生成的 vnode

{
  type: "div",
  children:
    [
      {
        type: Fragment,
        children: [{
          type: "span",
          children: "1",
          patchFlag: PatchFlag.TEXT,
          dynamicChildren: [],
        },
          {
            type: "span",
            children: "2",
            patchFlag: PatchFlag.TEXT,
            dynamicChildren: [],
          },
          {
            type: "span",
            children: "3",
            patchFlag: PatchFlag.TEXT,
            dynamicChildren: [],
          }],
        patchFlag: PatchFlag.UNKEYED_FRAGMENT,
        dynamicChildren: []
      },
      {
        type: ComA,
        children: null,
        patchFlag: PatchFlag.PROPS,
        dynamicChildren: null
      }
    ]
  ,
  patchFlag:0,
  dynamicChildren: [
    {
      type: Fragment,
      children: [{
        type: "span",
        children: "1",
        patchFlag: PatchFlag.TEXT,
        dynamicChildren: [],
      },
        {
          type: "span",
          children: "2",
          patchFlag: PatchFlag.TEXT,
          dynamicChildren: [],
        },
        {
          type: "span",
          children: "3",
          patchFlag: PatchFlag.TEXT,
          dynamicChildren: [],
        }],
      patchFlag: PatchFlag.UNKEYED_FRAGMENT,
      dynamicChildren: []
    },
    {
      type: ComA,
      children: null,
      patchFlag: PatchFlag.PROPS,
      dynamicChildren: null
    }
  ]
}

Block的作用

如果你瞭解Diff過程,你應該知道在 Diff 過程中,即使 vnode 沒有發生變化,也會進行一次比較。而 Block 的出現減少了這種不必要的的比較,由於 Block 中的動態節點都會被收集到 dynamicChildren 中,所以 Block 間的 patch 可以直接比較 dynamicChildren 中的節點,減少了非動態節點之間的比較。

Block 之間進行 patch 時,會調用一個 patchBlockChildren 方法來對 dynamicChildren 進行 patch

const patchElement = (
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  // ...
  let { patchFlag, dynamicChildren, dirs } = n2

  if (dynamicChildren) {
    patchBlockChildren(
      n1.dynamicChildren!,
      dynamicChildren,
      el,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds
    )
    if (__DEV__ && parentComponent && parentComponent.type.__hmrId) {
      traverseStaticChildren(n1, n2)
    }
  } else if (!optimized) {
    patchChildren(
      n1,
      n2,
      el,
      null,
      parentComponent,
      parentSuspense,
      areChildrenSVG,
      slotScopeIds,
      false
    )
  }
  
  // ...
}

patchElement 中如果新節點存在 dynamicChildren ,説明此時新節點是個 Block ,那麼會調用 patchBlockChildren 方法對 dynamicChildren 進行 patch ;否則如果 optimizedfalse 調用 patchChildrenpatchChildren 中可能會調用 patchKeyedChildren/patchUnkeyedChildren 進行 Diff

const patchBlockChildren: PatchBlockChildrenFn = (
  oldChildren,
  newChildren,
  fallbackContainer,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds
) => {
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i]
    const newVNode = newChildren[i]
    // 確定父容器
    const container =
      oldVNode.el &&
      (oldVNode.type === Fragment ||
        !isSameVNodeType(oldVNode, newVNode) ||
        oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
        ? hostParentNode(oldVNode.el)!
        : fallbackContainer
    patch(
      oldVNode,
      newVNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      true
    )
  }
}

總結

Blockvue3 中一種性能優化的手段。 Block 本質是一種特殊的 vnode ,它與普通 vnode 相比,多出了一個 dynamicChildren 屬性,這個屬性中保存了所有 Block 子代的動態節點。 Block 進行 patch 可以直接對 dynamicChildren 中的動態節點進行 patch ,避免了靜態節點之間的比較。

Block 的創建過程:

  1. 每次創建 Block 節點之前,需要調用 openBlcok 方法,創建一個新的數組賦值給 currentBlock ,並 pushblockStack 的棧頂。
  2. 在創建 vnode 的過程中如果滿足一些條件,會將動態節點放到 currentBlock 中。
  3. 節點創建完成後,作為參數傳入 setupBlock 中。在 setupBlock 中,將 currentBlock 複製給 vnode.dynamicChildren ,並調用 closeBlcok ,彈出 blockStack 棧頂元素,並使 currentBlock 指向最新的棧頂元素。最後如果此時 currentBlock 不為空,將 vnode 收集到 currentBlock 中。