Vue3 模板編譯原理 (Vue 的編譯模組整體邏輯)

deeply發表於2021-09-09

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

compiler-core
compiler-dom // 瀏覽器compiler-sfc // 單檔案元件compiler-ssr // 服務端渲染複製程式碼

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

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

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

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)
    }
}複製程式碼

在原始碼中對應的幾個函式分別是:

  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,  // 根節點 0ELEMENT, // 元素節點 1TEXT, // 文字節點 2COMMENT, // 註釋節點 3SIMPLE_EXPRESSION, // 表示式 4INTERPOLATION, // 雙花插值 {{ }} 5ATTRIBUTE, // 屬性 6DIRECTIVE, // 指令 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 = _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: [],// methodshelper(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 中,主要看它是不是靜態節點,並且需要將 hoistStatic 設為 true。

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

可以看到,上面有三個靜態節點,所以 hoists 陣列有 3 個值。並且無論靜態節點巢狀有多深,都會被提升到 hoists 中。

type 變化

圖片描述

從上圖可以看到,最外層的 div 的 type 原來為 1,經過 transform 生成的 codegenNode 中的 type 變成了 13。 這個 13 是程式碼生成對應的型別 VNODE_CALL。另外還有:

// codegenVNODE_CALL, // 13JS_CALL_EXPRESSION, // 14JS_OBJECT_EXPRESSION, // 15JS_PROPERTY, // 16JS_ARRAY_EXPRESSION, // 17JS_FUNCTION_EXPRESSION, // 18JS_CONDITIONAL_EXPRESSION, // 19JS_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 = Vueconst { createVNode: _createVNode, createCommentVNode: _createCommentVNode, createTextVNode: _createTextVNode } = _Vueconst _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 變數中都有一個 註釋。

這個註釋的作用是表示這個函式是純函式,沒有副作用,主要用於 tree-shaking。壓縮工具在打包時會將未被使用的程式碼直接刪除(shaking 搖掉)。

再來看一下生成動態節點 {{ test }} 的程式碼: _createVNode("p", null, _toDisplayString(test), 1 )

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

return val == null
    ? ''
    : isObject(val)
      ? JSON.stringify(val, replacer, 2)
      : String(val)複製程式碼

程式碼很簡單,就是轉成字串輸出。

_createVNode("p", null, _toDisplayString(test), 1 ) 最後一個引數 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() 生成。 同理,剩下的兩行程式碼生成過程也是如此,只是最終呼叫的函式不同。

 

關於 Vue 編譯原理這塊的整體邏輯主要分三個部分,也可以說是分三步,這三個部分是有前後關係的:

  • 第一步是將 模板字串 轉換成 element ASTs(解析器)

  • 第二步是對 AST 進行靜態節點標記,主要用來做虛擬DOM的渲染最佳化(最佳化器)

  • 第三步是 使用 element ASTs 生成 render 函式程式碼字串(程式碼生成器)

解析器

解析器主要乾的事是將 模板字串 轉換成 element ASTs,例如:



<div>
  <p>{{name}}</p>
</div>

上面這樣一個簡單的 模板 轉換成 element AST 後是這樣的:

{
  tag: "div"
  type: 1,
  staticRoot: false,  static: false,
  plain: true,
  parent: undefined,
  attrsList: [],
  attrsMap: {},
  children: [
      {
      tag: "p"
      type: 1,
      staticRoot: false,      static: false,
      plain: true,
      parent: {tag: "div", ...},
      attrsList: [],
      attrsMap: {},
      children: [{
          type: 2,
          text: "{{name}}",          static: false,
          expression: "_s(name)"
      }]
    }
  ]
}

我們先用這個簡單的例子來說明這個解析器的內部究竟發生了什麼。



這段模板字串會扔到 while 中去迴圈,然後 一段一段 的擷取,把擷取到的 每一小段字串 進行解析,直到最後截沒了,也就解析完了。

上面這個簡單的模板擷取的過程是這樣的:

<div>
  <p>{{name}}</p>
</div>

<p>{{name}}</p>
</div>

<p>{{name}}</p>
</div>

{{name}}</p>
</div>

</p>
</div>

</div>

</div>

那是根據什麼截的呢?換句話說擷取字串有什麼規則麼?

當然有

只要判斷模板字串是不是以 < 開頭我們就可以知道我們接下來要擷取的這一小段字串是 標籤 還是 文字

舉個?:

