Vue3原始碼分析——編譯模組和編譯器

戎馬發表於2021-10-31

Vue3 的編譯模組包含4個目錄:

compiler-core // 編譯核心
Compiler-DOM // 瀏覽器相關
Compiler-sfc // 單檔案元件
Compiler-SSR // 服務端渲染

其中,compiler-core 模組是Vue編譯的核心模組,與平臺無關。其餘三個基於 compiler-core,適用於不同的平臺。

Vue 的編譯分為三個階段,即 解析(Parse)、轉換(Transform)和程式碼生成(Codegen)

Parse 階段將模板字串轉換為語法抽象樹 ASTTransform 階段對 AST 做一些轉換處理。Codegen 階段根據 AST 生成相應的渲染函式字串。

Parse 階段

分析模板字串時,Vue 可分為兩種情況:以< 開頭的字串,和不是以 < 開頭的字串。

不是以 < 開頭的字串有兩種情況:文字節點或者插入表示式 {{exp}}

使用 < 將字串的開頭分為以下幾種情況:

  1. 元素開始標籤 <div>
  2. 元素結束標籤 </div>
  3. 註釋節點 <!-- 123 -->
  4. 檔案宣告 <!DOCTYPE html>

用虛擬碼表示,近似過程如下:

while (s.length) {
    if (startsWith(s, '{{')) { // 如果開始為 '{{'
        node = parseInterpolation(context, mode)
    } else if (s[0] === '<') { // 元素開始標籤
        if (s[1] === '!') {
            if (startsWith(s, '<!--')) { // 註釋節點
                node = parseComment(context)
            } else if (startsWith(s, '<!DOCTYPE')) { //文件語句
                node = parseBogusComment(context)
            }
        } else if (s[1] === '/') { // 結束標籤
            parseTag(context, TagType.End, parent)
        } else if (/[a-z]/i.test(s[1])) { // 開始標籤名
            node = parseElement(context, ancestors)
        }
    } else { // 普通文字節點     
        node = parseText(context, mode)
    }
}

原始程式碼點這裡vue-next parse.ts

相對應的幾個函式如下:

  1. parseChildren(),入口函式
  2. parseInterpolation(),分析雙花插值表示式
  3. parseComment(),解析註釋
  4. parseBogusComment(),分析檔案宣告
  5. parseTag(),分析標籤
  6. parseElement(),分析元素節點,它將在內部執行 parseTag()
  7. parseText(),分析普通文字
  8. parseAttribute(),分析屬性

當標籤、文字、註釋等每個節點生成相應的AST節點時,Vue 將截斷解析的字串。

字串被截斷是使用 AdvanceBy(context,numberOfCharacters)函式,context 是字串的上下文物件,numberOfCharacters是要截斷的字元數。

使用一個簡單的示例來模擬截斷操作:

<div name="test">
  <p></p>
</div>

首先分析 <div,然後執行 advanceBy(context,4) 截斷操作(內部執行 s=s.slice(4) ),變成:

name="test">
  <p></p>
</div>

然後分析屬性,並將其截斷為:

<p></p>
</div>

類似地,以下內容的截斷為:

></p>
</div>
</div>
<!-- 所有字串都已解析 -->

所有 AST 節點定義都在 Compiler-core/astts 檔案中,下面是元素節點的定義:

export interface BaseElementNode extends Node {
     TYPE: NODETYPES.EEMENT / / Type 型別
     NS: namespace // 名稱空間預設為html, ie 0
     Tag: String // 標籤名稱
     tagType: ElementTypes // 元素型別
     IsselfClosing: boolean // 是否為自閉標記, 例如 <hr />
     Props: Array <Attribute | DirectiveNode> // 屬性, 包含 Html 屬性和指令
     Children: TemplateChildNode [] // 子級模板指向
}

用一個比較複雜的例子來解釋解析過程。

<div name="test">
     <!-- This is a comment-->
  <p>{{ test }}</p>
     A text node
  <div>good job!</div>
</div>

上面的模板字串假定為 S,第一個字元 S[0] 在開始時為 <,這意味著它只能是剛才提到的四種情況之一。

再看看 S[1] 第二個字元的規則:

  1. 遇到 ! 時,呼叫字串原始方法 startsWith(),分析是 <!--的開頭, 還是 <!DOCTYPE 的開頭,它們對應的處理函式不同,例子中程式碼最終將解析到註釋節點。
  2. 如果是 / ,按結束標籤。
  3. 如果不是 /,按開始標籤處理。

在我們的示例中,這是一個 <div> 開始標籤。

