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

来源:https://www.cnblogs.com/heavenYJJ/p/18081439
-Advertisement-
Play Games

你知道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終端。
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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:霜序 本文首發於:https://juejin.cn/post/7299384698882539574 在大數據業務中,時常會出現且或關係邏輯的拼接,有需要做 ...
  • 專註探討UUID的核心原理及其生成機制,並詳細介紹不同版本UUID(如版本1的時間戳+節點ID、版本4的隨機數生成等)背後的數學原理和技術細節。 ...
  • 寫入剪切板 使用 clipboard.js 第三方插件: clipboard.js 安裝clipboard.js yarn yarn add clipboard npm npm install clipboard --save 使用示例(vue) <template> <div> <span v-c ...
  • 目錄一.HTML基本框架二.標題標簽三.段落標簽四.換行與水平線標簽五.文本格式化標簽(加粗、傾斜、下劃線、刪除線)六.圖像標簽擴展:相對路徑,絕對路徑與線上網址七.超鏈接標簽八.音頻標簽九.視頻標簽十.列表標簽十一.表格標簽擴展:表格結構標簽合併單元格十二.表單標簽1.input標簽input占位 ...
  • 一、UDP UDP(User Datagram Protocol),用戶數據包協議,是一個簡單的面向數據報的通信協議,即對應用層交下來的報文,不合併,不拆分,只是在其上面加上首部後就交給了下麵的網路層 也就是說無論應用層交給UDP多長的報文,它統統發送,一次發送一個報文 而對接收方,接到後直接去除首 ...
  • 問題:用html2canvas生成畫布圖片,再轉成pdf。生成圖片時內容結構里的圖片顯示空白。 解決: 首先伺服器設置圖片允許跨域,如阿裡雲騰訊雲配置跨域規則。其次圖片設置crossOrigin=“anonymous”,並且拿到圖片地址加隨機參數如 src +‘?v=’ + Math.random( ...
  • 1、背景: ​ 作者在寫項目的時候,遇到了一個很坑的問題,項目前端基於QUI,但是大部分是js + css實現。 ​ 有一個功能:列表頁面使用Dialog()組件打開編輯、新增窗體,編輯、新增窗體點擊提交關閉窗體,能夠刷新列表頁面,無論怎麼百度就是找不到可以實現的方法,最終功夫不負有心人,終於找到了 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 一、安全特性 在上篇文章中,我們瞭解到HTTP在通信過程中,存在以下問題: 通信使用明文(不加密),內容可能被竊聽 不驗證通信方的身份,因此有可能遭遇偽裝 而HTTPS的出現正是解決這些問題,HTTPS是建立在SSL之上,其安全性由SSL ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...