Vue3 的編譯模組包含4個目錄:
compiler-core // 編譯核心
Compiler-DOM // 瀏覽器相關
Compiler-sfc // 單檔案元件
Compiler-SSR // 服務端渲染
其中,compiler-core 模組是Vue編譯的核心模組,與平臺無關。其餘三個基於 compiler-core,適用於不同的平臺。
Vue 的編譯分為三個階段,即 解析(Parse)、轉換(Transform)和程式碼生成(Codegen)。
Parse 階段將模板字串轉換為語法抽象樹 AST。Transform 階段對 AST 做一些轉換處理。Codegen 階段根據 AST 生成相應的渲染函式字串。
Parse 階段
分析模板字串時,Vue 可分為兩種情況:以< 開頭的字串,和不是以 < 開頭的字串。
不是以 < 開頭的字串有兩種情況:文字節點或者插入表示式 {{exp}}。
使用 < 將字串的開頭分為以下幾種情況:
- 元素開始標籤 <div>
- 元素結束標籤 </div>
- 註釋節點 <!-- 123 -->
- 檔案宣告 <!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
相對應的幾個函式如下:
parseChildren()
,入口函式parseInterpolation()
,分析雙花插值表示式parseComment()
,解析註釋parseBogusComment()
,分析檔案宣告parseTag()
,分析標籤parseElement()
,分析元素節點,它將在內部執行parseTag()
parseText()
,分析普通文字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] 第二個字元的規則:
- 遇到 ! 時,呼叫字串原始方法 startsWith(),分析是 <!--的開頭, 還是 <!DOCTYPE 的開頭,它們對應的處理函式不同,例子中程式碼最終將解析到註釋節點。
- 如果是 / ,按結束標籤。
- 如果不是 /,按開始標籤處理。
在我們的示例中,這是一個 <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 字串,並解析其屬性。
屬性有兩種情況:
- HTML的普通屬性
- 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}} 的字串處理邏輯稍微複雜一些:
- 首先提取出雙括號內的內容,即 test,呼叫 trim 函式去掉兩邊空格。
- 然後生成兩個節點,一個節點為 INTERPOLATION 型別值為5,表示它是一個雙花插值。
- 第二個節點是其內容 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 中,還可以看到一些節點上的其他屬性:
- NS,名稱空間,通常為 HTML,值為0
- LOC,它是一條位置訊息,指示此節點位於源 HTML字串的位置,包含行、列、偏移量等資訊。
- {{ 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:
可以看到 CodegenNode、Helpers 和 Hoists 已填充了相應的值。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 node是 createVnodeCall 函式生成。
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模組匯入匯出功能,即 import 和 export。
Static node 靜態節點
此外,還有三個變數。以 hoisted 命名,後面跟數字,表示這是靜態變數。
看看解析階段的 HTML 模板字串:
<div name="test">
<! - This is a comment->
<p>{{ test }}</p>
A text node
<div>good job!</div>
</div>
這個示例只有一個動態節點,即 {{test},其餘的都是靜態節點。從生成的程式碼中還可以看出,生成的節點和模板中的程式碼對應於一個或多個節點。靜態節點的作用是隻生成一次,以後直接重用。
細心的你可能會發現 Highed_2 和 Highed_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 輔助函式
在 Transform 和 Codegen 階段,都看到了 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-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() 對映表被注入與瀏覽器相關的函式。
如何使用這些輔助函式?
在解析階段,解析不同節點時會生成相應的型別。
在轉換階段,生成一個輔助物件,它是一個集合資料結構。每當轉換 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() 函式生成。