天天用defineEmits宏函式,竟然不知道編譯後是vue2的選項式API?

前端欧阳發表於2024-03-19

前言

我們每天都在使用 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

相關文章