Vue3 源碼分析(二):編譯模塊 compiler

Vue 的編譯模塊包含 4 個目錄:html

compiler-core
compiler-dom // 瀏覽器
compiler-sfc // 單文件組件
compiler-ssr // 服務端渲染

其中 compiler-core 模塊是 Vue 編譯的核心模塊,而且是平臺無關的。而剩下的三個都是在 compiler-core 的基礎上針對不一樣的平臺做了適配處理。vue

Vue 的編譯分爲三個階段,分別是:parse、transform、codegen。node

其中 parse 階段將模板字符串轉化爲語法抽象樹 AST。transform 階段則是對 AST 進行了一些轉換處理。codegen 階段根據 AST 生成對應的 render 函數字符串。react

Parse

Vue 在解析模板字符串時,可分爲兩種狀況:以 < 開頭的字符串和不以 < 開頭的字符串。es6

不以 < 開頭的字符串有兩種狀況:它是文本節點或 {{ exp }} 插值表達式。api

而以 < 開頭的字符串又分爲如下幾種狀況:數組

  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)
    }
}

在源碼中對應的幾個函數分別是:緩存

  1. parseChildren(),主入口。
  2. parseInterpolation(),解析雙花插值表達式。
  3. parseComment(),解析註釋。
  4. parseBogusComment(),解析文檔聲明。
  5. parseTag(),解析標籤。
  6. parseElement(),解析元素節點,它會在內部執行 parseTag()
  7. parseText(),解析普通文本。
  8. parseAttribute(),解析屬性。

每解析完一個標籤、文本、註釋等節點時,Vue 就會生成對應的 AST 節點,而且會把已經解析完的字符串給截斷數據結構

對字符串進行截斷使用的是 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 節點

全部的 AST 節點定義都在 compiler-core/ast.ts 文件中,下面是一個元素節點的定義:

export interface BaseElementNode extends Node {
  type: NodeTypes.ELEMENT // 類型
  ns: Namespace // 命名空間 默認爲 HTML,即 0
  tag: string // 標籤名
  tagType: ElementTypes // 元素類型
  isSelfClosing: boolean // 是不是自閉合標籤 例如 <br/> <hr/>
  props: Array<AttributeNode | DirectiveNode> // props 屬性,包含 HTML 屬性和指令
  children: TemplateChildNode[] // 字節點
}

一些簡單的要點已經講完了,下面咱們再從一個比較複雜的例子來詳細講解一下 parse 的處理過程。

<div name="test">
  <!-- 這是註釋 -->
  <p>{{ test }}</p>
  一個文本節點
  <div>good job!</div>
</div>

上面的模板字符串假設爲 s,第一個字符 s[0] 是 < 開頭,那說明它只能是剛纔所說的四種狀況之一。
這時須要再看一下 s[1] 的字符是什麼:

  1. 若是是 !,則調用字符串原生方法 startsWith() 看看是以 '<!--' 開頭仍是以 '<!DOCTYPE' 開頭。雖然這二者對應的處理函數不同,但它們最終都是解析爲註釋節點。
  2. 若是是 /,則按結束標籤處理。
  3. 若是不是 /,則按開始標籤處理。

從咱們的示例來看,這是一個 <div> 開始標籤。

這裏還有一點要提一下,Vue 會用一個棧 stack 來保存解析到的元素標籤。當它遇到開始標籤時,會將這個標籤推入棧,遇到結束標籤時,將剛纔的標籤彈出棧。它的做用是保存當前已經解析了,但還沒解析完的元素標籤。這個棧還有另外一個做用,在解析到某個字節點時,經過 stack[stack.length - 1] 能夠獲取它的父元素。

從咱們的示例來看,它的出入棧順序是這樣的:

1. [div] // div 入棧
2. [div, p] // p 入棧
3. [div] // p 出棧
4. [div, div] // div 入棧
5. [div] // div 出棧
6. [] // 最後一個 div 出棧,模板字符串已解析完,這時棧爲空

接着上文繼續分析咱們的示例,這時已經知道是 div 標籤了,接下來會把已經解析完的 <div 字符串截斷,而後解析它的屬性。

Vue 的屬性有兩種狀況:

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

根據屬性的不一樣生成的節點不一樣,HTML 普通屬性節點 type 爲 6,Vue 指令節點 type 爲 7。

全部的節點類型值以下:

ROOT,  // 根節點 0
ELEMENT, // 元素節點 1
TEXT, // 文本節點 2
COMMENT, // 註釋節點 3
SIMPLE_EXPRESSION, // 表達式 4
INTERPOLATION, // 雙花插值 {{ }} 5
ATTRIBUTE, // 屬性 6
DIRECTIVE, // 指令 7

屬性解析完後,div 開始標籤也就解析完了,<div name="test"> 這一行字符串已經被截斷。如今剩下的字符串以下:

<!-- 這是註釋 -->
  <p>{{ test }}</p>
  一個文本節點
  <div>good job!</div>
</div>

