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

前言

靜態節點提高是「Vue3」針對 VNode 更新過程性能問題而提出的一個優化點。衆所周知,在大型應用場景下,「Vue2.x」的 patchVNode 過程,即 diff 過程是很是緩慢的,這是一個十分使人頭疼的問題。javascript

雖然,對於面試常問的 diff 過程在必定程度上是減小了對 DOM 的直接操做。可是,這個減小是有必定成本的。由於,若是是複雜應用,那麼就會存在父子關係很是複雜的 VNode,而這也就是 diff 的痛點,它會不斷地遞歸調用 patchVNode,不斷堆疊而成的幾毫秒,最終就會形成 VNode 更新緩慢。前端

也所以,這也是爲何咱們所看到的大型應用諸如阿里雲之類的採用的是基於「React」的技術棧的緣由之一。因此,「Vue3」也是痛改前非,重寫了整個 Compiler 過程,提出了靜態提高、靶向更新等優化點,來提升 patchVNode 過程。vue

那麼,回到今天的正題,咱們從源碼角度看看在整個編譯過程「Vue3」靜態節點提高到底是何許人也java

什麼是 patchFlag

因爲,在 compile 過程的 transfrom 階段會說起 AST Element 上的 patchFlag 屬性。因此,在正式認識 complie 以前,咱們先搞清楚一個概念,什麼是 patchFlagnode

patchFlagcomplier 時的 transform 階段解析 AST Element 打上的優化標識。而且,顧名思義 patchFlagpatch 一詞表示着它會爲 runtime 時的 patchVNode 提供依據,從而實現靶向更新 VNode 的效果。所以,這樣一來一往,也就是耳熟能詳的 Vue3 巧妙結合 runtimecompiler 實現靶向更新和靜態提高。面試

而在源碼中 patchFlag 被定義爲一個數字枚舉類型,每個枚舉值對應的標識意義會是這樣:後端

而且,值得一提的是總體上 patchFlag 的分爲兩大類:函數

  • patchFlag 的值大於 0 時,表明所對應的元素在 patchVNode 時或 render 時是能夠被優化生成或更新的。
  • patchFlag 的值小於 0 時,表明所對應的元素在 patchVNode 時,是須要被 full diff,即進行遞歸遍歷 VNode tree 的比較更新過程。
其實,還有兩類特殊的 flagshapeFlagslotFlag,這裏我就不對此展開,有興趣的同窗能夠自行去了解。

Compile 編譯過程

對比 Vue2.x 編譯過程

瞭解過「Vue2.x」源碼的同窗,我想應該都知道在「Vue2.x」中的 Compile 過程會是這樣:源碼分析

  • parse 編譯模板生成原始 AST。
  • optimize 優化原始 AST,標記 AST Element 爲靜態根節點或靜態節點。
  • generate 根據優化後的 AST,生成可執行代碼,例如 _c_l 之類的。

而在「Vue3」中,總體的 Compile 過程仍然是三個階段,可是不一樣於「Vue2.x」的是,第二個階段換成了正常編譯器都會存在的階段 transform。因此,它看起來會是這樣:post


在源碼中,它對應的僞代碼會是這樣:

export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  ...
  const ast = isString(template) ? baseParse(template, options) : template
  ...
  transform(
    ast,
    extend({}, options, {....})
  )

  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

那麼,我想這個時候你們可能會問爲何會是 transform?它的職責是什麼?

經過簡單的對比「Vue2.x」編譯過程的第二階段的 optimize,很明顯,transform 並非無米之炊,它仍然有着優化原始 AST 的做用,而具體職責會表如今:

  • 對全部 AST Element 新增 codegen 屬性來幫助 generate 更準確地生成最優的可執行代碼。
  • 對靜態 AST Element 新增 hoists 屬性來實現靜態節點的單首創建
  • ...

此外,transform 還標識了諸如 isBlockhelpers 等屬性,來生成最優的可執行代碼,這裏咱們就不細談,有興趣的同窗能夠自行了解。

baseParse 構建原始抽象語法樹(AST)

baseParse 顧名思義起着解析的做用,它的表現和「Vue2.x」的 parse 相同,都是解析模板 tempalte 生成原始 AST

假設,此時咱們有一個這樣的模板 template

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