<div></div> 這樣的一段字串是以 < 開頭的,那麼我們透過正則把 <div> 這一部分 match 出來,就可以拿到這樣的資料:

{
  tagName: 'div',
  attrs: [],
  unarySlash: '',
  start: 0,
  end: 5}

好奇如何用正則解析出 tagName 和 attrs 等資訊的同學可以看下面這個demo程式碼:

const ncname = '[a-zA-Z_][\w\-\.]*'
const qnameCapture = `((?:${ncname}\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^s*(/?)>/let html = `<div></div>`let index = 0const start = html.match(startTagOpen)

const match = {
  tagName: start[1],
  attrs: [],
  start: 0}
html = html.substring(start[0].length)
index += start[0].lengthlet end, attrwhile (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
  html = html.substring(attr[0].length)
  index += attr[0].length
  match.attrs.push(attr)
}if (end) {
  match.unarySlash = end[1]
  html = html.substring(end[0].length)
  index += end[0].length
  match.end = index
}
console.log(match)

Stack

用正則把 開始標籤 中包含的資料(attrs, tagName 等)解析出來之後還要做一個很重要的事,就是要維護一個 stack

那這個 stack 是用來幹什麼的呢?

這個 stack 是用來記錄一個層級關係的,用來記錄DOM的深度。

更準確的說,當解析到一個 開始標籤 或者 文字,無論是什麼, stack 中的最後一項,永遠是當前正在被解析的節點的 parentNode 父節點。

透過 stack 解析器就可以把當前解析到的節點 push 到 父節點的 children 中。

也可以把當前正在解析的節點的 parent 屬性設定為 父節點。

事實上也確實是這麼做的。

但並不是只要解析到一個標籤的開始部分就把當前標籤 pushstack 中。

因為在 HTML 中有一種 自閉和標籤,比如 input

<input /> 這種 自閉和的標籤 是不需要 pushstack 中的,因為 input 並不存在子節點。

所以當解析到一個標籤的開始時,要判斷當前被解析的標籤是否是自閉和標籤,如果不是自閉和標籤才 pushstack 中。

if (!unary) {
  currentParent = element
  stack.push(element)
}

現在有了 DOM 的層級關係,也可以解析出DOM的 開始標籤,這樣每解析一個 開始標籤 就生成一個 ASTElement (儲存當前標籤的attrs,tagName 等資訊的object)

並且把當前的 ASTElement push 到 parentNodechildren 中,同時給當前 ASTElementparent 屬性設定為 stack 中的最後一項

currentParent.children.push(element)
element.parent = currentParent

< 開頭的幾種情況

但並不是所有以 < 開頭的字串都是 開始標籤,以 < 開頭的字串有以下幾種情況:

  • 開始標籤 <div>

  • 結束標籤 </div>

  • HTML註釋 <!-- 我是註釋 -->

  • Doctype <!DOCTYPE html>

  • 條件註釋(Downlevel-revealed conditional comment)

當然我們解析器在解析的過程中遇到的最多的是 開始標籤 結束標籤註釋

擷取文字

我們繼續上面的例子解析,div開始標籤 解析之後剩餘的模板字串是下面的樣子:

<p>{{name}}</p>
</div>

這一次我們在解析發現 模板字串 不是以 < 開頭了。

那麼如果模板字串不是以 < 開頭的怎麼處理呢??

其實如果字串不是以 < 開頭可能會出現這麼幾種情況:

我是text <div></div>

或者:

我是text </p>

不論是哪種情況都會將標籤前面的文字部分解析出來,擷取這段文字其實並不難,看下面的例子:

// 可以直接將本 demo 放到瀏覽器 console 中去執行const html = '我是text </p>'let textEnd = html.indexOf('<')
const text = html.substring(0, textEnd)
console.log(text)

當然 vue 對文字的擷取不只是這麼簡單,vue對文字的擷取做了很安全的處理,如果 < 是文字的一部分,那上面 DEMO 中擷取的內容就不是我們想要的,例如這樣的:

a < b </p>

如果是這樣的文字,上面的 demo 肯定就掛了,擷取出的文字就會遺漏一部分而 vue 對這部分是進行了處理的,看下面的程式碼:

let textEnd = html.indexOf('<')let text, rest, nextif (textEnd >= 0) {
  rest = html.slice(textEnd)  // 剩餘部分的 HTML 不符合標籤的格式那肯定就是文字
  // 並且還是以 < 開頭的文字
  while (
    !endTag.test(rest) &&
    !startTagOpen.test(rest) &&
    !comment.test(rest) &&
    !conditionalComment.test(rest)
  ) {    // < in plain text, be forgiving and treat it as text
    next = rest.indexOf('<', 1)    if (next < 0) break
    textEnd += next
    rest = html.slice(textEnd)
  }
  text = html.substring(0, textEnd)
  html = html.substring(0, textEnd)
}

這段程式碼的邏輯是如果文字擷取完之後,剩餘的 模板字串 開頭不符合標籤的格式規則,那麼肯定就是有沒擷取完的文字

這個時候只需要迴圈把 textEnd 累加,直到剩餘的 模板字串 符合標籤的規則之後在一次性把 text模板字串 中擷取出來就好了。

繼續上面的例子,當前剩餘的 模板字串 是這個樣子的:

<p>{{name}}</p>
</div>

擷取之後剩餘的 模板字串 是這個樣子的:

<p>{{name}}</p>
</div>

被擷取出來的文字是這樣的:

"
 "

擷取之後就需要對文字進行解析,不過在解析文字之前需要進行預處理,也就是先簡單加工一下文字,vue 是這樣做的:

const children = currentParent.children
text = inPre || text.trim()
  ? isTextTag(currentParent) ? text : decodeHTMLCached(text)  // only preserve whitespace if its not right after a starting tag
  : preserveWhitespace && children.length ? ' ' : ''

這段程式碼的意思是:

  • 如果文字不為空,判斷父標籤是不是script或style,

  1. 如果是則什麼都不管,

  2. 如果不是需要 decode 一下編碼,使用github上的 he 這個類庫的 decodeHTML 方法

如果文字為空,判斷有沒有兄弟節點,也就是 parent.children.length 是不是為 0

  1. 如果大於0 返回 ' '

  2. 如果為 0 返回 ''

結果發現這一次的 text 正好命中最後的那個 '',所以這一次就什麼都不用做繼續下一輪解析就好

繼續上面的例子,現在的 模板字串 變是這個樣子:

<p>{{name}}</p>
</div>

接著解析 <p>,解析流程和上面的 <div> 一樣就不說了,直接繼續:

{{name}}</p>
</div>

透過上面寫的文字的擷取方式這一次擷取出來的文字是這個樣子的 "{{name}}"

解析文字

其實解析文字節點並不難,只需要將文字節點 pushcurrentParent.children.push(ast) 就行了。

但是帶變數的文字和不帶變數的純文字是不同的處理方式。

帶變數的文字是指 Hello {{ name }} 這個 name 就是變數。

不帶變數的文字是這樣的 Hello Berwin 這種沒有訪問資料的純文字。

純文字比較簡單,直接將 文字節點的ast pushparent 節點的 children 中就行了,例如:

children.push({
  type: 3,
  text: '我是純文字'
})

而帶變數的文字要多一個解析文字變數的操作:

const expression = parseText(text, delimiters) // 對變數解析 {{name}} => _s(name)children.push({
  type: 2,
  expression,
  text
})

上面例子中 "{{name}}" 是一個帶變數的文字,經過 parseText 解析後 expression_s(name),所以最後 pushcurrentParent.children 中的節點是這個樣子的:

{
  expression: "_s(name)",
  text: "{{name}}",
  type: 2}

結束標籤的處理

現在文字解析完之後,剩餘的 模板字串 變成了這個樣子:

</p>
</div>

這一次還是用上面說的辦法,html.indexOf('<') === 0,發現是 < 開頭的,然後用正則去 match 發現符合 結束標籤的格式,把它擷取出來。

並且還要做一個處理是用當前標籤名在 stack 從後往前找,將找到的 stack 中的位置往後的所有標籤全部刪除(意思是,已經解析到當前的結束標籤,那麼它的子集肯定都是解析過的,試想一下當前標籤都關閉了,它的子集肯定也都關閉了,所以需要把當前標籤位置往後從 stack中都清掉)

結束標籤不需要解析,只需要將 stack 中的當前標籤刪掉就好。

雖然不用解析,但 vue 還是做了一個最佳化處理,children 中的最後一項如果是空格 " ",則刪除最後這一項:

if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
  element.children.pop()
}

因為最後這一項空格是沒有用的,舉個例子:

<ul>
  <li></li>
</ul>

上面例子中解析成 element ASTs之後 ul 的結束標籤 </ul>li 的結束標籤 </li> 之間有一個空格,這個空格也屬於文字節點在 ulchildren 中,這個空格是沒有用的,把這個空格刪掉每次渲染dom都會少渲染一個文字節點,可以節省一定的效能開銷。

現在剩餘的 模板字串 已經不多了,是下面的樣子:

</div>

然後解析文字,就是一個其實就是一個空格的文字節點。

然後再一次解析結束標籤 </div>

</div>

解析完畢退出 while 迴圈。

解析完之後拿到的 element ASTs 就是文章開頭寫的那樣。

總結一下

其實這樣一個模板解析器的原理不是特別難,主要就是兩部分內容,一部分是 擷取 字串,一部分是對擷取之後的字串做 解析

每擷取一段標籤的開頭就 pushstack中,解析到標籤的結束就 pop 出來,當所有的字串都截沒了也就解析完了。

上文中的例子是比較簡單的,不涉及一些迴圈啊,什麼的,註釋的處理這些也都沒有涉及到,但其實這篇文章中想表達的內容也不是來扣細節的,如果扣細節可能要寫一本小書才夠,一篇文章的字數可能只夠把一個大體的邏輯給大家講清楚,希望同學們見諒,如果對細節感興趣可以在下面評論,我們們一起討論共同學習進步~

最佳化器

最佳化器的目標是找出那些靜態節點並打上標記,而靜態節點指的是 DOM 不需要發生變化的節點,例如:

<p>我是靜態節點,我不需要發生變化</p>

標記靜態節點有兩個好處:

  1. 每次重新渲染的時候不需要為靜態節點建立新節點

  2. 在 Virtual DOM 中 patching 的過程可以被跳過

最佳化器的實現原理主要分兩步:

  • 第一步:用遞迴的方式將所有節點新增 static 屬性,標識是不是靜態節點

  • 第二步:標記所有靜態根節點

什麼是靜態根節點? 答:子節點全是靜態節點的節點就是靜態根節點,例如:

<ul>
  <li>我是靜態節點,我不需要發生變化</li>
  <li>我是靜態節點2,我不需要發生變化</li>
  <li>我是靜態節點3,我不需要發生變化</li>
</ul>

ul 就是靜態根節點。

如何將所有節點標記 static 屬性?

vue 判斷一個節點是不是靜態節點的做法其實並不難:

  1. 先根據自身是不是靜態節點做一個標記 node.static = isStatic(node)

  2. 然後在迴圈 children,如果 children 中出現了哪怕一個節點不是靜態節點,在將當前節點的標記修改成 falsenode.static = false

如何判斷一個節點是不是靜態節點?

也就是說 isStatic 這個函式是如何判斷靜態節點的?

function isStatic (node: ASTNode): boolean {  if (node.type === 2) { // expression
    return false
  }  if (node.type === 3) { // text
    return true
  }  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in
    isPlatformReservedTag(node.tag) && // not a component
    !isDirectChildOfTemplateFor(node) &&    Object.keys(node).every(isStaticKey)
  ))
}

先解釋一下,在上文講的解析器中將 模板字串 解析成 AST 的時候,會根據不同的文字型別設定一個 type

type 說明
1 元素節點
2 帶變數的動態文字節點
3 不帶變數的純文字節點

所以上面 isStatic 中的邏輯很明顯,如果 type === 2 那肯定不是 靜態節點 返回 false,如果 type === 3 那就是靜態節點,返回 true

那如果 type === 1,就有點複雜了,元素節點判斷是不是靜態節點的條件很多,我們們先一個個看。

首先如果 node.pretrue 直接認為當前節點是靜態節點。

其次 node.hasBindings 不能為 true

node.hasBindings 屬性是在解析器轉換 AST 時設定的,如果當前節點的 attrs 中,有 v-@:開頭的 attr,就會把 node.hasBindings 設定為 true

const dirRE = /^v-|^@|^:/if (dirRE.test(attr)) {  // mark element as dynamic
  el.hasBindings = true}

並且元素節點不能有 iffor屬性。

node.ifnode.for 也是在解析器轉換 AST 時設定的。

在解析的時候發現節點使用了 v-if,就會在解析的時候給當前節點設定一個 if 屬性。

就是說元素節點不能使用 v-if v-for v-else 等指令。

並且元素節點不能是 slotcomponent

並且元素節點不能是元件。

例如:

<List></List>

不能是上面這樣的自定義元件

並且元素節點的父級節點不能是帶 v-fortemplate

並且元素節點上不能出現額外的屬性。

額外的屬性指的是不能出現 type
tag attrsList attrsMap plain parent children attrs staticClass staticStyle 這幾個屬性之外的其他屬性,如果出現其他屬性則認為當前節點不是靜態節點。

只有符合上面所有條件的節點才會被認為是靜態節點。

如何標記所有節點?

上面講如何判斷單個節點是否是靜態節點,AST 是一棵樹,我們如何把所有的節點都打上標記(static)呢?

還有一個問題是,判斷 元素節點是不是靜態節點不能光看它自身是不是靜態節點,如果它的子節點不是靜態節點,那就算它自身符合上面講的靜態節點的條件,它也不是靜態節點。

所以在 vue 中有這樣一行程式碼:

for (let i = 0, l = node.children.length; i < l; i++) {
  const child = node.children[i]
  markStatic(child)  if (!child.static) {
    node.static = false
  }
}

markStatic 可以給節點標記,規則上面剛講過,vue.js 透過迴圈 children 打標記,然後每個不同的子節點又會走相同的邏輯去迴圈它的 children 這樣遞迴下來所有的節點都會被打上標記。

然後在迴圈中判斷,如果某個子節點不是 靜態節點,那麼講當前節點的標記改為 false

這樣一圈下來之後 AST 上的所有節點都被準確的打上了標記。

如何標記靜態根節點?

標記靜態根節點其實也是遞迴的過程。

vue 中的實現大概是這樣的:

function markStaticRoots (node: ASTNode, isInFor: boolean) {  if (node.type === 1) {    // For a node to qualify as a static root, it should have children that
    // are not just static text. Otherwise the cost of hoisting out will
    // outweigh the benefits and it's better off to just always render it fresh.
    if (node.static && node.children.length && !(
      node.children.length === 1 &&
      node.children[0].type === 3
    )) {
      node.staticRoot = true
      return
    } else {
      node.staticRoot = false
    }    if (node.children) {      for (let i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i], isInFor || !!node.for)
      }
    }
  }
}

這段程式碼其實就一個意思:

當前節點是靜態節點,並且有子節點,並且子節點不是單個靜態文字節點這種情況會將當前節點標記為根靜態節點。

額,,可能有點繞口,重新解釋下。

上面我們標記 靜態節點 的時候有一段邏輯是隻有所有 子節點 都是 靜態節點,當前節點才是真正的 靜態節點。

所以這裡我們如果發現一個節點是 靜態節點,那就能證明它的所有 子節點 也都是靜態節點,而我們要標記的是 靜態根節點,所以如果一個靜態節點只包含了一個文字節點那就不會被標記為 靜態根節點。

其實這麼做也是為了效能考慮,vue 在註釋中也說了,如果把一個只包含靜態文字的節點標記為根節點,那麼它的成本會超過收益~

總結一下

整體邏輯其實就是遞迴 AST 這顆樹,然後將 靜態節點 和 靜態根節點 找到並打上標記。

程式碼生成器

程式碼生成器的作用是使用 element ASTs 生成 render 函式程式碼字串。

使用本文開頭舉的例子中的模板生成後的 AST 來生成 render 後是這樣的:

{
  render: `with(this){return _c('div',[_c('p',[_v(_s(name))])])}`
}

格式化後是這樣的:

with(this){  return _c(
    'div',
    [
      _c(
        'p',
        [
          _v(_s(name))
        ]
      )
    ]
  )
}

生成後的程式碼字串中看到了有幾個函式呼叫 _c_v_s

_c 對應的是 createElement,它的作用是建立一個元素。

  1. 第一個引數是一個HTML標籤名

  2. 第二個引數是元素上使用的屬性所對應的資料物件,可選項

  3. 第三個引數是 children

例如:

一個簡單的模板:

<p title="Berwin" @click="c">1</p>

生成後的程式碼字串是:

`with(this){return _c('p',{attrs:{"title":"Berwin"},on:{"click":c}},[_v("1")])}`

格式化後:

with(this){  return _c(
    'p',
    {
      attrs:{"title":"Berwin"},
      on:{"click":c}
    },
    [_v("1")]
  )
}

_v 的意思是建立一個文字節點。

_s 是返回引數中的字串。

程式碼生成器的總體邏輯其實就是使用 element ASTs 去遞迴,然後拼出這樣的 _c('div',[_c('p',[_v(_s(name))])]) 字串。

那如何拼這個字串呢??

請看下面的程式碼:

function genElement (el: ASTElement, state: CodegenState) {
  const data = el.plain ? undefined : genData(el, state)
  const children = el.inlineTemplate ? null : genChildren(el, state, true)    
  let code = `_c('${el.tag}'${
    data ? `,${data}` : '' // data
  }${
    children ? `,${children}` : '' // children
  })`  
  return code
}

因為 _c 的引數需要 tagNamedatachildren

所以上面這段程式碼的主要邏輯就是用 genDatagenChildren 獲取 datachildren,然後拼到 _c 中去,拼完後把拼好的 "_c(tagName, data, children)" 返回。

所以我們現在比較關心的兩個問題:

  1. data 如何生成的(genData 的實現邏輯)?

  2. children 如何生成的(genChildren 的實現邏輯)?

我們先看 genData 是怎樣的實現邏輯:

function genData (el: ASTElement, state: CodegenState): string {  let data = '{'  // key
  if (el.key) {
    data += `key:${el.key},`
  }  // ref
  if (el.ref) {
    data += `ref:${el.ref},`
  }  if (el.refInFor) {
    data += `refInFor:true,`
  }  // pre
  if (el.pre) {
    data += `pre:true,`
  }  // ... 類似的還有很多種情況
  data = data.replace(/,$/, '') + '}'  return data
}

可以看到,就是根據 AST 上當前節點上都有什麼屬性,然後針對不同的屬性做一些不同的處理,最後拼出一個字串~

然後我們在看看 genChildren 是怎樣的實現的:

function genChildren (
  el: ASTElement,
  state: CodegenState): string | void {
  const children = el.children  if (children.length) {    return `[${children.map(c => genNode(c, state)).join(',')}]`
  }
}

function genNode (node: ASTNode, state: CodegenState): string {  if (node.type === 1) {    return genElement(node, state)
  } if (node.type === 3 && node.isComment) {    return genComment(node)
  } else {    return genText(node)
  }
}

從上面程式碼中可以看出,生成 children 的過程其實就是迴圈 AST 中當前節點的 children,然後把每一項在重新按不同的節點型別去執行 genElement genComment genText。如果 genElement 中又有 children 在迴圈生成,如此反覆遞迴,最後一圈跑完之後能拿到一個完整的 render 函式程式碼字串,就是類似下面這個樣子。

"_c('div',[_c('p',[_v(_s(name))])])"

最後把生成的 code 裝到 with 裡。

export function generate (
  ast: ASTElement | void,
  options: CompilerOptions): CodegenResult {
  const state = new CodegenState(options)  // 如果ast為空,則建立一個空div
  const code = ast ? genElement(ast, state) : '_c("div")'  return {
    render: `with(this){return ${code}}`
  }
}

關於程式碼生成器的部分到這裡就說完了,其實原始碼中遠不止這麼簡單,很多細節我都沒有去說,我只說了一個大體的流程,對具體細節感興趣的同學可以自己去看原始碼瞭解詳情。

總結

本篇文章我們說了 vue 對模板編譯的整體流程分為三個部分:解析器(parser),最佳化器(optimizer)和程式碼生成器(code generator)。

解析器(parser)的作用是將 模板字串 轉換成 element ASTs

最佳化器(optimizer)的作用是找出那些靜態節點和靜態根節點並打上標記。

程式碼生成器(code generator)的作用是使用 element ASTs 生成 render函式程式碼(generate render function code from element ASTs)。

用一張圖來表示:

[圖片上傳失敗...(image-4ad47f-1521111234756)]

解析器(parser)的原理是一小段一小段的去擷取字串,然後維護一個 stack 用來儲存DOM深度,每擷取到一段標籤的開始就 pushstack 中,當所有字串都擷取完之後也就解析出了一個完整的 AST

最佳化器(optimizer)的原理是用遞迴的方式將所有節點打標記,表示是否是一個 靜態節點,然後再次遞迴一遍把 靜態根節點 也標記出來。

程式碼生成器(code generator)的原理也是透過遞迴去拼一個函式執行程式碼的字串,遞迴的過程根據不同的節點型別呼叫不同的生成方法,如果發現是一顆元素節點就拼一個 _c(tagName, data, children) 的函式呼叫字串,然後 datachildren 也是使用 AST 中的屬性去拼字串。

如果 children 中還有 children 則遞迴去拼。

最後拼出一個完整的 render 函式程式碼。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/1978/viewspace-2826585/,如需轉載,請註明出處,否則將追究法律責任。

相關文章