1. 程式人生 > 程式設計 >深入瞭解Vue3模板編譯原理

深入瞭解Vue3模板編譯原理

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 }} 插值表示式。

而以 < 開頭的字串又分為以下幾種情況:

  • 元素開始標籤 <div>
  • 元素結束標籤 </div>
  • 註釋節點 <!-- 123 -->
  • 文件宣告 <!DOCTYPE html>

用偽程式碼表示,大概過程如下:

e (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 檔案中,下面是一個元素節點的定義:

rt 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] 的字元是什麼:

  • 如果是 ! ,則呼叫字串原生方法 startsWith() 看看是以 '<!--' 開頭還是以 '<!DOCTYPE' 開頭。雖然這兩者對應的處理函式不一樣,但它們最終都是解析為註釋節點。
  • 如果是 / ,則按結束標籤處理。
  • 如果不是 / ,則按開始標籤處理。

從我們的示例來看,這是一個 <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 的屬性有兩種情況:

  • HTML 普通屬性
  • 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 }}

  • 先將雙花括號中的內容提取出來,即 test ,再對它執行 trim() ,去除空格。
  • 然後會生成兩個節點,一個節點是 INTERPOLATION ,type 為 5,表示它是雙花插值。
  • 第二個節點是它的內容,即 test ,它會生成一個 SIMPLE_EXPRESSION 節點,type 為 4。
turn {
 type: NodeTypes.INTERPOLATION,// 雙花插值型別
 content: {
 type: NodeTypes.SIMPLE_EXPRESSION,isStatic: false,// 非靜態節點
 isConstant: false,content,loc: getSelection(context,innerStart,innerEnd)
 },start)
}

剩下的字串解析邏輯和上文的差不多,就不解釋了,最後這個示例解析出來的 AST 如下所示:

深入瞭解Vue3模板編譯原理

從 AST 上,我們還能看到某些節點上有一些別的屬性:

  • ns,名稱空間,一般為 HTML,值為 0。
  • loc,它是一個位置資訊,表明這個節點在源 HTML 字串中的位置,包含行,列,偏移量等資訊。
  • {{ test }} 解析出來的節點會有一個 isStatic 屬性,值為 false,表示這是一個動態節點。如果是靜態節點,則只會生成一次,並且在後面的階段一直複用同一個,不用進行 diff 比較

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

t 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 。大家可以在 網站 上自己試一下。

深入瞭解Vue3模板編譯原理

深入瞭解Vue3模板編譯原理

prefixIdentifiers

這個引數的作用是用於程式碼生成。例如 {{ foo }} 在 module 模式下生成的程式碼為 _ctx.foo ,而在 function 模式下是 with (this) { ... } 。因為在 module 模式下,預設為嚴格模式,不能使用 with 語句。

PatchFlags

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

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

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:

深入瞭解Vue3模板編譯原理

可以看到 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 中,主要看它是不是靜態節點,並且需要將 hoistStatic 設為 true。

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

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

type 變化

深入瞭解Vue3模板編譯原理

從上圖可以看到,最外層的 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() 生成的:

{
 type: NodeTypes.VNODE_CALL,loc
}

可以從上述程式碼看到,type 被設定為 NodeTypes.VNODE_CALL,即 13。

每個不同的節點都由不同的 transform 函式來處理,由於篇幅有限,具體程式碼請自行查閱。

Codegen

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

nst _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",_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",1 /* TEXT */)

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

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

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

_createVNode("p",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 節點呼叫不同的程式碼生成函式,最終將程式碼字串拼在一起,輸出一個完整的程式碼字串。

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

t _hoisted_1 = { name: "test" }
const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一個文字節點 ")
const _hoisted_3 = /*#__PURE__*/_createVNode("div",-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 執行不同的函式。

st _hoisted_1 = { name: "test" }

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

到此這篇關於深入瞭解Vue3模板編譯原理的文章就介紹到這了,更多相關Vue3模板編譯內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!