前言
我們每天都在使用 defineEmits
宏函式,但是你知道defineEmits
宏函式經過編譯後其實就是vue2的選項式API嗎?透過回答下面兩個問題,我將逐步為你揭秘defineEmits
宏函式的神秘面紗。為什麼 Vue 的 defineEmits
宏函式不需要 import 匯入就可用?為什麼defineEmits
的返回值等同於$emit
方法用於在元件中丟擲事件?
舉兩個例子
要回答上面提的幾個問題我們先來看兩個例子是如何宣告事件和丟擲事件,分別是vue2的選項式語法和vue3的組合式語法。
我們先來看vue2的選項式語法的例子,options-child.vue
檔案程式碼如下:
<template>
<button @click="handleClick">放大文字</button>
</template>
<script>
export default {
name: "options-child",
emits: ["enlarge-text"],
methods: {
handleClick() {
this.$emit("enlarge-text");
},
},
};
</script>
使用emits
選項宣告瞭要丟擲的事件"enlarge-text",然後在點選按鈕後呼叫this.$emit
方法丟擲"enlarge-text"
事件。這裡的this大家都知道是指向的當前元件的vue例項,所以this.$emit
是呼叫的當前vue例項的$emit
方法。大家先記住vue2的選項式語法例子,後面我們講defineEmits
宏函式編譯原理時會用。
我們再來看看vue3的組合式語法的例子,composition-child.vue
程式碼如下:
<template>
<button @click="handleClick">放大文字</button>
</template>
<script setup lang="ts">
const emits = defineEmits(["enlarge-text"]);
function handleClick() {
emits("enlarge-text");
}
</script>
在這個例子中我們使用了defineEmits
宏函式宣告瞭要丟擲的事件"enlarge-text",defineEmits
宏函式執行後返回了一個emits
函式,然後在點選按鈕後使用 emits("enlarge-text")
丟擲"enlarge-text"
事件。
透過debug搞清楚上面幾個問題
首先我們要搞清楚應該在哪裡打斷點,在我之前的文章 vue檔案是如何編譯為js檔案 中已經帶你搞清楚了將vue檔案中的<script>
模組編譯成瀏覽器可直接執行的js程式碼,底層就是呼叫vue/compiler-sfc
包的compileScript
函式。當然如果你還沒看過我的vue檔案是如何編譯為js檔案 文章也不影響這篇文章閱讀。
所以我們將斷點打在vue/compiler-sfc
包的compileScript
函式中,一樣的套路,首先我們在vscode的開啟一個debug終端。
然後在node_modules中找到vue/compiler-sfc
包的compileScript
函式打上斷點,compileScript
函式位置在/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js
。在debug終端上面執行yarn dev
後在瀏覽器中開啟對應的頁面,比如:http://localhost:5173/ 。此時斷點就會走到compileScript
函式中,由於每編譯一個vue
檔案都要走到這個debug中,現在我們只想debug
看看composition-child.vue
檔案,也就是我們前面舉的vue3的組合式語法的例子。所以為了方便我們在compileScript
中加了下面這樣一段程式碼,並且去掉了在compileScript
函式中加的斷點,這樣就只有編譯composition-child.vue
檔案時會走進斷點。加的這段程式碼中的sfc.fileName
就是檔案路徑的意思,後面我們會講。
compileScript
函式
我們再來回憶一下composition-child.vue
檔案中的script
模組程式碼如下:
<script setup lang="ts">
const emits = defineEmits(["enlarge-text"]);
function handleClick() {
emits("enlarge-text");
}
</script>
compileScript
函式內包含了編譯script
模組的所有的邏輯,程式碼很複雜,光是原始碼就接近1000行。這篇文章我們同樣不會去通讀compileScript
函式的所有功能,只講涉及到defineEmits
流程的程式碼。這個是根據我們這個場景將compileScript
函式簡化後的程式碼:
function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
const endOffset = ctx.endOffset;
const scriptSetupAst = ctx.scriptSetupAst;
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
// ...
}
if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
const isDefineEmits = processDefineEmits(ctx, init, decl.id);
if (isDefineEmits) {
ctx.s.overwrite(
startOffset + init.start,
startOffset + init.end,
"__emit"
);
}
}
}
}
if (
(node.type === "VariableDeclaration" && !node.declare) ||
node.type.endsWith("Statement")
) {
// ....
}
}
ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);
let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`;
const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : "");
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
`defineComponent`
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);
return {
//....
content: ctx.s.toString(),
};
}
如果看過我上一篇 為什麼defineProps宏函式不需要從vue中import匯入?文章的小夥伴應該會很熟悉這個compileScript
函式,compileScript
函式內處理defineProps
和defineEmits
大體流程其實很相似的。
ScriptCompileContext類
我們將斷點走到compileScript
函式中的第一部分程式碼。
function compileScript(sfc, options) {
const ctx = new ScriptCompileContext(sfc, options);
const startOffset = ctx.startOffset;
const endOffset = ctx.endOffset;
const scriptSetupAst = ctx.scriptSetupAst;
// ...省略
return {
//....
content: ctx.s.toString(),
};
}
這部分程式碼主要使用ScriptCompileContext
類new了一個ctx
上下文物件,並且讀取了上下文物件中的startOffset
、endOffset
、scriptSetupAst
、s
四個屬性。我們將斷點走進ScriptCompileContext
類,看看他的constructor
建構函式。下面這個是我簡化後的ScriptCompileContext
類的程式碼:
import MagicString from 'magic-string'
class ScriptCompileContext {
source = this.descriptor.source
s = new MagicString(this.source)
startOffset = this.descriptor.scriptSetup?.loc.start.offset
endOffset = this.descriptor.scriptSetup?.loc.end.offset
constructor(descriptor, options) {
this.descriptor = descriptor;
this.s = new MagicString(this.source);
this.scriptSetupAst = descriptor.scriptSetup && parse(descriptor.scriptSetup.content, this.startOffset);
}
}
在compileScript
函式中new ScriptCompileContext
時傳入的第一個引數是sfc
變數,然後在ScriptCompileContext
類的建構函式中是使用descriptor
變數來接收,接著賦值給descriptor
屬性。
在之前的vue檔案是如何編譯為js檔案 文章中我們已經講過了傳入給compileScript
函式的sfc
變數是一個descriptor
物件,descriptor
物件是由vue檔案編譯來的。descriptor
物件擁有template屬性、scriptSetup屬性、style屬性、source屬性,分別對應vue檔案的<template>
模組、<script setup>
模組、<style>
模組、原始碼code字串。在我們這個場景只關注scriptSetup
和source
屬性就行了,其中sfc.scriptSetup.content
的值就是<script setup>
模組中code程式碼字串。詳情檢視下圖:
現在我想你已經搞清楚了ctx
上下文物件4個屬性中的startOffset
屬性和endOffset
屬性了,startOffset
和endOffset
分別對應的就是descriptor.scriptSetup?.loc.start.offset
和descriptor.scriptSetup?.loc.end.offset
。startOffset
為<script setup>
模組中的內容開始的位置。endOffset
為<script setup>
模組中的內容結束的位置。
我們接著來看建構函式中的this.s = new MagicString(this.source)
這段話,this.source
是vue檔案中的原始碼code字串,以這個字串new了一個MagicString
物件賦值給s
屬性。magic-string
是一個用於高效操作字串的 JavaScript 庫。它提供豐富的 API,可以輕鬆地對字串進行插入、刪除、替換等操作。我們這裡主要用到toString
、remove
、overwrite
、prependLeft
、appendRight
五個方法。toString
方法用於生成經過處理後返回的字串,其餘幾個方法我舉幾個例子你應該就明白了。
s.remove( start, end )
用於刪除從開始到結束的字串:
const s = new MagicString('hello word');
s.remove(0, 6);
s.toString(); // 'word'
s.overwrite( start, end, content )
,使用content
的內容替換開始位置到結束位置的內容。
const s = new MagicString('hello word');
s.overwrite(0, 5, "你好");
s.toString(); // '你好 word'
s.prependLeft( index, content )
用於在指定index
的前面插入字串:
const s = new MagicString('hello word');
s.prependLeft(5, 'xx');
s.toString(); // 'helloxx word'
s.appendRight( index, content )
用於在指定index
的後面插入字串:
const s = new MagicString('hello word');
s.appendRight(5, 'xx');
s.toString(); // 'helloxx word'
現在你應該已經明白了ctx
上下文物件中的s
屬性了,我們接著來看最後一個屬性scriptSetupAst
。在建構函式中是由parse
函式的返回值賦值的: this.scriptSetupAst = descriptor.scriptSetup && parse(descriptor.scriptSetup.content, this.startOffset)
。parse
函式的程式碼如下:
import { parse as babelParse } from '@babel/parser'
function parse(input: string, offset: number): Program {
try {
return babelParse(input, {
plugins,
sourceType: 'module',
}).program
} catch (e: any) {
}
}
我們在前面已經講過了descriptor.scriptSetup.content
的值就是vue
檔案中的<script setup>
模組的程式碼code
字串,parse
函式中呼叫了babel
提供的parser
函式,將vue
檔案中的<script setup>
模組的程式碼code
字串轉換成AST抽象語法樹
。
在ScriptCompileContext
建構函式中主要做了下面這些事情:
processDefineEmits函式
我們接著將斷點走到compileScript
函式中的第二部分,for
迴圈遍歷AST抽象語法樹的地方,程式碼如下:
function compileScript(sfc, options) {
// ...省略
for (const node of scriptSetupAst.body) {
if (node.type === "ExpressionStatement") {
// ...
}
if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
const isDefineEmits = processDefineEmits(ctx, init, decl.id);
if (isDefineEmits) {
ctx.s.overwrite(
startOffset + init.start,
startOffset + init.end,
"__emit"
);
}
}
}
}
if (
(node.type === "VariableDeclaration" && !node.declare) ||
node.type.endsWith("Statement")
) {
// ....
}
}
// ...省略
}
看過我上一篇 為什麼defineProps宏函式不需要從vue中import匯入?可能會疑惑了,為什麼這裡不列出滿足node.type === "ExpressionStatement"
條件的程式碼呢。原因是在上一篇文章中我們沒有將defineProps
函式的返回值賦值給一個變數,他是一條表示式語句,所以滿足node.type === "ExpressionStatement"
的條件。在這篇文章中我們將defineEmits
函式的返回值賦值給一個emits
變數,他是一條變數宣告語句,所以他滿足node.type === "VariableDeclaration"
的條件。
// 表示式語句
defineProps({
content: String,
});
// 變數宣告語句
const emits = defineEmits(["enlarge-text"]);
將斷點走進for迴圈裡面,我們知道在script模組中第一行程式碼是變數宣告語句const emits = defineEmits(["enlarge-text"]);
。在console中看看由這條變數宣告語句編譯成的node節點長什麼樣子,如下圖:
從上圖中我們可以看到當前的node
節點型別為變數宣告語句,並且node.declare
的值為undefined
。我們再來看看node.declarations
欄位,他表示該節點的所有宣告子節點。這句話是什麼意思呢?說人話就是表示const右邊的語句。那為什麼declarations
是一個陣列呢?那是因為const右邊可以有多條語句,比如const a = 2, b = 4;
。在我們這個場景node.declarations
欄位就是表示emits = defineEmits(["enlarge-text"]);
。接著來看declarations
陣列下的init
欄位,從名字我想你應該已經猜到了他的作用是表示變數的初始化值,在我們這個場景init
欄位就是表示defineEmits(["enlarge-text"])
。而init.start
表示defineEmits(["enlarge-text"]);
中的開始位置,也就是字串'd'的位置,init.end
表示defineEmits(["enlarge-text"]);
中的結束位置,也就是字串';'的位置。
現在我們將斷點走到if語句內,下面的這些程式碼我想你應該能夠很輕鬆的理解了:
if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
const isDefineEmits = processDefineEmits(ctx, init, decl.id);
// 省略...
}
}
}
我們在控制檯中已經看到了node.declare
的值是undefined
,並且這也是一條變數宣告語句,所以斷點會走到if裡面。由於我們這裡只宣告瞭一個變數,所以node.declarations
陣列中只有一個值,這個值就是對應的emits = defineEmits(["enlarge-text"]);
。接著遍歷node.declarations
陣列,將陣列中的item賦值給decl
變數,然後使用decl.init
讀取到變數宣告語句中的初始化值,在我們這裡初始化值就是defineEmits(["enlarge-text"]);
。如果有初始化值,那就將他傳入給processDefineEmits
函式判斷是否在呼叫defineEmits
函式。我們來看看processDefineEmits
函式是什麼樣的:
const DEFINE_EMITS = "defineEmits";
function processDefineEmits(ctx, node, declId) {
if (!isCallOf(node, DEFINE_EMITS)) {
return false;
}
ctx.emitsRuntimeDecl = node.arguments[0];
return true;
}
在 processDefineEmits
函式中,我們首先使用 isCallOf
函式判斷當前的 AST 語法樹節點 node 是否在呼叫 defineEmits
函式。isCallOf
函式的第一個引數是 node 節點,第二個引數在這裡是寫死的字串 "defineEmits"。isCallOf的程式碼如下:
export function isCallOf(node, test) {
return !!(
node &&
test &&
node.type === "CallExpression" &&
node.callee.type === "Identifier" &&
(typeof test === "string"
? node.callee.name === test
: test(node.callee.name))
);
}
我們在debug console中將node.type
、node.callee.type
、node.callee.name
的值列印出來看看。
從圖上看到node.type
、node.callee.type
、node.callee.name
的值後,我們知道了當前節點確實是在呼叫 defineEmits
函式。所以isCallOf(node, DEFINE_EMITS)
的執行結果為 true,在 processDefineEmits
函式中我們是對 isCallOf
函式的執行結果取反,所以 !isCallOf(node, DEFINE_EMITS)
的執行結果為 false。
我們接著來看processDefineEmits
函式:
const DEFINE_EMITS = "defineEmits";
function processDefineEmits(ctx, node, declId) {
if (!isCallOf(node, DEFINE_EMITS)) {
return false;
}
ctx.emitsRuntimeDecl = node.arguments[0];
return true;
}
如果是在執行defineEmits
函式,就會執行接下來的程式碼ctx.emitsRuntimeDecl = node.arguments[0];
。將傳入的node
節點第一個引數賦值給ctx
上下文物件的emitsRuntimeDecl
屬性,這裡的第一個引數其實就是呼叫defineEmits
函式時給傳入的第一個引數。為什麼寫死成取arguments[0]
呢?是因為defineEmits
函式只接收一個引數,傳入的引數可以是一個物件或者陣列。比如:
const props = defineEmits({
'enlarge-text': null
})
const emits = defineEmits(['enlarge-text'])
記住這個在ctx
上下文上面塞的emitsRuntimeDecl
屬性,後面會用到。
至此我們已經瞭解到了processDefineEmits
中主要做了兩件事:判斷當前執行的表示式語句是否是defineEmits
函式,如果是那麼就將呼叫defineEmits
函式時傳入的引數轉換成的node節點塞到ctx
上下文的emitsRuntimeDecl
屬性中。
我們接著來看compileScript
函式中的程式碼:
if (node.type === "VariableDeclaration" && !node.declare) {
const total = node.declarations.length;
for (let i = 0; i < total; i++) {
const decl = node.declarations[i];
const init = decl.init;
if (init) {
const isDefineEmits = processDefineEmits(ctx, init, decl.id);
if (isDefineEmits) {
ctx.s.overwrite(
startOffset + init.start,
startOffset + init.end,
"__emit"
);
}
}
}
}
將processDefineEmits
函式的執行結果賦值賦值給isDefineEmits
變數,在我們這個場景當然是在呼叫defineEmits
函式,所以會執行if語句內的ctx.s.overwrite
方法。ctx.s.overwrite
方法我們前面已經講過了,作用是使用指定的內容替換開始位置到結束位置的內容。在執行ctx.s.overwrite
前我們先在debug console中執行ctx.s.toString()
看看當前的code程式碼字串是什麼樣的。
從上圖我們可以看到此時的code程式碼字串還是和我們的原始碼是一樣的,我們接著來看ctx.s.overwrite
方法接收的引數。第一個引數為startOffset + init.start
,startOffset
我們前面已經講過了他的值為script
模組的內容開始的位置。init
我們前面也講過了,他表示emits
變數的初始化值對應的node節點,在我們這個場景init
欄位就是表示defineEmits(["enlarge-text"])
。所以init.start
為emits
變數的初始化值在script
模組中開始的位置。而ctx.s.
為操縱整個vue檔案的code程式碼字串,所以startOffset + init.start
的值為emits
變數的初始化值的起點在整個vue檔案的code程式碼字串所在位置。同理第二個引數startOffset + init.end
的值為emits
變數的初始化值的終點在整個vue檔案的code程式碼字串所在位置,而第三個引數是一個寫死的字串"__emit"。所以ctx.s.overwrite
方法的作用是將const emits = defineEmits(["enlarge-text"]);
替換為const emits = __emit;
。
關於startOffset
、init.start
、 init.end
請看下圖:
在執行ctx.s.overwrite
方法後我們在debug console中再次執行ctx.s.toString()
看看這會兒的code程式碼字串是什麼樣的。
從上圖中我們可以看到此時程式碼中已經沒有了defineEmits
函式,已經變成了一個__emit
變數。
genRuntimeEmits函式
我們接著將斷點走到compileScript
函式中的第三部分,生成執行時的“宣告事件”。我們在上一步將defineEmits
宣告事件的程式碼替換為__emit
,那麼總得有一個地方去生成“宣告事件”。沒錯,就是在genRuntimeEmits
函式這裡生成的。compileScript
函式中執行genRuntimeEmits
函式的程式碼如下:
ctx.s.remove(0, startOffset);
ctx.s.remove(endOffset, source.length);
let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`;
從上面的程式碼中我們看到首先執行了兩次remove
方法,在前面已經講過了startOffset
為script
模組中的內容開始的位置。所以ctx.s.remove(0, startOffset);
的意思是刪除掉template
模組的內容和<script setup>
開始標籤。這行程式碼執行完後我們再看看ctx.s.toString()
的值:
從上圖我們可以看到此時template
模組和<script setup>
開始標籤已經沒有了,接著執行ctx.s.remove(endOffset, source.length);
,這行程式碼的意思是刪除</script >
結束標籤和<style>
模組。這行程式碼執行完後我們再來看看ctx.s.toString()
的值:
從上圖我們可以看到,此時只有script模組中的內容了。
我們接著將compileScript
函式中的斷點走到呼叫genRuntimeEmits
函式處,簡化後程式碼如下:
function genRuntimeEmits(ctx) {
let emitsDecl = "";
if (ctx.emitsRuntimeDecl) {
emitsDecl = ctx.getString(ctx.emitsRuntimeDecl).trim();
}
return emitsDecl;
}
看到上面的程式碼是不是覺得和上一篇defineProps
文章中講的genRuntimeProps
函式很相似。這裡的上下文ctx
上面的emitsRuntimeDecl
屬性我們前面講過了,他就是呼叫defineEmits
函式時傳入的引數轉換成的node節點。我們將斷點走進ctx.getString
函式,程式碼如下:
getString(node, scriptSetup = true) {
const block = scriptSetup ? this.descriptor.scriptSetup : this.descriptor.script;
return block.content.slice(node.start, node.end);
}
我們前面已經講過了descriptor
物件是由vue
檔案編譯而來,其中的scriptSetup
屬性就是對應的<script setup>
模組。我們這裡沒有傳入scriptSetup
,所以block
的值為this.descriptor.scriptSetup
。同樣我們前面也講過scriptSetup.content
的值是<script setup>
模組code
程式碼字串。請看下圖:
這裡傳入的node
節點就是我們前面存在上下文中ctx.emitsRuntimeDecl
,也就是在呼叫defineEmits
函式時傳入的引數節點,node.start
就是引數節點開始的位置,node.end
就是引數節點的結束位置。所以使用content.slice
方法就可以擷取出來呼叫defineEmits
函式時傳入的引數。請看下圖:
現在我們再回過頭來看compileScript
函式中的呼叫genRuntimeEmits
函式的程式碼你就能很容易理解了:
let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`;
這裡的emitsDecl
在我們這個場景中就是使用slice
擷取出來的emits
定義,再使用字串拼接 emits:
,就得到了runtimeOptions
的值。如圖:
看到runtimeOptions
的值是不是就覺得很熟悉了,又有name
屬性,又有emits
屬性,和我們前面舉的兩個例子中的vue2的選項式語法的例子比較相似。
拼接成完整的瀏覽器執行時 js
程式碼
我們接著將斷點走到compileScript
函式中的最後一部分:
const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : "");
ctx.s.prependLeft(
startOffset,
`\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
`defineComponent`
)}({${def}${runtimeOptions}\n ${
hasAwait ? `async ` : ``
}setup(${args}) {\n${exposeCall}`
);
ctx.s.appendRight(endOffset, `})`);
return {
//....
content: ctx.s.toString(),
};
這塊程式碼和我們講defineProps
文章中是一樣的,先呼叫了ctx.s.prependLeft
方法給字串開始的地方插入了一串字串,這串拼接的字串看著很麻煩的樣子,我們直接在debug console上面看看要拼接的字串是什麼樣的:
看到這串你應該很熟悉,除了前面我們拼接的name
和emits
之外還有部分setup
編譯後的程式碼,但是這裡的setup
程式碼還不完整,剩餘部分還在ctx.s.toString()
裡面。
將斷點執行完ctx.s.prependLeft
後,我們在debug console上面透過ctx.s.toString()
看此時操作的字串變成什麼樣了:
從上圖可以看到此時的setup函式已經拼接完整了,已經是一個編譯後的vue
元件物件的程式碼字串了,只差一個})
結束符號,所以執行ctx.s.appendRight
方法將結束符號插入進去。
我們最後再來看看經過compileScript
函式處理後的瀏覽器可執行的js
程式碼字串,也就是ctx.s.toString()
從上圖中我們可以看到編譯後的程式碼中宣告事件
還是透過vue元件物件上面的emits
選項宣告的,和我們前面舉的vue2的選項式語法的例子一模一樣。
為什麼defineEmits
的返回值等同於$emit
方法用於在元件中丟擲事件?
在上一節中我們知道了defineEmits
函式在編譯時就被替換為了__emit
變數,然後將__emit
賦值給我們定義的emits
變數。在需要丟擲事件時我們是呼叫的emits("enlarge-text");
,實際就是在呼叫__emit("enlarge-text");
。那我們現在透過debug看看這個__emit
到底是什麼東西?
首先我們需要在瀏覽器的source皮膚中找到由vue檔案編譯而來的js檔案,然後給setup函式打上斷點。在我們前面的 Vue 3 的 setup語法糖到底是什麼東西?文章中已經手把手的教你了怎麼在瀏覽器中找到編譯後的js檔案,所以在這篇文章中就不再贅述了。
給setup
函式打上斷點,重新整理瀏覽器頁面後,我們看到斷點已經走進來了。如圖:
從上圖中我們可以看見defineEmits
的返回值也就是__emit
變數,實際就是setup
函式的第二個引數物件中的emit
屬性。右邊的Call Stack有的小夥伴可能不常用,他的作用是追蹤函式的執行流。比如在這裡setup
函式是由callWithErrorHandling
函式內呼叫的,在Call Stack中setup
下面就是callWithErrorHandling
。而callWithErrorHandling
函式是由setupStatefulComponent
函式內呼叫的,所以在Call Stack中callWithErrorHandling
下面就是setupStatefulComponent
。並且還可以透過點選函式名稱跳轉到對應的函式中。
為了搞清楚setup
函式的第二個引數到底是什麼,所以我們點選右邊的Call Stack中的callWithErrorHandling
函式,看看在callWithErrorHandling
函式中是怎麼呼叫setup
函式的。程式碼如下:
function callWithErrorHandling(fn, instance, type, args) {
try {
return args ? fn(...args) : fn();
} catch (err) {
handleError(err, instance, type);
}
}
從上面的程式碼中可以看到這個callWithErrorHandling
函式實際就是用於錯誤處理的,如果有引數args
,那就呼叫fn
時將引數以...args
的形式傳入給fn
。在我們這裡fn
就是setup
函式,我們現在要看傳遞給setup
的第二個引數,就對應的這裡的是args
陣列中的第二項。現在我們知道了呼叫callWithErrorHandling
函式時傳入的第四個引數是一個陣列,陣列的第二項就是呼叫setup
函式時傳入的第二個引數物件。
我們接著來看在setupStatefulComponent
函式中是如何呼叫callWithErrorHandling
函式的,簡化後程式碼如下:
function setupStatefulComponent(instance, isSSR) {
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null);
const setupResult = callWithErrorHandling(setup, instance, 0, [
true ? shallowReadonly(instance.props) : instance.props,
setupContext,
]);
}
從上面的程式碼中可以看到呼叫callWithErrorHandling
函式時傳入的第四個引數確實是一個陣列,陣列的第二項是setupContext
,這個setupContext
就是呼叫setup
函式時傳入的第二個引數物件。而setupContext
的值是由createSetupContext
函式返回的,在呼叫createSetupContext
函式時傳入了當前的vue例項。我們接著來看簡化後的createSetupContext
函式是什麼樣的:
function createSetupContext(instance) {
return Object.freeze({
get attrs() {
return getAttrsProxy(instance);
},
get slots() {
return getSlotsProxy(instance);
},
get emit() {
return (event, ...args) => instance.emit(event, ...args);
},
expose,
});
}
這裡出現了一個我們平時不常用的Object.freeze
方法,在mdn上面查了一下他的作用:
Object.freeze()
靜態方法可以使一個物件被凍結。凍結物件可以防止擴充套件,並使現有的屬性不可寫入和不可配置。被凍結的物件不能再被更改:不能新增新的屬性,不能移除現有的屬性,不能更改它們的可列舉性、可配置性、可寫性或值,物件的原型也不能被重新指定。freeze()
返回與傳入的物件相同的物件。
從前面我們已經知道了createSetupContext
函式的返回值就是呼叫setup
函式時傳入的第二個引數物件,我們要找的__emit
就是第二個引數物件中的emit
屬性。當讀取emit
屬性時就會走到上面的凍結物件的get emit()
中,當我們呼叫emit
函式丟擲事件時實際就是呼叫的是instance.emit
方法,也就是vue
例項上面的emit
方法。
現在我想你應該已經反應過來了,呼叫defineEmits
函式的返回值實際就是在呼叫vue例項上面的emit方法,其實在執行時丟擲事件的做法還是和vue2的選項式語法一樣的,只是在編譯時就將看著高大上的defineEmits
函式編譯成vue2的選項式語法的樣子。
總結
現在我們能夠回答前面提的兩個問題了:
-
為什麼 Vue 的
defineEmits
宏函式不需要 import 匯入就可用?
在遍歷script模組轉換成的AST抽象語法樹時,如果當前的node節點是在呼叫defineEmits
函式,就繼續去找這個node節點下面的引數節點,也就是呼叫defineEmits
函式傳入的引數對應的node節點。然後將引數節點物件賦值給當前的ctx
上下文的emitsRuntimeDecl
屬性中,接著根據defineEmits
函式對應的node節點中記錄的start和end位置對vue檔案的code程式碼字串進行替換。將defineEmits(["enlarge-text"])
替換為__emit
,此時在程式碼中已經就沒有了defineEmits
宏函式了,自然也不需要從vue中import匯入。當遍歷完AST抽象語法樹後呼叫genRuntimeEmits
函式,從前面存的ctx
上下文中的emitsRuntimeDecl
屬性中取出來呼叫defineEmits
函式時傳入的引數節點資訊。根據引數節點中記錄的start和end位置,對script模組中的code程式碼字串執行slice方法,擷取出呼叫defineEmits
函式時傳入的引數。然後透過字串拼接的方式將呼叫defineEmits
函式時傳入的引數拼接到vue元件物件的emits屬性上。 -
為什麼
defineEmits
的返回值等同於$emit
方法用於在元件中丟擲事件?
defineEmits
宏函式在上個問題中我們已經講過了會被替換為__emit
,而這個__emit
是呼叫setup
函式時傳入的第二個引數物件上的emit
屬性。而第二個引數物件是在setupStatefulComponent
函式中呼叫createSetupContext
函式生成的setupContext
物件。在createSetupContext
函式中我們看到返回的emit
屬性其實就是一個箭頭函式,當呼叫defineEmits
函式返回的emit
函式時就會呼叫這個箭頭函式,在箭頭函式中其實是呼叫vue例項上的emit
方法。
搞明白了上面兩個問題我想你現在應該明白了為什麼說vue3的defineEmits 宏函式編譯後其實就是vue2的選項式API,defineEmits
宏函式宣告的事件經過編譯後就變成了vue元件物件上的emits
屬性。defineEmits
函式的返回值emit
函式,其實就是在呼叫vue例項上的emit
方法,這不就是我們在vue2的選項式API中宣告事件和觸發事件的樣子嗎。大部分看著高大上的黑魔法其實都是編譯時做的事情,vue3中的像defineEmits
這樣的宏函式經過編譯後其實還是我們熟悉的vue2的選項式API。
關注公眾號:前端歐陽
,解鎖我更多vue
乾貨文章。
還可以加我微信,私信我想看哪些vue
原理文章,我會根據大家的反饋進行創作。