這裡要提到的一點,Vue 將使用棧來儲存已解析的元素標籤。當遇到開始標記時,標籤被推入棧中。當遇到結束標記時,將彈出棧。它的作用是儲存已解析但尚未解析完的元素標籤。在這個棧中還有另一個角色,通過 stack[stack.length-1] ,可以得到它的父元素。

從我們的例子來看,在解析過程中,棧中儲存如下:

1. [div] // div 入棧
2. [div, P] // p 入棧
3. [div] // P 彈出
4. [div, div] // div 入棧
5. [div] // div 彈出
6. [] // 最後一個div彈出後,模板字串已解析,棧為空。

按照上面的例子,接下來將截斷 <div 字串,並解析其屬性。

屬性有兩種情況:

  1. HTML的普通屬性
  2. Vue的指令

生成的型別節點值,HTML 普通屬性節點型別為6,Vue 指令節點型別為7。

所有節點型別值詳情如下:

Root, // 根節點為 0
Element, // 元素節點為 1
Text, // 文字節點為 2
Comment, // 註釋節點為 3
Simple_expression, // 簡單表示式為 4
Interpolation, // 雙花插值 {{}} 為 5
Attribute, // 屬性為 6
Directive, // 指令為 7

屬性分析後,div 開始標籤被分析完畢,<div name="test"> 此行字串被截斷。其餘字串現在如下所示:

<!-- This is a comment -->
  <p>{{ test }}</p>
     A text node
  <div>good job!</div>
</div>

註釋文字和普通文字節點解析規則比較簡單簡單,直接截斷,生成節點。註釋節點呼叫 parseComment() 函式處理,Text 節點呼叫 parseText() 處理。

雙花插值 {{test}} 的字串處理邏輯稍微複雜一些:

  1. 首先提取出雙括號內的內容,即 test,呼叫 trim 函式去掉兩邊空格。
  2. 然後生成兩個節點,一個節點為 INTERPOLATION 型別值為5,表示它是一個雙花插值。
  3. 第二個節點是其內容 test,將生成節點為 Simple_expression,型別值為4。
return {
  TYPE: NODETYPES.ITERPOLATION, // 雙花括號型別
  content: {
    type: NodeTypes.SIMPLE_EXPRESSION, // 簡單表示式型別
    Isstatic: false, // 不是靜態節點
    isConstant: false,
    content,
    loc: getSelection(context, innerStart, innerEnd)
  },
  loc: getSelection(context, start)
}

字串解析邏輯的其餘部分與上述內容類似,因此未對其進行解釋。示例解析AST如下所示:

AST 中,還可以看到一些節點上的其他屬性:

  1. NS,名稱空間,通常為 HTML,值為0
  2. LOC,它是一條位置訊息,指示此節點位於源 HTML字串的位置,包含行、列、偏移量等資訊。
  3. {{ test }} 解析後的節點將具有 isStatic屬性,該值為 false,表示這是一個動態節點。如果是靜態節點,則只生成一次,並且會複用相同的節點,不需要進行差異比較。

還有一個標籤型別值,它有4個值:

export const enum ElementTypes {
  ELEMENT, // 0 元素節點 
  Component, // 1 註釋節點
  Slot, // 2 插槽節點
  Template // 3 模板
}

主要用於區分以上四種型別的節點。

Transform 階段

在轉換階段,Vue 將對 AST 執行一些轉換操作,主要是根據 CodeGen階段 使用的不同 AST節點新增不同的選項引數。以下是一些重要的選項:

cacheHandlers 快取處理程式

如果 CacheHandlers 的值為 true,則啟用函式快取。例如 @click="foo" 預設情況下編譯為 {onClick:foo},如果開啟此選項,則編譯為:

{ onClick: _cache[0] || (_cache[0] = e => _ctx.foo(e)) }  // 具備快取功能

hoistStatic 靜態提升

hoistStatic 是一個識別符號,表示是否應啟用靜態節點提升。如果值為 true ,靜態節點將被提升在 render() 函式外部,生成名為 _hoisted_x 的變數。

例如,文字 A text node 生成的程式碼為 const hoisted_2 = / # pure / createtextVNode ("a text node")

在下面兩張圖片中,前一張為 hoistStatic=false,後一張為 hoistStatic=true,都可以自己嘗試一下 地址

prefixIdentifiers 字首標識

此引數的角色用於程式碼生成。例如,{{ foo }} 模組(module)模式下生成的程式碼是 _ctx.foo,函式(function)模式下生成的程式碼是 width(this){…}。因為在模組(module)模式下,預設為嚴格模式,不能使用 with 語句。