註釋文本和普通文本節點解析規則都很簡單,直接截斷,生成節點。註釋文本調用 parseComment() 函數處理,文本節點調用 parseText() 處理。

雙花插值的字符串處理邏輯稍微複雜點,例如示例中的 {{ test }}

  1. 先將雙花括號中的內容提取出來,即 test ,再對它執行 trim(),去除空格。
  2. 而後會生成兩個節點,一個節點是 INTERPOLATION,type 爲 5,表示它是雙花插值。
  3. 第二個節點是它的內容,即 test,它會生成一個 SIMPLE_EXPRESSION 節點,type 爲 4。
return {
  type: NodeTypes.INTERPOLATION, // 雙花插值類型
  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,表示這是一個動態節點。若是是靜態節點,則只會生成一次,而且在後面的階段一直複用同一個,不用進行 diff 比較。

另外還有一個 tagType 屬性,它有 4 個值:

export const enum ElementTypes {
  ELEMENT, // 0 元素節點
  COMPONENT, // 1 組件
  SLOT, // 2 插槽
  TEMPLATE // 3 模板
}

主要用於區分上述四種類型節點。

Transform

在 transform 階段,Vue 會對 AST 進行一些轉換操做,主要是根據不一樣的 AST 節點添加不一樣的選項參數,這些參數在 codegen 階段會用到。下面列舉一些比較重要的選項:

cacheHandlers

若是 cacheHandlers 的值爲 true,則表示開啓事件函數緩存。例如 @click="foo" 默認編譯爲 { onClick: foo },若是開啓了這個選項,則編譯爲

{ onClick: _cache[0] || (_cache[0] = e => _ctx.foo(e)) }

hoistStatic

hoistStatic 是一個標識符,表示要不要開啓靜態節點提高。若是值爲 true,靜態節點將被提高到 render() 函數外面生成,並被命名爲 _hoisted_x 變量。

例如 一個文本節點 生成的代碼爲 const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一個文本節點 ")

下面兩張圖,前者是 hoistStatic = false,後面是 hoistStatic = true。你們能夠在網站上本身試一下。

prefixIdentifiers

這個參數的做用是用於代碼生成。例如 {{ foo }} 在 module 模式下生成的代碼爲 _ctx.foo,而在 function 模式下是 with (this) { ... }。由於在 module 模式下,默認爲嚴格模式,不能使用 with 語句。

PatchFlags

transform 在對 AST 節點進行轉換時,會打上 patchflag 參數,這個參數主要用於 diff 比較過程。當 DOM 節點有這個標誌而且大於 0,就表明要更新,沒有就跳過。

咱們來看一下 patchflag 的取值範圍:

export const enum PatchFlags {
  // 動態文本節點
  TEXT = 1,

  // 動態 class
  CLASS = 1 << 1, // 2

  // 動態 style
  STYLE = 1 << 2, // 4

  // 動態屬性,但不包含類名和樣式
  // 若是是組件,則能夠包含類名和樣式
  PROPS = 1 << 3, // 8

  // 具備動態 key 屬性,當 key 改變時,須要進行完整的 diff 比較。
  FULL_PROPS = 1 << 4, // 16

  // 帶有監聽事件的節點
  HYDRATE_EVENTS = 1 << 5, // 32

  // 一個不會改變子節點順序的 fragment
  STABLE_FRAGMENT = 1 << 6, // 64

  // 帶有 key 屬性的 fragment 或部分子字節有 key
  KEYED_FRAGMENT = 1 << 7, // 128

  // 子節點沒有 key 的 fragment
  UNKEYED_FRAGMENT = 1 << 8, // 256

  // 一個節點只會進行非 props 比較
  NEED_PATCH = 1 << 9, // 512

  // 動態 slot
  DYNAMIC_SLOTS = 1 << 10, // 1024

  // 靜態節點
  HOISTED = -1,

  // 指示在 diff 過程應該要退出優化模式
  BAIL = -2
}

從上述代碼能夠看出 patchflag 使用一個 11 位的位圖來表示不一樣的值,每一個值都有不一樣的含義。Vue 在 diff 過程會根據不一樣的 patchflag 使用不一樣的 patch 方法。

下圖是通過 transform 後的 AST:

能夠看到 codegenNode、helpers 和 hoists 已經被填充上了相應的值。codegenNode 是生成代碼要用到的數據,hoists 存儲的是靜態節點,helpers 存儲的是建立 VNode 的函數名稱(實際上是 Symbol)。

在正式開始 transform 前,須要建立一個 transformContext,即 transform 上下文。和這三個屬性有關的數據和方法以下:

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
},

咱們來看一下具體的 transform 過程是怎樣的,用 <p>{{ test }}</p> 來作示例。

這個節點對應的是 transformElement() 轉換函數,因爲 p 沒有綁定動態屬性,沒有綁定指令,因此重點不在它,而是在 {{ test }} 上。{{ test }} 是一個雙花插值表達式,因此將它的 patchFlag 設爲 1(動態文本節點),對應的執行代碼是 patchFlag |= 1。而後再執行 createVNodeCall() 函數,它的返回值就是這個節點的 codegenNode 值。

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

