本文是 vue3编译原理揭秘 的第 5 篇,和该系列的其他文章一起服用效果更佳。
- vue3的宏到底是什么东西?
- Vue 3 的 setup语法糖到底是什么东西?
- 看不懂来打我,vue3的.vue文件(SFC)编译过程
- 为什么defineProps宏函数不需要从vue中import导入?
前言
我们每天都在使用 defineEmits
宏函数,但是你知道defineEmits
宏函数经过编译后其实就是vue2的选项式API吗?通过回答下面两个问题,我将逐步为你揭秘defineEmits
宏函数的神秘面纱。为什么 Vue 的 defineEmits
宏函数不需要 import 导入就可用?为什么defineEmits
的返回值等同于$emit
方法用于在组件中抛出事件?
举两个例子
要回答上面提的几个问题我们先来看两个例子是如何声明事件和抛出事件,分别是vue2的选项式天天用defineEmits宏函数竟然不知道编译后是vue2的选项式API?和vue3的组合式API。
我们先来看vue2的选项式语法的例子,options-child.vue
文件代码如下:
xml
<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
宏函数编译原理时会用。
关注公众号:前端欧阳,解锁我更多vue
干货文章。还可以加我wx,私信我想看哪些vue原理文章,我会根据大家的反馈进行创作。
我们再来看看vue3的组合式语法的例子,composition-child.vue
代码如下:
xml
<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
模块代码如下:
xml
<script setup lang="ts">
const emits = defineEmits(["enlarge-text"]);
function handleClick() {
emits("enlarge-text");
}
</script>
compileScript
函数内包含了编译script
模块的所有的逻辑,代码很复杂,光是源代码就接近1000行。这篇文章我们同样不会去通读compileScript
函数的所有功能,只讲涉及到defineEmits
流程的代码。这个是根据我们这个场景将compileScript
函数简化后的代码:
ini
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
函数中的第一部分代码。
ini
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
类的代码:
kotlin
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 )
用于删除从开始到结束的字符串:
ini
const s = new MagicString('hello word');
s.remove(0, 6);
s.toString(); // 'word'
s.overwrite( start, end, content )
,使用content
的内容替换开始位置到结束位置的内容。
ini
const s = new MagicString('hello word');
s.overwrite(0, 5, "你好");
s.toString(); // '你好 word'
s.prependLeft( index, content )
用于在指定index
的前面插入字符串:
ini
const s = new MagicString('hello word');
s.prependLeft(5, 'xx');
s.toString(); // 'helloxx word'
s.appendRight( index, content )
用于在指定index
的后面插入字符串:
ini
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
函数的代码如下:
typescript
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抽象语法树的地方,代码如下:
ini
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"
的条件。
scss
// 表达式语句
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语句内,下面的这些代码我想你应该能够很轻松的理解了:
ini
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
函数是什么样的:
ini
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的代码如下:
ini
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
函数:
ini
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
函数只接收一个参数,传入的参数可以是一个对象或者数组。比如:
csharp
const props = defineEmits({
'enlarge-text': null
})
const emits = defineEmits(['enlarge-text'])
记住这个在ctx
上下文上面塞的emitsRuntimeDecl
属性,后面会用到。
至此我们已经了解到了processDefineEmits
中主要做了两件事:判断当前执行的表达式语句是否是defineEmits
函数,如果是那么就将调用defineEmits
函数时传入的参数转换成的node节点塞到ctx
上下文的emitsRuntimeDecl
属性中。
我们接着来看compileScript
函数中的代码:
ini
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
函数的代码如下:
ini
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
函数处,简化后代码如下:
ini
function genRuntimeEmits(ctx) {
let emitsDecl = "";
if (ctx.emitsRuntimeDecl) {
emitsDecl = ctx.getString(ctx.emitsRuntimeDecl).trim();
}
return emitsDecl;
}
看到上面的代码是不是觉得和上一篇defineProps
文章中讲的genRuntimeProps
函数很相似。这里的上下文ctx
上面的emitsRuntimeDecl
属性我们前面讲过了,他就是调用defineEmits
函数时传入的参数转换成的node节点。我们将断点走进ctx.getString
函数,代码如下:
kotlin
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
函数的代码你就能很容易理解了:
ini
let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`;
这里的emitsDecl
在我们这个场景中就是使用slice
截取出来的emits
定义,再使用字符串拼接 emits:
,就得到了runtimeOptions
的值。如图:
看到runtimeOptions
的值是不是就觉得很熟悉了,又有name
属性,又有emits
属性,和我们前面举的两个例子中的vue2的选项式语法的例子比较相似。
拼接成完整的浏览器运行时 js
代码
我们接着将断点走到compileScript
函数中的最后一部分:
javascript
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
函数的。代码如下:
php
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
函数的,简化后代码如下:
ini
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
函数是什么样的:
csharp
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。