PatchFlags 補丁標識

轉換為 AST 節點時,使用 PatchFlag 引數,該引數主要用於差異比較 diff 過程。當 DOM 節點具有此標誌且大於0時,它將被更新,並且不會跳過。

來看看 PatchFlag 的值:

export const enum PatchFlags {
  // 動態文字節點
  TEXT = 1,
    // 動態類
  CLASS = 1 << 1, // 2
    // 動態Style
  STYLE = 1 << 2, // 4
    // 動態屬性,但不包括 calss 和 style
  // 如果是元件,則可以包含 calss 和 style。
  PROPS = 1 << 3, // 8
  // 具有動態鍵屬性,當鍵更改時,需要進行完整的 DIFF 差異比較
  FULL_PROPS = 1 << 4, // 16
    // 具有偵聽事件的節點
  HYDRATE_EVENTS = 1 << 5, // 32
    // 不改變子序列的片段
  STABLE_FRAGMENT = 1 << 6, // 64
    // 具有key屬性的片段或部分子位元組具有key
  KEYED_FRAGMENT = 1 << 7, // 128
  // 子節點沒有金鑰的 key
  UNKEYED_FRAGMENT = 1 << 8, // 256
  // 節點將僅執行 non-PROPS 比較
  NEED_PATCH = 1 << 9, // 512
  // 動態插槽
  DYNAMIC_SLOTS = 1 << 10, // 1024
  // 靜態節點
  HOISTED = -1,
  // 退出 DIFF 差異比較優化模式
  BAIL = -2
}

從上面的程式碼可以看出,PatchFlag 使用 bit-map 來表示不同的值,每個值都有不同的含義。 Vue 會在 diff 過程中根據不同的修補標誌使用不同的修補方法。

下圖為變換後的 AST

可以看到 CodegenNodeHelpersHoists 已填充了相應的值。CodegenNode 是生成要使用的程式碼的資料。Hoists 儲存靜態節點。Helpers 儲存建立 vNode 的函式名(實際上是 Symbol)。

在正式開始轉換之前,需要建立一個 transformContext,即轉換上下文。與這三個屬性相關的資料和方法如下:

helpers: new Set(),
hoists: [],

// methods
helper(name) {
  context.helpers.add(name)
  return name
},
helperString(name) {
  return `_${helperNameMap[context.helper(name)]}`
},
hoist(exp) {
  context.hoists.push(exp)
  const identifier = createSimpleExpression(
    `_hoisted_${context.hoists.length}`,
    false,
    exp.loc,
    true
  )
  identifier.hoisted = exp
  return identifier
},

讓我們來看看具體的轉換過程是如何使用的。用 <p>{{ test }}</p> 舉例說明。

此節點對應 TransformElement() 轉換函式,因為 p 沒有繫結動態屬性,沒有繫結指令,所以焦點不在它上面。而 {{test}} 是一個雙花插值表示式,所以將其 patchflag 設定為1(動態文字節點),相應的執行程式碼 patchFlag |=1。然後執行 createVNodeCall() 函式,其返回值為該節點的 codegennode 值。

node.codegenNode = createVNodeCall(
    context,
    vnodeTag,
    vnodeProps,
    vnodeChildren,
    vnodePatchFlag,
    vnodeDynamicProps,
    vnodeDirectives,
    !!shouldUseBlock,
    false /* disableTracking */,
    node.loc
)

createVNodeCall() 會相應的在 createVNode() 中新增一個符號,它放置在 helpers 中。事實上,helpers 功能將在程式碼生成階段引入。

// createVNodeCall () 內部執行過程,多餘程式碼已刪除
context.helper(CREATE_VNODE)
return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

hoists 提升

是否將節點提升,主要看它是否是靜態節點。

<div name = "test"> // 靜態屬性節點
     <! - This is a comment->
  <p>{{ test }}</p>
     A text node // 靜態節點
     <div> good job! </div> // 靜態節點
</div>

可以看到,上面有三個靜態節點,因此 hoists 陣列有3個值。註釋為什麼不算靜態節點,暫時還沒有找到原因。。。

TYPE changes 型別改變

從上圖中可以看出,最外層 div 的型別為1,由 Transform 生成的 CodeGen node 中的型別為13。

這13是 VNODE_CALL 對應的型別值,其他還有:

