Vue3 源碼解析(三):靜態提高

什麼是靜態提高

Vue3 還沒有發佈正式版本前,尤大在一次關於 Vue3 的分享中說起了靜態提高,當時筆者就對這個亮點產生了好奇,因此在源碼閱讀時,靜態提高也是筆者的一個重點閱讀點。html

那麼什麼是靜態提高呢?當 Vue 的編譯器在編譯過程當中,發現了一些不會變的節點或者屬性,就會給這些節點打上標記。而後編譯器在生成代碼字符串的過程當中,會發現這些靜態的節點,並提高它們,將他們序列化成字符串,以此減小編譯及渲染成本。有時能夠跳過一整棵樹。vue

<div>
  <span class="foo">
    Static
  </span>
  <span>
    {{ dynamic }}
  </span>
</div>

例如這段模板代碼,毫無疑問,咱們能看出來 <span class="foo"> 這個節點,不論 dynamic 表達式如何變,它都不會再改變了。對於這樣的節點,就能夠打上標記進行靜態提高。node

而 Vue3 也能夠對 props 屬性進行靜態提高。git

<div id="foo" class="bar">
    {{ text }}
</div>

例如這段模板代碼,Vue3 會跳過節點,僅僅將將再也不會變更的 id="foo"class="bar" 進行提高。github

編譯後的代碼字符串

上面的例子咱們只是簡單的分析了一些模板,如今咱們經過一個例子,來了解靜態提高先後的變化。dom

<div>
  <div>
    <span class="foo"></span>
    <span class="foo"></span>
    <span class="foo"></span>
    <span class="foo"></span>
    <span class="foo"></span>
  </div>
</div>

來看這樣一個模板,符合靜態提高的條件,可是若是沒有靜態提高的機制,它會被編譯成以下代碼:函數

const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = Vue

return function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _createVNode("div", null, [
      _createVNode("span", { class: "foo" }),
      _createVNode("span", { class: "foo" }),
      _createVNode("span", { class: "foo" }),
      _createVNode("span", { class: "foo" }),
      _createVNode("span", { class: "foo" })
    ])
  ]))
}

編譯後生成的 render 函數很清晰,是一個柯里化的函數,返回一個函數,建立一個根節點的 div,children 裏有再建立一個 div 元素,最後在最裏面的 div 節點裏建立五個 span 子元素。性能

若是進行靜態提高,那麼它會被編譯成這樣:優化

const { createVNode: _createVNode, createStaticVNode: _createStaticVNode, openBlock: _openBlock, createBlock: _createBlock } = Vue

const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<div><span class=\"foo\"></span><span class=\"foo\"></span><span class=\"foo\"></span><span class=\"foo\"></span><span class=\"foo\"></span></div>", 1)

return function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _hoisted_1
  ]))
}

靜態提高之後生成的代碼,咱們能夠看出有明顯區別,它會生成一個變量: _hoisted_1,並打上 /*#__PURE__*/ 標記。 _hoisted_1 經過字符串的傳參,調用 createStaticVNode 建立了靜態節點。而 _createBlock 中由原來的多個建立節點的函數的傳入,變爲了僅僅傳入一個函數。性能的提高天然不言而喻。spa

在知道了靜態提高的現象後,咱們就一塊兒來看看源碼中的實現。

transform 轉換器

在上一篇文章中筆者提到編譯時會調用 compiler-core 模塊中 @vue/compiler-core/src/compile.ts 文件下的 baseCompile 函數。在這個函數的執行過程當中會執行 transform 函數,傳入解析出來的 AST 抽象語法樹。那麼咱們首先一塊兒看一下 transform 函數作了什麼。

export function transform(root: RootNode, options: TransformOptions) {
  // 建立轉換上下文
  const context = createTransformContext(root, options)
  // 遍歷全部節點,執行轉換
  traverseNode(root, context)
  // 若是編譯選項中打開了 hoistStatic 開關,則進行靜態提高
  if (options.hoistStatic) {
    hoistStatic(root, context)
  }
  if (!options.ssr) {
    createRootCodegen(root, context)
  }
  // 肯定最終的元信息 
  root.helpers = [...context.helpers.keys()]
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = context.imports
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached
}

transform 函數很簡短,而且從中文註釋中,咱們能夠關注到在第 7 行代碼的位置,轉換器判斷了編譯時是否有開啓靜態提高的開關,如果打開的話則對節點進行靜態提高。今天筆者的文章主要是介紹靜態提高,那麼就圍繞靜態提高的代碼往下探索下去,而其他部分代碼則不展開來細究了。

hoistStatic 靜態提高轉換

hoistStatic 的函數源碼以下:

export function hoistStatic(root: RootNode, context: TransformContext) {
  walk(
    root,
    context,
    // 很不幸,根節點是不能被靜態提高的
    isSingleElementRoot(root, root.children[0])
  )
}

從函數的聲明中咱們可以得知,靜態提高轉換器接收根節點以及轉換器上下文做爲參數。而且僅僅是調用了 walk 函數。

