前言 在之前的 面試官:來說說vue3是怎麼處理內置的v-for、v-model等指令? 文章中講了transform階段處理完v-for、v-model等指令後,會生成一棵javascript AST抽象語法樹。這篇文章我們來接著講generate階段是如何根據這棵javascript AST抽象 ...
前言
在之前的 面試官:來說說vue3是怎麼處理內置的v-for、v-model等指令? 文章中講了transform
階段處理完v-for、v-model等指令後,會生成一棵javascript AST抽象語法樹。這篇文章我們來接著講generate
階段是如何根據這棵javascript AST抽象語法樹生成render函數字元串的,本文中使用的vue版本為3.4.19
。
看個demo
還是一樣的套路,我們通過debug一個demo來搞清楚render函數字元串是如何生成的。demo代碼如下:
<template>
<p>{{ msg }}</p>
</template>
<script setup lang="ts">
import { ref } from "vue";
const msg = ref("hello world");
</script>
上面這個demo很簡單,使用p標簽渲染一個msg響應式變數,變數的值為"hello world"。我們在瀏覽器中來看看這個demo生成的render函數是什麼樣的,代碼如下:
import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=23bfe016";
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock(
"p",
null,
_toDisplayString($setup.msg),
1
/* TEXT */
);
}
上面的render函數中使用了兩個函數:openBlock
和createElementBlock
。在之前的 vue3早已具備拋棄虛擬DOM的能力了文章中我們已經講過了這兩個函數:
-
openBlock
的作用為初始化一個全局變數currentBlock
數組,用於收集dom樹中的所有動態節點。 -
createElementBlock
的作用為生成根節點p標簽的虛擬DOM,然後將收集到的動態節點數組currentBlock
塞到根節點p標簽的dynamicChildren
屬性上。
render函數的生成其實很簡單,經過transform
階段處理後會生成一棵javascript AST抽象語法樹
,這棵樹的結構和要生成的render函數結構是一模一樣的。所以在generate
函數中只需要遞歸遍歷這棵樹,進行字元串拼接就可以生成render函數啦!
關註公眾號:【前端歐陽】,解鎖我更多vue原理文章。
加我微信heavenyjj0012回覆「666」,免費領取歐陽研究vue源碼過程中收集的源碼資料,歐陽寫文章有時也會參考這些資料。同時讓你的朋友圈多一位對vue有深入理解的人。
generate
函數
首先給generate
函數打個斷點,generate
函數在node_modules/@vue/compiler-core/dist/compiler-core.cjs.js文件中。
然後啟動一個debug終端,在終端中執行yarn dev
(這裡是以vite舉例)。在瀏覽器中訪問 http://localhost:5173/ ,此時斷點就會走到generate
函數中了。在我們這個場景中簡化後的generate
函數是下麵這樣的:
function generate(ast) {
const context = createCodegenContext();
const { push, indent, deindent } = context;
const preambleContext = context;
genModulePreamble(ast, preambleContext);
const functionName = `render`;
const args = ["_ctx", "_cache"];
args.push("$props", "$setup", "$data", "$options");
const signature = args.join(", ");
push(`function ${functionName}(${signature}) {`);
indent();
push(`return `);
genNode(ast.codegenNode, context);
deindent();
push(`}`);
return {
ast,
code: context.code,
};
}
generate
中主要分為四部分:
-
生成
context
上下文對象。 -
執行
genModulePreamble
函數生成:import { xxx } from "vue";
-
生成render函數中的函數名稱和參數,也就是
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
-
生成render函數中return的內容
context
上下文對象
context
上下文對象是執行createCodegenContext
函數生成的,將斷點走進createCodegenContext
函數。簡化後的代碼如下:
function createCodegenContext() {
const context = {
code: ``,
indentLevel: 0,
helper(key) {
return `_${helperNameMap[key]}`;
},
push(code) {
context.code += code;
},
indent() {
newline(++context.indentLevel);
},
deindent(withoutNewLine = false) {
if (withoutNewLine) {
--context.indentLevel;
} else {
newline(--context.indentLevel);
}
},
newline() {
newline(context.indentLevel);
},
};
function newline(n) {
context.push("\n" + ` `.repeat(n));
}
return context;
}
為了代碼具有較強的可讀性,我們一般都會使用換行和鎖進。context
上下文中的這些屬性和方法作用就是為了生成具有較強可讀性的render函數。
-
code
屬性:當前生成的render函數字元串。 -
indentLevel
屬性:當前的鎖進級別,每個級別對應兩個空格的鎖進。 -
helper
方法:返回render函數中使用到的vue包中export導出的函數名稱,比如返回openBlock
、createElementBlock
等函數 -
push
方法:向當前的render函數字元串後插入字元串code。 -
indent
方法:插入換行符,並且增加一個鎖進。 -
deindent
方法:減少一個鎖進,或者插入一個換行符並且減少一個鎖進。 -
newline
方法:插入換行符。
生成import {xxx} from "vue"
我們接著來看generate
函數中的第二部分,生成import {xxx} from "vue"
。將斷點走進genModulePreamble
函數,在我們這個場景中簡化後的genModulePreamble
函數代碼如下:
function genModulePreamble(ast, context) {
const { push, newline, runtimeModuleName } = context;
if (ast.helpers.size) {
const helpers = Array.from(ast.helpers);
push(
`import { ${helpers
.map((s) => `${helperNameMap[s]} as _${helperNameMap[s]}`)
.join(", ")} } from ${JSON.stringify(runtimeModuleName)}
`,
-1 /* End */
);
}
genHoists(ast.hoists, context);
newline();
push(`export `);
}
其中的ast.helpers
是在transform
階段收集的需要從vue中import導入的函數,無需將vue中所有的函數都import導入。在debug終端看看helpers
數組中的值如下圖:
從上圖中可以看到需要從vue中import導入toDisplayString
、openBlock
、createElementBlock
這三個函數。
在執行push
方法之前我們先來看看此時的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到此時生成的render函數字元串還是一個空字元串,執行完push方法後,我們來看看此時的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到此時的render函數中已經有了import {xxx} from "vue"
了。
這裡執行的genHoists
函數就是前面 搞懂 Vue 3 編譯優化:靜態提升的秘密文章中講過的靜態提升的入口。
生成render函數中的函數名稱和參數
執行完genModulePreamble
函數後,已經生成了一條import {xxx} from "vue"
了。我們接著來看generate
函數中render函數的函數名稱和參數是如何生成的,代碼如下:
const functionName = `render`;
const args = ["_ctx", "_cache"];
args.push("$props", "$setup", "$data", "$options");
const signature = args.join(", ");
push(`function ${functionName}(${signature}) {`);
上面的代碼很簡單,都是執行push
方法向render函數中添加code字元串,其中args
數組就是render函數中的參數。我們在來看看執行完上面這塊代碼後的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到此時已經生成了render函數中的函數名稱和參數了。
生成render函數中return的內容
接著來看generate
函數中最後一塊代碼,如下:
indent();
push(`return `);
genNode(ast.codegenNode, context);
首先調用indent
方法插入一個換行符並且增加一個鎖進,然後執行push
方法添加一個return
字元串。
接著以根節點的codegenNode
屬性為參數執行genNode
函數生成return中的內容,在我們這個場景中genNode
函數簡化後的代碼如下:
function genNode(node, context) {
switch (node.type) {
case NodeTypes.SIMPLE_EXPRESSION:
genExpression(node, context)
break
case NodeTypes.INTERPOLATION:
genInterpolation(node, context);
break;
case NodeTypes.VNODE_CALL:
genVNodeCall(node, context);
break;
}
}
這裡涉及到SIMPLE_EXPRESSION
、INTERPOLATION
和VNODE_CALL
三種AST抽象語法樹node節點類型:
-
INTERPOLATION
:表示當前節點是雙大括弧節點,我們這個demo中就是:{{msg}}
這個文本節點。 -
SIMPLE_EXPRESSION
:表示當前節點是簡單表達式節點,在我們這個demo中就是雙大括弧節點{{msg}}
中的更裡層節點msg
-
VNODE_CALL
:表示當前節點是虛擬節點,比如我們這裡第一次調用genNode
函數傳入的ast.codegenNode
(根節點的codegenNode
屬性)就是虛擬節點。
genVNodeCall
函數
由於當前節點是虛擬節點,第一次進入genNode
函數時會執行genVNodeCall
函數。在我們這個場景中簡化後的genVNodeCall
函數代碼如下:
const OPEN_BLOCK = Symbol(`openBlock`);
const CREATE_ELEMENT_BLOCK = Symbol(`createElementBlock`);
function genVNodeCall(node, context) {
const { push, helper } = context;
const { tag, props, children, patchFlag, dynamicProps, isBlock } = node;
if (isBlock) {
push(`(${helper(OPEN_BLOCK)}(${``}), `);
}
const callHelper = CREATE_ELEMENT_BLOCK;
push(helper(callHelper) + `(`, -2 /* None */, node);
genNodeList(
// 將參數中的undefined轉換成null
genNullableArgs([tag, props, children, patchFlag, dynamicProps]),
context
);
push(`)`);
if (isBlock) {
push(`)`);
}
}
首先判斷當前節點是不是block節點,由於此時的node為根節點,所以isBlock
為true。將斷點走進helper
方法,我們來看看helper(OPEN_BLOCK)
返回值是什麼。helper
方法的代碼如下:
const helperNameMap = {
[OPEN_BLOCK]: `openBlock`,
[CREATE_ELEMENT_BLOCK]: `createElementBlock`,
[TO_DISPLAY_STRING]: `toDisplayString`,
// ...省略
};
helper(key) {
return `_${helperNameMap[key]}`;
}
helper
方法中的代碼很簡單,這裡的helper(OPEN_BLOCK)
返回的就是_openBlock
。
將斷點走到第一個push
方法,代碼如下:
push(`(${helper(OPEN_BLOCK)}(${``}), `);
執行完這個push
方法後在debug終端看看此時的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到,此時render函數中增加了一個_openBlock
函數的調用。
將斷點走到第二個push
方法,代碼如下:
const callHelper = CREATE_ELEMENT_BLOCK;
push(helper(callHelper) + `(`, -2 /* None */, node);
同理helper(callHelper)
方法返回的是_createElementBlock
,執行完這個push
方法後在debug終端看看此時的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到,此時render函數中增加了一個_createElementBlock
函數的調用。
繼續將斷點走到genNodeList
部分,代碼如下:
genNodeList(
genNullableArgs([tag, props, children, patchFlag, dynamicProps]),
context
);
其中的genNullableArgs
函數功能很簡單,將參數中的undefined
轉換成null
。比如此時的props
就是undefined
,經過genNullableArgs
函數處理後傳給genNodeList
函數的props
就是null
。
genNodeList
函數
繼續將斷點走進genNodeList
函數,在我們這個場景中簡化後的代碼如下:
function genNodeList(nodes, context, multilines = false, comma = true) {
const { push } = context;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (shared.isString(node)) {
push(node);
} else {
genNode(node, context);
}
if (i < nodes.length - 1) {
comma && push(", ");
}
}
}
我們先來看看此時的nodes
參數,如下圖:
這裡的nodes
就是調用genNodeList
函數時傳的數組:[tag, props, children, patchFlag, dynamicProps]
,只是將數組中的undefined
轉換成了null
。
-
nodes
數組中的第一項為字元串p,表示當前節點是p標簽。 -
由於當前p標簽沒有props,所以第二項為null的字元串。
-
第三項為p標簽子節點:{{msg}}
-
第四項也是一個字元串,標記當前節點是否是動態節點。
在講genNodeList
函數之前,我們先來看一下如何使用h
函數生成一個<p>{{ msg }}</p>
標簽的虛擬DOM節點。根據vue官網的介紹,h
函數定義如下:
// 完整參數簽名
function h(
type: string | Component,
props?: object | null,
children?: Children | Slot | Slots
): VNode
h
函數接收的第一個參數是標簽名稱或者一個組件,第二個參數是props對象或者null,第三個參數是子節點。
所以我們要使用h
函數生成demo中的p標簽虛擬DOM節點代碼如下:
h("p", null, msg)
h
函數生成虛擬DOM實際就是調用的createBaseVNode
函數,而我們這裡的createElementBlock
函數生成虛擬DOM也是調用的createBaseVNode
函數。兩者的區別是createElementBlock
函數多接收一些參數,比如patchFlag
和dynamicProps
。
現在我想你應該已經反應過來了,為什麼調用genNodeList
函數時傳入的第一個參數nodes
為:[tag, props, children, patchFlag, dynamicProps]
。這個數組的順序就是調用createElementBlock
函數時傳入的參數順序。
所以在genNodeList
中會遍歷nodes
數組生成調用createElementBlock
函數需要傳入的參數。
先來看第一個參數tag
,這裡tag
的值為字元串"p"。所以在for迴圈中會執行push(node)
,生成調用createElementBlock
函數的第一個參數"p"。在debug終端看看此時的render函數,如下圖:
從上圖中可以看到createElementBlock
函數的第一個參數"p"
接著來看nodes
數組中的第二個參數:props
,由於p標簽中沒有props
屬性。所以第二個參數props
的值為字元串"null",在for迴圈中同樣會執行push(node)
,生成調用createElementBlock
函數的第二個參數"null"。在debug終端看看此時的render函數,如下圖:
從上圖中可以看到createElementBlock
函數的第二個參數null
接著來看nodes
數組中的第三個參數:children
,由於children
是一個對象,所以以當前children節點作為參數執行genNode
函數。
這個genNode
函數前面已經執行過一次了,當時是以根節點的codegenNode
屬性作為參數執行的。回顧一下genNode
函數的代碼,如下:
function genNode(node, context) {
switch (node.type) {
case NodeTypes.SIMPLE_EXPRESSION:
genExpression(node, context)
break
case NodeTypes.INTERPOLATION:
genInterpolation(node, context);
break;
case NodeTypes.VNODE_CALL:
genVNodeCall(node, context);
break;
}
}
前面我們講過了NodeTypes.INTERPOLATION
類型表示當前節點是雙大括弧節點,而我們這次執行genNode
函數傳入的p標簽children,剛好就是{{msg}}雙大括弧節點。所以代碼會走到genInterpolation
函數中。
genInterpolation
函數
將斷點走進genInterpolation
函數中,genInterpolation
代碼如下:
function genInterpolation(node, context) {
const { push, helper } = context;
push(`${helper(TO_DISPLAY_STRING)}(`);
genNode(node.content, context);
push(`)`);
}
首先會執行push
方法向render函數中插入一個_toDisplayString
函數調用,在debug終端看看執行完這個push
方法後的render函數,如下圖:
從上圖中可以看到此時createElementBlock
函數的第三個參數只生成了一半,調用_toDisplayString
函數傳入的參數還沒生成。
接著會以node.content
作為參數執行genNode(node.content, context);
生成_toDisplayString
函數的參數,此時代碼又走回了genNode
函數。
將斷點再次走進genNode
函數,看看此時的node是什麼樣的,如下圖:
從上圖中可以看到此時的node節點是一個簡單表達式節點,表達式為:$setup.msg
。所以代碼會走進genExpression
函數。
genExpression
函數
接著將斷點走進genExpression
函數中,genExpression
函數中的代碼如下:
function genExpression(node, context) {
const { content, isStatic } = node;
context.push(
isStatic ? JSON.stringify(content) : content,
-3 /* Unknown */,
node
);
}
由於當前的msg
變數是一個ref
響應式變數,所以isStatic
為false
。所以會執行push
方法,將$setup.msg
插入到render函數中。
執行完push
方法後,在debug終端看看此時的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到此時的render函數基本已經生成了,剩下的就是調用push
方法生成各個函數的右括弧")"和右花括弧"}"。將斷點逐層走出,直到generate
函數中。代碼如下:
function generate(ast) {
// ...省略
genNode(ast.codegenNode, context);
deindent();
push(`}`);
return {
ast,
code: context.code,
};
}
執行完最後一個 push
方法後,在debug終端看看此時的render函數字元串是什麼樣的,如下圖:
從上圖中可以看到此時的render函數終於生成啦!
總結
這是我畫的我們這個場景中generate
生成render函數的流程圖:
-
執行
genModulePreamble
函數生成:import { xxx } from "vue";
-
簡單字元串拼接生成render函數中的函數名稱和參數,也就是
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
-
以根節點的
codegenNode
屬性為參數調用genNode
函數生成render函數中return的內容。-
此時傳入的是虛擬節點,執行
genVNodeCall
函數生成return _openBlock(), _createElementBlock(
和調用genNodeList
函數,生成createElementBlock
函數的參數。 -
處理p標簽的
tag
標簽名和props
,生成createElementBlock
函數的第一個和第二個參數。此時render函數return的內容為:return _openBlock(), _createElementBlock("p", null
-
處理p標簽的children也就是
{{msg}}
節點,再次調用genNode
函數。此時node節點類型為雙大括弧節點,調用genInterpolation
函數。 -
在
genInterpolation
函數中會先調用push
方法,此時的render函數return的內容為:return _openBlock(), _createElementBlock("p", null, _toDisplayString(
。然後以node.content
為參數再次調用genNode
函數。 -
node.content
為$setup.msg
,是一個簡單表達式節點,所以在genNode
函數中會調用genExpression
函數。執行完genExpression
函數後,此時的render函數return的內容為:return _openBlock(), _createElementBlock("p", null, _toDisplayString($setup.msg
-
調用push方法生成各個函數的右括弧")"和右花括弧"}",生成最終的render函數
-
關註(圖1)公眾號:【前端歐陽】,解鎖我更多vue原理文章。
加我(圖2)微信回覆「666」,免費領取歐陽研究vue源碼過程中收集的源碼資料,歐陽寫文章有時也會參考這些資料。同時讓你的朋友圈多一位對vue有深入理解的人。