那麼,它在通過 baseParse 處理後生成的 AST 看起來會是這樣:

{
  cached: 0,
  children: [{…}],
  codegenNode: undefined,
  components: [],
  directives: [],
  helpers: [],
  hoists: [],
  imports: [],
  loc: {start: {…}, end: {…}, source: "<div><div>hi vue3</div><div>{{msg}}</div></div>"},
  temps: 0,
  type: 0
}

若是,瞭解過「Vue2.x」編譯過程的同窗應該對於上面這顆 AST 的大部分屬性不會陌生。AST 的本質是經過用對象來描述「DSL」(特殊領域語言),例如:

  • children 中存放的就是最外層 div 的後代。
  • loc 則用來描述這個 AST Element 在整個字符串(template)中的位置信息。
  • type 則是用於描述這個元素的類型(例如 5 爲插值、2 爲文本)等等。

而且,能夠看到的是不一樣於「Vue2.x」的 AST,這裏咱們多了諸如 helperscodegenNodehoists 等屬性。而,這些屬性會在 transform 階段進行相應地賦值,進而幫助 generate 階段生成更優的可執行代碼。

transfrom 優化原始抽象語法樹(AST)

對於 transform 階段,若是瞭解過編譯器的工做流程的同窗應該知道,一個完整的編譯器的工做流程會是這樣:

  • 首先,parse 解析原始代碼字符串,生成抽象語法樹 AST。
  • 其次,transform 轉化抽象語法樹,讓它變成更貼近目標「DSL」的結構。
  • 最後,codegen 根據轉化後的抽象語法樹生成目標「DSL」的可執行代碼。

而在「Vue3」採用 Monorepo 的方式管理項目後,compile 對應的能力就是一個編譯器。因此,transform 也是整個編譯過程的重中之重。換句話說,若是沒有 transformAST 作諸多層面的轉化,「Vue」仍然會掛在 diff 這個飽受詬病的過程。

相比之下,「Vue2.x」的編譯階段沒有完整的 transform,只是 optimize 優化了一下 AST,能夠想象在「Vue」設計之初尤大也沒想到它之後會 這麼地流行

那麼,咱們來看看 transform 函數源碼中的定義:

function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)
  traverseNode(root, context)
  if (options.hoistStatic) {
    hoistStatic(root, context)
  }
  if (!options.ssr) {
    createRootCodegen(root, context)
  }
  // finalize meta information
  root.helpers = [...context.helpers]
  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 函數作了什麼,在它的定義中是盡收眼底。這裏咱們提一下它對靜態提高其決定性做用的兩件事:

  • 將原始 AST 中的靜態節點對應的 AST Element 賦值給根 AST 的 hoists 屬性。
  • 獲取原始 AST 須要的 helpers 對應的鍵名,用於 generate 階段的生成可執行代碼的獲取對應函數,例如 createTextVNodecreateStaticVNoderenderList 等等。

而且,在 traverseNode 函數中會對 AST Element 應用具體的 transform 函數,大體能夠分爲兩類:

  • 靜態節點 transform 應用,即節點不含有插值、指令、props、動態樣式的綁定等。
  • 動態節點 transform 應用,即節點含有插值、指令、props、動態樣式的綁定等。

那麼,咱們就來看看對於靜態節點 transform 是如何應用的?

靜態節點 transform 應用

這裏,對於上面咱們說到的這個栗子,靜態節點就是這個部分:

<div>hi vue3</div>

而它在沒有進行 transform 應用以前,它對應的 AST 會是這樣:

{
  children: [{
    content: "hi vue3"
    loc: {start: {…}, end: {…}, source: "hi vue3"}
    type: 2
  }],
  codegenNode: undefined,
  isSelfClosing: false,
  loc: {start: {…}, end: {…}, source: "<div>hi vue3</div>"},
  ns: 0,
  props: [],
  tag: "div",
  tagType: 0,
  type: 1
}

能夠看出,此時它的 codegenNodeundefined。而在源碼中各種 transform 函數被定義爲 plugin,它會根據 baseParse 生成的 AST 遞歸應用對應的 plugin。而後,建立對應 AST Element 的 codegen 對象。

因此,此時咱們會命中 transformElementtransformText 兩個 plugin 的邏輯。

transformText