walk 函數很長,因此在咱們講解 walk 函數以前,我先將 walk 函數的函數簽名寫出來給你們講一講。

(node: ParentNode, context: TransformContext, doNotHoistNode: boolean) => void

從函數簽名中能夠看出,walk 函數的參數中須要一個 node 節點,context 轉換器的上下文,以及 doNotHoistNode 這樣一個布爾值來從外部告知該節點是否能夠被提高。在 hoistStatic 函數中,傳入了根節點,而且根節點是不能夠被提高的。

walk 函數

接下來筆者會分段的給你們解析 walk 函數。

function walk(
  node: ParentNode,
  context: TransformContext,
  doNotHoistNode: boolean = false
) {
  let hasHoistedNode = false
  let canStringify = true

  const { children } = node
  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    /* 省略邏輯 */
  }
   
  if (canStringify && hasHoistedNode && context.transformHoist) {
    context.transformHoist(children, context, node)
  }
}

walk 函數首先會聲明兩個標記,hasHoistedNode:記錄該節點是否能夠被提高; canStringify: 當前節點是否能夠被字符序列化。

對於 canStringify 這個變量,源碼是這樣解釋的:有一些轉換,好比 @vue/compiler-sfc 中的 transformAssetUrls,用表達式代替靜態的綁定。這些表達式是不可變的,因此它們依然是能夠被合法的提高的,可是他們只有在運行時的時候纔會被發現,所以不能提早評估。這只是字符串序列化以前的一個問題(經過 @vue/compiler-dom 的 transformHoist 功能),可是在這裏容許咱們執行一次完整的 AST 解析,並容許 stringifyStatic 在知足其字符串閾值後當即中止執行 walk 函數。

以後會遍歷當前節點的 children 全部子節點,而 for 內處理的邏輯咱們暫時忽略,後面再看。

執行完 for 循環以後,能夠看到若是該節點能被提高且能被字符序列化,而且上下文中有 transformHoist 的轉換器,則對當前節點經過提高轉換器進行提高。由此能夠推測出 for 循環主體內的工做就是遍歷節點,而且判斷是否能夠被提高以及字符序列化,並將結果賦值給函數開頭聲明的這兩個標記。這樣的遍歷行爲跟函數名 walk 的意義也是一致的。

一塊兒來看一下 for 循環體內的邏輯:

for (let i = 0; i < children.length; i++) {
  const child = children[i]
  // 只有簡單的元素以及文本是能夠被合法提高的
  if (
    child.type === NodeTypes.ELEMENT &&
    child.tagType === ElementTypes.ELEMENT
  ) {
    // 若是不容許被提高,則賦值 constantType NOT_CONSTANT 不可被提高的標記
    // 不然調用 getConstantType 獲取子節點的靜態類型
    const constantType = doNotHoistNode
      ? ConstantTypes.NOT_CONSTANT
      : getConstantType(child, context)
    // 若是獲取到的 constantType 枚舉值大於 NOT_CONSTANT
    if (constantType > ConstantTypes.NOT_CONSTANT) {
      // 根據 constantType 枚舉值判斷是否能夠被字符序列化
      if (constantType < ConstantTypes.CAN_STRINGIFY) {
        canStringify = false
      }
      // 若是能夠被提高
      if (constantType >= ConstantTypes.CAN_HOIST) {
        // 則將子節點的 codegenNode 屬性的 patchFlag 標記爲 HOISTED 可提高
        ;(child.codegenNode as VNodeCall).patchFlag =
          PatchFlags.HOISTED + (__DEV__ ? ` /* HOISTED */` : ``)
        child.codegenNode = context.hoist(child.codegenNode!)
        // hasHoistedNode 記錄爲 true
        hasHoistedNode = true
        continue
      }
    } else {
      // 節點可能包含動態的子節點,可是它的 props 屬性也可能能被合法提高
      const codegenNode = child.codegenNode!
      if (codegenNode.type === NodeTypes.VNODE_CALL) {
        // 獲取 patchFlag
        const flag = getPatchFlag(codegenNode)
        // 若是不存在 flag,或者 flag 是文本類型
        // 而且該節點 props 的 constantType 值判斷出能夠被提高
        if (
          (!flag ||
            flag === PatchFlags.NEED_PATCH ||
            flag === PatchFlags.TEXT) &&
          getGeneratedPropsConstantType(child, context) >=
            ConstantTypes.CAN_HOIST
        ) {
          // 獲取節點的 props,並在轉換器上下文中執行提高操做
          const props = getNodeProps(child)
          if (props) {
            codegenNode.props = context.hoist(props)
          }
        }
      }
    }
  // 若是節點類型爲 TEXT_CALL,則一樣進行檢查,邏輯與前面一致
  } else if (child.type === NodeTypes.TEXT_CALL) {
    const contentType = getConstantType(child.content, context)
    if (contentType > 0) {
      if (contentType < ConstantTypes.CAN_STRINGIFY) {
        canStringify = false
      }
      if (contentType >= ConstantTypes.CAN_HOIST) {
        child.codegenNode = context.hoist(child.codegenNode)
        hasHoistedNode = true
      }
    }
  }

  // walk further
  /* 暫時忽略 */
}

