天天用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。

關注公衆號:前端歐陽,解鎖我更多vue乾貨文章。
qrcode
還可以加我微信,私信我想看哪些vue原理文章,我會根據大家的反饋進行創作。
wxcode

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章