// codegen
VNODE_CALL, // 13
JS_CALL_EXPRESSION, // 14
JS_OBJECT_EXPRESSION, // 15
JS_PROPERTY, // 16
JS_ARRAY_EXPRESSION, // 17
JS_FUNCTION_EXPRESSION, // 18
JS_CONDITIONAL_EXPRESSION, // 19
JS_CACHE_EXPRESSION, // 20

剛才提到的例子 {{ test }}, 其 codegen nodecreateVnodeCall 函式生成。

return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

從上面的程式碼可以看出,type 設定為 nodetypes.VNODE_CALL,即13。
每個不同的節點由不同的變換函式處理。可以自己再深入的瞭解。

Codegen階段

程式碼生成階段最後生成了一個字串,去掉了字串的雙引號,具體內容是什麼:

const _Vue = Vue
const { createVNode: _createVNode, createCommentVNode: _createCommentVNode, createTextVNode: _createTextVNode } = _Vue

const _hoisted_1 = { name: "test" }
 const _hoisted_2 = / * # __ pure __ * / _ createtextVNode ("a text node")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

return function render(_ctx, _cache) {
  with (_ctx) {
    const { createCommentVNode: _createCommentVNode, toDisplayString: _toDisplayString, createVNode: _createVNode, createTextVNode: _createTextVNode, openBlock: _openBlock, createBlock: _createBlock } = _Vue

    return (_openBlock(), _createBlock("div", _hoisted_1, [
             _CreateCommentVNode ("This is a comment"),
      _createVNode("p", null, _toDisplayString(test), 1 /* TEXT */),
      _hoisted_2,
      _hoisted_3
    ]))
  }
}

程式碼生成模式

可以看到上面的程式碼最終返回了 render() 函式,生成相應的 VNODE

實際上,程式碼生成有兩種模式:模組和函式。選取哪種模式由字首識別符號決定。

函式模式功能:使用 const {helpers…}=Vue 獲取幫助函式的方法,即 createVode()createCommentVNode() 這些函式,最後返回 render() 函式。

模組模式為:使用ES6模組匯入匯出功能,即 importexport

Static node 靜態節點

此外,還有三個變數。以 hoisted 命名,後面跟數字,表示這是靜態變數。

看看解析階段的 HTML 模板字串:

<div name="test">
     <! - This is a comment->
  <p>{{ test }}</p>
     A text node
  <div>good job!</div>
</div>