transformText 顧名思義,它和文本相關。很顯然,此時的 AST Element 所屬的類型就是 Text。那麼,咱們先來看一下 transformText 函數對應的僞代碼:

export const transformText: NodeTransform = (node, context) => {
  if (
    node.type === NodeTypes.ROOT ||
    node.type === NodeTypes.ELEMENT ||
    node.type === NodeTypes.FOR ||
    node.type === NodeTypes.IF_BRANCH
  ) {
    return () => {
      const children = node.children
      let currentContainer: CompoundExpressionNode | undefined = undefined
      let hasText = false

      for (let i = 0; i < children.length; i++) { // {1}
        const child = children[i]
        if (isText(child)) {
          hasText = true
          ...
        }
      }
      if (
        !hasText ||
        (children.length === 1 &&
          (node.type === NodeTypes.ROOT ||
            (node.type === NodeTypes.ELEMENT &&
              node.tagType === ElementTypes.ELEMENT)))
      ) { // {2}
        return
      }
      ...
    }
  }
}

能夠看到,這裏咱們會命中 {2} 的邏輯,即若是對於節點含有單一文本 transformText 並不須要進行額外的處理,即該節點仍然在這裏仍然保留和「Vue2.x」版本同樣的處理方式。

transfromText 真正發揮做用的場景是當模板中存在這樣的狀況:

<div>ab {a} {b}</div>

此時 transformText 須要將二者放在一個單獨的 AST Element 下,在源碼中它被稱爲「Compound Expression」,即組合的表達式。這種組合的目的是爲了 patchVNode 這類 VNode 時作到更好地定位和實現 DOM 的更新。反之,若是是一個文本節點和插值動態節點的話,在 patchVNode 階段一樣的操做須要進行兩次,例如對於同一個 DOM 節點操做兩次。

transformElement

transformElement 是一個全部 AST Element 都會被執行的一個 plugin,它的核心是爲 AST Element 生成最基礎的 codegen屬性。例如標識出對應 patchFlag,從而爲生成 VNode 提供依據,例如 dynamicChildren

而對於靜態節點,一樣是起到一個初始化它的 codegenNode 屬性的做用。而且,從上面介紹的 patchFlag 的類型,咱們能夠知道它的 patchFlag 爲默認值 0。因此,它的 codegenNode 屬性值看起來會是這樣:

{
  children: {
    content: "hi vue3"
    loc: {start: {…}, end: {…}, source: "hi vue3"}
    type: 2
  },
  directives: undefined,
  disableTracking: false,
  dynamicProps: undefined,
  isBlock: false,
  loc: {start: {…}, end: {…}, source: "<div>hi vue3</div>"},
  patchFlag: undefined,
  props: undefined,
  tag: ""div"",
  type: 13
}

generate 生成可執行代碼

generatecompile 階段的最後一步,它的做用是將 transform 轉換後的 AST 生成對應的可執行代碼,從而在以後 Runtime 的 Render 階段時,就能夠經過可執行代碼生成對應的 VNode Tree,而後最終映射爲真實的 DOM Tree 在頁面上。

一樣地,這一階段在「Vue2.x」也是由 generate 函數完成,它會生成是諸如 _l_c 之類的函數,這本質上是對 _createElement 函數的封裝。而相比較「Vue2.x」版本的 generate,「Vue3」改變了不少,其 generate 函數對於的僞代碼會是這樣:

export function generate(
  ast: RootNode,
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {}
): CodegenResult {
  const context = createCodegenContext(ast, options)
  if (options.onContextCreated) options.onContextCreated(context)
  const {
    mode,
    push,
    prefixIdentifiers,
    indent,
    deindent,
    newline,
    scopeId,
    ssr
  } = context
  ...
  genFunctionPreamble(ast, context)
  ...

  if (!ssr) {
    ...
    push(`function render(_ctx, _cache${optimizeSources}) {`)
  }
  ....

  return {
    ast,
    code: context.code,
    // SourceMapGenerator does have toJSON() method but it's not in the types
    map: context.map ? (context.map as any).toJSON() : undefined
  }
}

因此,接下來,咱們就來一睹帶有靜態節點對應的 AST 生成的可執行代碼的過程會是怎樣。

CodegenContext 代碼生成上下文

