天天用defineEmits宏函数,竟然不知道编译后是vue2的选项式API?

前言

我们每天都在使用 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终端。
debug-terminal

然后在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就是文件路径的意思,后面我们会讲。
debug-terminal

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函数内处理definePropsdefineEmits大体流程其实很相似的。

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上下文对象,并且读取了上下文对象中的startOffsetendOffsetscriptSetupAsts四个属性。我们将断点走进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字符串。在我们这个场景只关注scriptSetupsource属性就行了,其中sfc.scriptSetup.content的值就是<script setup>模块中code代码字符串。详情查看下图:
composition-child

现在我想你已经搞清楚了ctx上下文对象4个属性中的startOffset属性和endOffset属性了,startOffsetendOffset分别对应的就是descriptor.scriptSetup?.loc.start.offsetdescriptor.scriptSetup?.loc.end.offsetstartOffset<script setup>模块中的内容开始的位置。endOffset<script setup>模块中的内容结束的位置。

我们接着来看构造函数中的this.s = new MagicString(this.source)这段话,this.source是vue文件中的源代码code字符串,以这个字符串new了一个MagicString对象赋值给s属性。magic-string是一个用于高效操作字符串的 JavaScript 库。它提供丰富的 API,可以轻松地对字符串进行插入、删除、替换等操作。我们这里主要用到toStringremoveoverwriteprependLeftappendRight五个方法。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构造函数中主要做了下面这些事情:
progress1

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节点长什么样子,如下图:
first-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.typenode.callee.typenode.callee.name的值打印出来看看。
isCallOf

从图上看到node.typenode.callee.typenode.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代码字符串是什么样的。
before-overwrite

从上图我们可以看到此时的code代码字符串还是和我们的源代码是一样的,我们接着来看ctx.s.overwrite方法接收的参数。第一个参数为startOffset + init.startstartOffset我们前面已经讲过了他的值为script模块的内容开始的位置。init我们前面也讲过了,他表示emits变量的初始化值对应的node节点,在我们这个场景init字段就是表示defineEmits(["enlarge-text"])。所以init.startemits变量的初始化值在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;

关于startOffsetinit.startinit.end请看下图:
params-overwrite

在执行ctx.s.overwrite方法后我们在debug console中再次执行ctx.s.toString()看看这会儿的code代码字符串是什么样的。
after-overwrite

从上图中我们可以看到此时代码中已经没有了defineEmits函数,已经变成了一个__emit变量。
convert-defineEmits

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方法,在前面已经讲过了startOffsetscript模块中的内容开始的位置。所以ctx.s.remove(0, startOffset);的意思是删除掉template模块的内容和<script setup>开始标签。这行代码执行完后我们再看看ctx.s.toString()的值:
remove1

从上图我们可以看到此时template模块和<script setup>开始标签已经没有了,接着执行ctx.s.remove(endOffset, source.length);,这行代码的意思是删除</script >结束标签和<style>模块。这行代码执行完后我们再来看看ctx.s.toString()的值:
remove2

从上图我们可以看到,此时只有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代码字符串。请看下图:
script-code

这里传入的node节点就是我们前面存在上下文中ctx.emitsRuntimeDecl,也就是在调用defineEmits函数时传入的参数节点,node.start就是参数节点开始的位置,node.end就是参数节点的结束位置。所以使用content.slice方法就可以截取出来调用defineEmits函数时传入的参数。请看下图:
block-slice

现在我们再回过头来看compileScript函数中的调用genRuntimeEmits函数的代码你就能很容易理解了:

let runtimeOptions = ``;
const emitsDecl = genRuntimeEmits(ctx);
if (emitsDecl) runtimeOptions += `\n  emits: ${emitsDecl},`;

这里的emitsDecl在我们这个场景中就是使用slice截取出来的emits定义,再使用字符串拼接 emits:,就得到了runtimeOptions的值。如图:
runtimeOptions

看到runtimeOptions的值是不是就觉得很熟悉了,又有name属性,又有emits属性,和我们前面举的两个例子中的vue2的选项式语法的例子比较相似。
genRuntimeEmits

拼接成完整的浏览器运行时 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上面看看要拼接的字符串是什么样的:
prependLeft

看到这串你应该很熟悉,除了前面我们拼接的nameemits之外还有部分setup编译后的代码,但是这里的setup代码还不完整,剩余部分还在ctx.s.toString()里面。

将断点执行完ctx.s.prependLeft后,我们在debug console上面通过ctx.s.toString()看此时操作的字符串变成什么样了:
after-prependLeft

从上图可以看到此时的setup函数已经拼接完整了,已经是一个编译后的vue组件对象的代码字符串了,只差一个})结束符号,所以执行ctx.s.appendRight方法将结束符号插入进去。

我们最后再来看看经过compileScript函数处理后的浏览器可执行的js代码字符串,也就是ctx.s.toString()
full-code

从上图中我们可以看到编译后的代码中声明事件还是通过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函数打上断点,刷新浏览器页面后,我们看到断点已经走进来了。如图:
setup-debug

从上图中我们可以看见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的选项式语法的样子。
full-emit-progress

总结

现在我们能够回答前面提的两个问题了:

  • 为什么 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的选项式APIdefineEmits宏函数声明的事件经过编译后就变成了vue组件对象上的emits属性。defineEmits函数的返回值emit函数,其实就是在调用vue实例上的emit方法,这不就是我们在vue2的选项式API中声明事件和触发事件的样子吗。大部分看着高大上的黑魔法其实都是编译时做的事情,vue3中的像defineEmits这样的宏函数经过编译后其实还是我们熟悉的vue2的选项式API。

关注(图1)公众号:【前端欧阳】,解锁我更多vue原理文章。
加我(图2)微信回复「资料」,免费领取欧阳研究vue源码过程中收集的源码资料,欧阳写文章有时也会参考这些资料。同时让你的朋友圈多一位对vue有深入理解的人。
公众号微信

posted @ 2024-03-19 11:12  前端欧阳  阅读(454)  评论(0编辑  收藏  举报