你知道defineEmits 巨集函數經過編譯後其實就是vue2的選項式API嗎?你知道為什麼 Vue 的 defineEmits 巨集函數不需要 import 導入就可用嗎?為什麼defineEmits的返回值等同於$emit 方法用於在組件中拋出事件? ...
前言
我們每天都在使用 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
原理文章,我會根據大家的反饋進行創作。