createVNodeCall() 根據這個節點添加了一個 createVNode Symbol 符號,它放在 helpers 裏。其實就是要在代碼生成階段引入的幫助函數。

// createVNodeCall() 內部執行過程,已刪除多餘的代碼
context.helper(CREATE_VNODE)

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

hoists

一個節點是否添加到 hoists 中,主要看它是否是靜態節點。

<div name="test"> // 屬性靜態節點
  <!-- 這是註釋 -->
  <p>{{ test }}</p>
  一個文本節點 // 靜態節點
  <div>good job!</div> // 靜態節點
</div>

能夠看到,上面有三個靜態節點,因此 hoists 數組有 3 個值。至於註釋爲何不算靜態節點,暫時沒找到緣由...

type 變化

從上圖能夠看到,最外層的 div 的 type 原來爲 1,通過 transform 生成的 codegenNode 中的 type 變成了 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 }},它的 codegenNode 就是經過調用 createVNodeCall() 生成的:

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

能夠從上述代碼看到,type 被設置爲 NodeTypes.VNODE_CALL,即 13。

每一個不一樣的節點都由不一樣的 transform 函數來處理,因爲篇幅有限,具體代碼請自行查閱。

Codegen

代碼生成階段最後生成了一個字符串,咱們把字符串的雙引號去掉,看一下具體的內容是什麼:

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

const _hoisted_1 = { name: "test" }
const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一個文本節點 ")
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(" 這是註釋 "),
      _createVNode("p", null, _toDisplayString(test), 1 /* TEXT */),
      _hoisted_2,
      _hoisted_3
    ]))
  }
}

代碼生成模式

能夠看到上述代碼最後返回一個 render() 函數,做用是生成對應的 VNode。

其實代碼生成有兩種模式:module 和 function,由標識符 prefixIdentifiers 決定使用哪一種模式。

function 模式的特色是:使用 const { helpers... } = Vue 的方式來引入幫助函數,也就是是 createVode() createCommentVNode() 這些函數。向外導出使用 return 返回整個 render() 函數。

module 模式的特色是:使用 es6 模塊來導入導出函數,也就是使用 import 和 export。

靜態節點

另外還有三個變量是用 _hoisted_ 命名的,後面跟着數字,表明這是第幾個靜態變量。
再看一下 parse 階段的 HTML 模板字符串:

<div name="test">
  <!-- 這是註釋 -->
  <p>{{ test }}</p>
  一個文本節點
  <div>good job!</div>
</div>

這個示例只有一個動態節點,即 {{ test }},剩下的全是靜態節點。從生成的代碼中也能夠看出,生成的節點和模板中的代碼是一一對應的。靜態節點的做用就是隻生成一次,之後直接複用。

細心的網友可能發現了 _hoisted_2_hoisted_3 變量中都有一個 /*#__PURE__*/ 註釋。

這個註釋的做用是表示這個函數是純函數,沒有反作用,主要用於 tree-shaking。壓縮工具在打包時會將未被使用的代碼直接刪除(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 */) 最後一個參數 1 就是 transform 添加的 patchflag 了。

幫助函數 helpers

在 transform、codegen 這兩個階段,咱們都能看到 helpers 的影子,到底 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.
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(),它是幹什麼用的呢?

咱們知道編譯模塊 compiler-core 是平臺無關的,而 compiler-dom 是瀏覽器相關的編譯模塊。爲了能在瀏覽器正常運行 Vue 程序,就得把瀏覽器相關的 Vue 數據和函數導入進來。
registerRuntimeHelpers(helpers: any() 正是用來作這件事的,從 compiler-dom 的 runtimeHelpers.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(),往映射表注入了瀏覽器相關的部分函數。

helpers 是怎麼使用的呢?

在 parse 階段,解析到不一樣節點時會生成對應的 type。

在 transform 階段,會生成一個 helpers,它是一個 set 數據結構。每當它轉換 AST 時,都會根據 AST 節點的 type 添加不一樣的 helper 函數。

例如,假設它如今正在轉換的是一個註釋節點,它會執行 context.helper(CREATE_COMMENT),內部實現至關於 helpers.add('createCommentVNode')。而後在 codegen 階段,遍歷 helpers,將程序須要的函數從 Vue 裏導入,代碼實現以下:

// 這是 module 模式
`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(" 一個文本節點 ")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

看一下這段代碼是怎麼生成的,首先執行 genHoists(ast.hoists, context),將 transform 生成的靜態節點數組 hoists 做爲第一個參數。genHoists() 內部實現:

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

從上述代碼能夠看到,遍歷 hoists 數組,調用 genNode(exp, context)genNode() 根據不一樣的 type 執行不一樣的函數。

const _hoisted_1 = { name: "test" }

這一行代碼中的 const _hoisted_1 = genHoists() 生成,{ name: "test" }genObjectExpression() 生成。
同理,剩下的兩行代碼生成過程也是如此,只是最終調用的函數不一樣。

Vue3 源碼分析系列文章

相關文章
相關標籤/搜索