從上面 generate 函數的僞代碼能夠看到,在函數的開始調用了 createCodegenContext 爲當前 AST 生成了一個 context。在整個 generate 函數的執行過程都依託於一個 CodegenContext 生成代碼上下文(對象)的能力,它是經過 createCodegenContext 函數生成。而 CodegenContext 的接口定義會是這樣:

interface CodegenContext
  extends Omit<Required<CodegenOptions>, 'bindingMetadata'> {
  source: string
  code: string
  line: number
  column: number
  offset: number
  indentLevel: number
  pure: boolean
  map?: SourceMapGenerator
  helper(key: symbol): string
  push(code: string, node?: CodegenNode): void
  indent(): void
  deindent(withoutNewLine?: boolean): void
  newline(): void
}

能夠看到 CodegenContext 對象中有諸如 pushindentnewline 之類的方法。而它們的做用是在根據 AST 來生成代碼時用來實現換行添加代碼縮進等功能。從而,最終造成一個個可執行代碼,即咱們所認知的 render 函數,而且,它會做爲 CodegenContextcode 屬性的值返回。

下面,咱們就來看下靜態節點的可執行代碼生成的核心,它被稱爲 Preamble 前導。

genFunctionPreamble 生成前準備

整個靜態提高的可執行代碼生成就是在 genFunctionPreamble 函數部分完成的。而且,你們仔細斟酌一番靜態提高的字眼,靜態二字咱們能夠不看,可是提高二字,直抒本意地表達出它(靜態節點)被提升了

爲何說是提升了?由於在源碼中的體現,確實是被提升了。在前面的 generate 函數,咱們能夠看到 genFunctionPreamble 是先於 render 函數加入 context.code 中,因此,在 Runtime 階段的 Render,它會先於 render 函數執行。

geneFunctionPreamble 函數(僞代碼):

function genFunctionPreamble(ast: RootNode, context: CodegenContext) {
  const {
    ssr,
    prefixIdentifiers,
    push,
    newline,
    runtimeModuleName,
    runtimeGlobalName
  } = context
  ...
  const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`
  if (ast.helpers.length > 0) {
    ...
    if (ast.hoists.length) {
      const staticHelpers = [
        CREATE_VNODE,
        CREATE_COMMENT,
        CREATE_TEXT,
        CREATE_STATIC
       ]
        .filter(helper => ast.helpers.includes(helper))
        .map(aliasHelper)
        .join(', ')
      push(`const { ${staticHelpers} } = _Vue\n`)
    }
  }
  ...
  genHoists(ast.hoists, context)
  newline()
  push(`return `)
}

能夠看到,這裏會對前面咱們在 transform 函數說起的 hoists 屬性的長度進行判斷。顯然,對於前面說的這個栗子,它的 ast.hoists.length 長度是大於 0 的。因此,這裏就會根據 hoists 中的 AST 生成對應的可執行代碼。所以,到這裏,生成的可執行代碼會是這樣:

const _Vue = Vue
const { createVNode: _createVNode } = _Vue
// 靜態提高部分
const _hoisted_1 = _createVNode("div", null, "hi vue3", -1 /* HOISTED */)
// render 函數會在這下面

小結

靜態節點提高在整個 compile 編譯階段體現,從最初的 baseCompiletransform 轉化原始 AST、再到 generate 的優先 render 函數處理生成可執行代碼,最後交給 Runtime 時的 Render 執行,這種設計能夠說是很是精妙!因此,這樣一來,就完成了咱們常常看到在一些文章說起的「Vue3」對於靜態節點在整個生命週期中它只會執行一次建立的源碼實現,這在必定程度上下降了性能上的開銷。

寫在最後

看完靜態的節點在整個編譯過程的處理,我想你們可能都火燒眉毛地想去了解對於靜態節點的 patchVNode 又是怎樣一番景象?原先,我是打算在一篇文章描述完整個過程,可是後來思考,這無形中給閱讀增長了成本。由於,在「Vue3」版本的 patchVNode 已不只僅是 diff 的比較過程,它對於每一種 VNode 都實現了不一樣的 patch 過程。因此,patchVNode 的過程會在寫在下一篇文章,敬請期待!

往期文章回顧

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

詳解,從後端導出文件到前端(Blob)下載過程

❤️ 愛心三連擊

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

相關文章
相關標籤/搜索