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>
用偽代碼表示,大概過程如下:
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)
}
}
在源碼中對應的幾個函數分別是:
parseChildren()
,主入口。parseInterpolation()
,解析雙花插值表達式。parseComment()
,解析注釋。parseBogusComment()
,解析文檔聲明。parseTag()
,解析標簽。parseElement()
,解析元素節點,它會在內部執行parseTag()
。parseText()
,解析普通文本。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] 的字符是什么:
- 如果是
!
,則調用字符串原生方法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。
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 上,我們還能看到某些節點上有一些別的屬性:
- ns,命名空間,一般為 HTML,值為 0。
- loc,它是一個位置信息,表明這個節點在源 HTML 字符串中的位置,包含行,列,偏移量等信息。
{{ 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()
生成。
同理,剩下的兩行代碼生成過程也是如此,只是最終調用的函數不同。