【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 中。