這個示例只有一個動態節點,即 {{test},其餘的都是靜態節點。從生成的程式碼中還可以看出,生成的節點和模板中的程式碼對應於一個或多個節點。靜態節點的作用是隻生成一次,以後直接重用。

細心的你可能會發現 Highed_2Highed_3 變數有一個 /#\_PURE_/ 的註釋。

此註釋的作用是表明此功能是純功能,無副作用,主要用於Tree-shaking 。壓縮工具將直接從打包時未使用的程式碼中刪除。

來看下一代動態節點,{{ test }} 生成程式碼對應為 _createVNode("p", null, _toDisplayString(test), 1 / TEXT /)

其中,_toDisplayString(test) 的內部實現是:

return val == null
    ? ''
    : isObject(val)
      ? JSON.stringify(val, replacer, 2)
      : String(val)

該程式碼非常簡單,它是一個字串轉換輸出。

_createVNode("p", null, _toDisplayString(test), 1 / TEXT /) 的最後一個引數增加轉換時的 Patchflag 值。

Help function 輔助函式

TransformCodegen 階段,都看到了 helpers 輔助函式的影子,它是什麼呢?

Name mapping for runtime helpers that need to be imported from 'vue' in
generated code. Make sure these are correctly exported in the runtime!
Using `any` here because TS doesn't allow symbols as index type.
// 需要從生成程式碼中的“vue”匯入的執行時幫助程式的名稱對映。
// 確保這些檔案在執行時正確匯出!
// 此處使用'any',因為TS不允許將符號作為索引型別。
export const helperNameMap: any = {
  [FRAGMENT]: `Fragment`,
  [TELEPORT]: `Teleport`,
  [SUSPENSE]: `Suspense`,
  [KEEP_ALIVE]: `KeepAlive`,
  [BASE_TRANSITION]: `BaseTransition`,
  [OPEN_BLOCK]: `openBlock`,
  [CREATE_BLOCK]: `createBlock`,
  [CREATE_VNODE]: `createVNode`,
  [CREATE_COMMENT]: `createCommentVNode`,
  [CREATE_TEXT]: `createTextVNode`,
  [CREATE_STATIC]: `createStaticVNode`,
  [RESOLVE_COMPONENT]: `resolveComponent`,
  [RESOLVE_DYNAMIC_COMPONENT]: `resolveDynamicComponent`,
  [RESOLVE_DIRECTIVE]: `resolveDirective`,
  [WITH_DIRECTIVES]: `withDirectives`,
  [RENDER_LIST]: `renderList`,
  [RENDER_SLOT]: `renderSlot`,
  [CREATE_SLOTS]: `createSlots`,
  [TO_DISPLAY_STRING]: `toDisplayString`,
  [MERGE_PROPS]: `mergeProps`,
  [TO_HANDLERS]: `toHandlers`,
  [CAMELIZE]: `camelize`,
  [CAPITALIZE]: `capitalize`,
  [SET_BLOCK_TRACKING]: `setBlockTracking`,
  [PUSH_SCOPE_ID]: `pushScopeId`,
  [POP_SCOPE_ID]: `popScopeId`,
  [WITH_SCOPE_ID]: `withScopeId`,
  [WITH_CTX]: `withCtx`
}

export function registerRuntimeHelpers(helpers: any) {
  Object.getOwnPropertySymbols(helpers).forEach(s => {
    helperNameMap[s] = helpers[s]
  })
}

事實上,幫助函式是 Vue 在程式碼生成時引入的一些函式,因此程式可以正常執行,從上面生成的程式碼可以看出。helperNameMap 是預設的對映表名,它是要從 Vue 引入的函式名。

此外,我們還可以看到一個註冊函式。registerRuntimeHelpers(helpers: any() 是做什麼用的呢?

我們知道編譯模組的編譯器核心是一個獨立於平臺的,而編譯Dom是一個與瀏覽器相關的編譯模組。要在瀏覽器中執行 Vue 程式,請匯入與瀏覽器相關的 Vue 資料和功能。

registerRuntimeHelpers(helpers: any() 用於執行此操作,可以從 Compiler-domruntimehelpers.ts 檔案中看到:

registerRuntimeHelpers({
  [V_MODEL_RADIO]: `vModelRadio`,
  [V_MODEL_CHECKBOX]: `vModelCheckbox`,
  [V_MODEL_TEXT]: `vModelText`,
  [V_MODEL_SELECT]: `vModelSelect`,
  [V_MODEL_DYNAMIC]: `vModelDynamic`,
  [V_ON_WITH_MODIFIERS]: `withModifiers`,
  [V_ON_WITH_KEYS]: `withKeys`,
  [V_SHOW]: `vShow`,
  [TRANSITION]: `Transition`,
  [TRANSITION_GROUP]: `TransitionGroup`
})

執行 registerRuntimeHelpers(helpers: any() 對映表被注入與瀏覽器相關的函式。

如何使用這些輔助函式?

在解析階段,解析不同節點時會生成相應的型別。

在轉換階段,生成一個輔助物件,它是一個集合資料結構。每當轉換 AST 時,都會根據 AST 節點的型別新增不同的幫助器函式。

例如,假設現在正在轉換註釋節點,它將執行 context.helper(CREATE_COMMENT) ,內部通過 helpers.add('createCommentVNode') 新增。

然後在 Codegen 階段,遍歷 helpers,從 Vue 匯入所需的函式,程式碼實現如下:

// 這是模組模式
`import { ${ast.helpers
  .map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
  .join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`

如何生成程式碼?

Codegen.ts 檔案中,可以看到許多程式碼生成函式:

Generate () // 入口檔案
 GenfunctionExpression () // 生成函式表示式
 Gennode () // 生成vNode節點
...

生成程式碼是基於不同的 AST 節點呼叫不同的程式碼生成函式,最後將程式碼字串拼合在一起,輸出完整的程式碼字串。

老規矩,還是看一個例子:

const _hoisted_1 = { name: "test" }
 const _hoisted_2 = / * # __ pure __ * / _ createtextVNode ("a text node")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

看看這段程式碼是如何生成的,內部會執行 genHoists(ast.hoists, context), 提升的靜態節點作為第一個引數,genHoists() 內部簡化實現:

hoists.forEach((exp, i) => {
    if (exp) {
        push(`const _hoisted_${i + 1} = `);
        genNode(exp, context);
        newline();
    }
})

從上面的程式碼可以看出,遍歷 hoists 陣列,呼叫 genNode(exp, context) 函式。genNode() 根據不同的型別執行不同的功能。

const _hoisted_1 = { name: "test" }

這一行的 const _hoisted_1 = 通過 genHoists() 函式生成,{ name: "test" } 是通過 genObjectExpression() 函式生成。

相關文章