循環體內的函數較長,因此咱們先不關注底部 walk further 的部分,爲了便於理解,我逐行添加了註釋。

經過最外層 if 分支頂部的註釋,咱們能夠知道只有簡單的元素和文本類型是能夠被提高的,因此會先判斷該節點是不是一個元素類型。若是該節點是一個元素,那麼會檢查 walk 函數的 doNotHoistNode 參數確認該節點是否能被提高,若是 doNotHoistNode 不爲真,則調用 getConstantType 函數獲取當前節點的 constantType。

export const enum ConstantTypes {
  NOT_CONSTANT = 0,
  CAN_SKIP_PATCH,
  CAN_HOIST,
  CAN_STRINGIFY
}

這是 ConstantType 枚舉的聲明,經過這個枚舉能夠將靜態類型分爲 4 個等級,而靜態類型更高等級的節點涵蓋了更小值的節點是全部能力。例如當一個節點被標記了 CAN_STRINGIFY,意味着它可以被字符序列化,因此它永遠也是一個能夠被靜態提高(CAN_HOIST)以及跳過 PATCH 檢查的節點。

在搞明白了 ConstantType 類型後,再接着看後續的判斷,獲取了元素類型節點的靜態類型後,會判斷靜態類型的值是否大於 NOT_CONSTANT,若是條件爲 true,則說明該節點可能能被提高或字符序列化。接着往下判斷該靜態類型可否被字符序列化,若是不能則修改 canStringify 的標記。以後判斷靜態類型可否被提高,若是能夠被提高,則將子節點的 codegenNode 對象的 patchFlag 屬性標記爲 PatchFlags.HOISTED,執行轉換器上下文中的 context.hoist 操做,並修改 hasHoistedNode 的標記。

至此元素類型節點的提高判斷完畢,咱們有發現有一個 PatchFlags 標記的存在,你們只要知道 Patch Flag 是在編譯過程當中生成的一些優化記號就行。

後續的代碼是在判斷當該節點不是簡單元素時,嘗試提高該節點的 props 中的靜態屬性,以及當節點爲文本類型時,確認是否須要提高。限於篇幅緣由,請你們自行查看上方代碼。

在前面我隱藏了一段 walk further 的邏輯,從註釋中來理解,這段代碼的做用是繼續查看一些分支狀況,看看是否還有可能進行靜態提高,代碼以下:

// walk further
  if (child.type === NodeTypes.ELEMENT) {
    // 若是子節點的 tagType 是組件,則繼續遍歷子節點
    // 以便判斷插槽中的狀況
    const isComponent = child.tagType === ElementTypes.COMPONENT
    if (isComponent) {
      context.scopes.vSlot++
    }
    walk(child, context)
    if (isComponent) {
      context.scopes.vSlot--
    }
  } else if (child.type === NodeTypes.FOR) {
    // 查看 v-for 類型的節點是否可以被提高
    // 可是若是 v-for 的節點中是隻有一個子節點,則不能被提高
    walk(child, context, child.children.length === 1)
  } else if (child.type === NodeTypes.IF) {
    // 若是子節點是 v-if 類型,判斷它全部的分支狀況
    for (let i = 0; i < child.branches.length; i++) {
            // 若是隻有一個分支條件,則不進行提高
      walk(
        child.branches[i],
        context,
        child.branches[i].children.length === 1
      )
    }
  }

walk futher 的部分會嘗試判斷元素爲組件、v-for、v-if 的狀況。再一次遍歷組件的目的是爲了檢查其中的插槽是否能被靜態提高。v-for 和 v-if 也是同樣,檢查 v-for 循環生成的節點以及 v-if 的分支條件可否被靜態提高。可是這裏須要注意,若是 v-for 是單一節點或者 v-if 的分支中只有一個分支判斷那麼均不會進行提高,由於它們會是一個 block 類型。

至此,walk 函數就給你們講解完了。

總結

今天的這篇文章,帶你們一塊兒閱讀了 Vue 源碼中靜態提高的部分,筆者經過編譯後代碼的區別給你們直觀的舉例了靜態提高到底有什麼做用,它讓編譯後的代碼產生了怎樣的區別。而且咱們從 transform 函數一路向下深究,直至 walk 函數,咱們在 walk 函數中看到了 Vue3 如何去遍歷各個節點,並給他們打上靜態類型的標記,以便於編譯時進行鍼對性的優化。

因爲篇幅限制,筆者並無展開講解 getConstantType 這個函數是如何區分各個節點類型來返回靜態類型的,也沒有講解當一個節點能夠被字符序列化時,context.transformHoist(children, context, node) 這行代碼是如何將節點字符序列化的,這些都留給感興趣的讀者繼續深刻閱讀。

若是這篇文章可以幫助到你再深一點的理解 Vue3 的特性,但願能給本文點一個喜歡❤️。若是想繼續追蹤後續文章,也能夠關注個人帳號或 follow 個人 github,再次謝謝各位可愛的看官老爺。

相關文章
相關標籤/搜索