有點兒神奇,原來vue3的setup語法糖中元件無需註冊因為這個

前端欧阳發表於2024-06-20

前言

眾所周知,在vue2的時候使用一個vue元件要麼全域性註冊,要麼區域性註冊。但是在setup語法糖中直接將元件import匯入無需註冊就可以使用,你知道這是為什麼呢?注:本文中使用的vue版本為3.4.19

關注公眾號:【前端歐陽】,給自己一個進階vue的機會

看個demo

我們先來看個簡單的demo,程式碼如下:

<template>
  <Child />
</template>

<script lang="ts" setup>
import Child from "./child.vue";
</script>

上面這個demo在setup語法糖中import匯入了Child子元件,然後在template中就可以直接使用了。

我們先來看看上面的程式碼編譯後的樣子,在之前的文章中已經講過很多次如何在瀏覽器中檢視編譯後的vue檔案,這篇文章就不贅述了。編譯後的程式碼如下:

import {
  createBlock as _createBlock,
  defineComponent as _defineComponent,
  openBlock as _openBlock,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";
import Child from "/src/components/setupComponentsDemo/child.vue";

const _sfc_main = _defineComponent({
  __name: "index",
  setup(__props, { expose: __expose }) {
    __expose();
    const __returned__ = { Child };
    return __returned__;
  },
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createBlock($setup["Child"]);
}

_sfc_main.render = _sfc_render;
export default _sfc_main;

從上面的程式碼可以看到,編譯後setup語法糖已經沒有了,取而代之的是一個setup函式。在setup函式中會return一個物件,物件中就包含了Child子元件。

有一點需要注意的是,我們原本是在setup語法糖中import匯入的Child子元件,但是經過編譯後import匯入的程式碼已經被提升到setup函式外面去了。

在render函式中使用$setup["Child"]就可以拿到Child子元件,並且透過_createBlock($setup["Child"]);就可以將子元件渲染到頁面上去。從命名上我想你應該猜到了$setup物件和上面的setup函式的return物件有關,其實這裡的$setup["Child"]就是setup函式的return物件中的Child元件。至於在render函式中是怎麼拿到setup函式返回的物件可以看我的另外一篇文章: Vue 3 的 setup語法糖到底是什麼東西?

接下來我將透過debug的方式帶你瞭解編譯時是如何將Child塞到setup函式的return物件中,以及怎麼將import匯入Child子元件的語句提升到setup函式外面去的。

compileScript函式

在上一篇 有點東西,template可以直接使用setup語法糖中的變數原來是因為這個 文章中我們已經詳細講過了setup語法糖是如何編譯成setup函式,以及如何根據將頂層繫結生成setup函式的return物件。所以這篇文章的重點是setup語法糖如何處理裡面的import匯入語句。

還是一樣的套路啟動一個debug終端。這裡以vscode舉例,開啟終端然後點選終端中的+號旁邊的下拉箭頭,在下拉中點選Javascript Debug Terminal就可以啟動一個debug終端。
debug-terminal

然後在node_modules中找到vue/compiler-sfc包的compileScript函式打上斷點,compileScript函式位置在/node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js。接下來我們來看看簡化後的compileScript函式原始碼,程式碼如下:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const setupBindings = Object.create(null);
  const scriptSetupAst = ctx.scriptSetupAst;

  for (const node of scriptSetupAst.body) {
    if (node.type === "ImportDeclaration") {
      // 。。。省略
    }
  }

  for (const node of scriptSetupAst.body) {
    // 。。。省略
  }

  let returned;
  const allBindings = {
    ...setupBindings,
  };
  for (const key in ctx.userImports) {
    if (!ctx.userImports[key].isType && ctx.userImports[key].isUsedInTemplate) {
      allBindings[key] = true;
    }
  }
  returned = `{ `;
  for (const key in allBindings) {
    // ...遍歷allBindings物件生成setup函式的返回物件
  }

  return {
    // ...省略
    content: ctx.s.toString(),
  };
}

我們先來看看簡化後的compileScript函式。

compileScript函式中首先使用ScriptCompileContext類new了一個ctx上下文物件,在new的過程中將compileScript函式的入參sfc傳了過去,sfc中包含了<script setup>模組的位置資訊以及原始碼。

ctx.scriptSetupAst<script setup>模組中的code程式碼字串對應的AST抽象語法樹。

接著就是遍歷AST抽象語法樹的內容,如果發現當前節點是一個import語句,就會將該import收集起來放到ctx.userImports物件中(具體如何收集接下來會講)。

然後會再次遍歷AST抽象語法樹的內容,如果發現當前節點上頂層宣告的變數、函式、類、列舉宣告,就將其收集到setupBindings物件中。

最後就是使用擴充套件運算子...setupBindingssetupBindings物件中的屬性合併到allBindings物件中。

對於ctx.userImports的處理就不一樣了,不會將其全部合併到allBindings物件中。而是遍歷ctx.userImports物件,如果當前import匯入不是ts的型別匯入,並且匯入的東西在template模版中使用了,才會將其合併到allBindings物件中。

經過前面的處理allBindings物件中已經收集了setup語法糖中的所有頂層繫結,然後遍歷allBindings物件生成setup函式中的return物件。

我們在debug終端來看看生成的return物件,如下圖:
return

從上圖中可以看到setup函式中已經有了一個return物件了,return物件的Child屬性值就是Child子元件的引用。

收集import匯入

接下來我們來詳細看看如何將setup語法糖中的全部import匯入收集到ctx.userImports物件中,程式碼如下:

function compileScript(sfc, options) {
  // 。。。省略
  for (const node of scriptSetupAst.body) {
    if (node.type === "ImportDeclaration") {
      hoistNode(node);
      for (let i = 0; i < node.specifiers.length; i++) {
        // 。。。省略
      }
    }
  }
  // 。。。省略
}

遍歷scriptSetupAst.body也就是<script setup>模組中的code程式碼字串對應的AST抽象語法樹,如果當前節點型別是import匯入,就會執行hoistNode函式將當前import匯入提升到setup函式外面去。

hoistNode函式

將斷點走進hoistNode函式,程式碼如下:

function hoistNode(node) {
  const start = node.start + startOffset;
  let end = node.end + startOffset;
  while (end <= source.length) {
    if (!/\s/.test(source.charAt(end))) {
      break;
    }
    end++;
  }
  ctx.s.move(start, end, 0);
}

編譯階段生成新的code字串是基於整個vue原始碼去生成的,而不是僅僅基於<script setup>模組中的js程式碼去生成的。我們來看看此時的code程式碼字串是什麼樣的,如下圖:
before-move

從上圖中可以看到此時的code程式碼字串還是和初始的原始碼差不多,沒什麼變化。

首先要找到當前import語句在整個vue原始碼中開始位置和結束位置在哪裡。node.start為當前import語句在<script setup>模組中的開始位置,startOffset<script setup>模組中的內容在整個vue原始碼中的開始位置。所以node.start + startOffset就是當前import語句在整個vue原始碼中開始位置,將其賦值給start變數。

同理node.end + startOffset就是當前import語句在整個vue原始碼中結束位置,將其賦值給end變數。由於import語句後面可能會有空格,所以需要使用while迴圈將end指向import語句後面非空格前的位置,下一步move的時候將空格一起給move過去。

最後就是呼叫ctx.s.move方法,這個方法接收三個引數。第一個引數是要移動的字串開始位置,第二個引數是要移動的字串結束位置,第三個引數為將字串移動到的位置。

所以這裡的ctx.s.move(start, end, 0)就是將import語句移動到最前面的位置,執行完ctx.s.move方法後,我們在debug終端來看看此時的code程式碼字串,如下圖:
after-move

從上圖中可以看到import語句已經被提升到了最前面去了。

遍歷import匯入說明符

我們接著來看前面省略的遍歷node.specifiers的程式碼,如下:

function compileScript(sfc, options) {
  // 。。。省略

  for (const node of scriptSetupAst.body) {
    if (node.type === "ImportDeclaration") {
      hoistNode(node);
      for (let i = 0; i < node.specifiers.length; i++) {
        const specifier = node.specifiers[i];
        const local = specifier.local.name;
        const imported = getImportedName(specifier);
        const source2 = node.source.value;
        registerUserImport(
          source2,
          local,
          imported,
          node.importKind === "type" ||
            (specifier.type === "ImportSpecifier" &&
              specifier.importKind === "type"),
          true,
          !options.inlineTemplate
        );
      }
    }
  }

  // 。。。省略
}

我們先在debug終端看看node.specifiers陣列是什麼樣的,如下圖:
specifiers

從上圖中可以看到node.specifiers陣列是一個匯入說明符,那麼為什麼他是一個陣列呢?原因是import匯入的時候可以一次匯入 多個變數進來,比如import {format, parse} from "./util.js"

node.source.value是當前import匯入的路徑,在我們這裡是./child.vue

specifier.local.name是將import匯入進來後賦值的變數,這裡是賦值為Child變數。

specifier.type是匯入的型別,這裡是ImportDefaultSpecifier,說明是default匯入。

接著呼叫getImportedName函式,根據匯入說明符獲取當前匯入的name。程式碼如下:

function getImportedName(specifier) {
  if (specifier.type === "ImportSpecifier")
    return specifier.imported.type === "Identifier"
      ? specifier.imported.name
      : specifier.imported.value;
  else if (specifier.type === "ImportNamespaceSpecifier") return "*";
  return "default";
}

大家都知道import匯入有三種寫法,分別對應的就是getImportedName函式中的三種情況。如下:

import { format } from "./util.js";	// 命名匯入
import * as foo from 'module';	// 名稱空間匯入
import Child from "./child.vue";	// default匯入的方式

如果是命名匯入,也就是specifier.type === "ImportSpecifier",就會返回匯入的名稱。

如果是名稱空間匯入,也就是specifier.type === "ImportNamespaceSpecifier",就會返回字串*

否則就是default匯入,返回字串default

最後就是拿著這些import匯入相關的資訊去呼叫registerUserImport函式。

registerUserImport函式

將斷點走進registerUserImport函式,程式碼如下:

function registerUserImport(
  source2,
  local,
  imported,
  isType,
  isFromSetup,
  needTemplateUsageCheck
) {
  let isUsedInTemplate = needTemplateUsageCheck;
  if (
    needTemplateUsageCheck &&
    ctx.isTS &&
    sfc.template &&
    !sfc.template.src &&
    !sfc.template.lang
  ) {
    isUsedInTemplate = isImportUsed(local, sfc);
  }
  ctx.userImports[local] = {
    isType,
    imported,
    local,
    source: source2,
    isFromSetup,
    isUsedInTemplate,
  };
}

registerUserImport函式就是將當前import匯入收集到ctx.userImports物件中的地方,我們先不看裡面的那塊if語句,先來在debug終端中來看看ctx.userImports物件中收集了哪些import匯入的資訊。如下圖:
userImports

從上圖中可以看到收集到ctx.userImports物件中的key就是import匯入進來的變數名稱,在這裡就是Child變數。

  • imported: 'default':表示當前import匯入是個default匯入的方式。

  • isFromSetup: true:表示當前import匯入是從setup函式中匯入的。

  • isType: false:表示當前import匯入不是一個ts的型別匯入,後面生成return物件時判斷是否要將當前import匯入加到return物件中,會去讀取ctx.userImports[key].isType屬性,其實就是這裡的isType

  • local: 'Child':表示當前import匯入進來的變數名稱。

  • source: './child.vue':表示當前import匯入進來的路徑。

  • isUsedInTemplate: true:表示當前import匯入的變數是不是在template中使用。

上面的一堆變數大部分都是在上一步"遍歷import匯入說明符"時拿到的,除了isUsedInTemplate以外。這個變數是呼叫isImportUsed函式返回的。

isImportUsed函式

將斷點走進isImportUsed函式,程式碼如下:

function isImportUsed(local, sfc) {
  return resolveTemplateUsedIdentifiers(sfc).has(local);
}

這個local你應該還記得,他的值是Child變數。resolveTemplateUsedIdentifiers(sfc)函式會返回一個set集合,所以has(local)就是返回的set集合中是否有Child變數,也就是template中是否有使用Child元件。

resolveTemplateUsedIdentifiers函式

接著將斷點走進resolveTemplateUsedIdentifiers函式,程式碼如下:

function resolveTemplateUsedIdentifiers(sfc): Set<string> {
  const { ast } = sfc.template!;
  const ids = new Set<string>();
  ast.children.forEach(walk);

  function walk(node) {
    switch (node.type) {
      case NodeTypes.ELEMENT:
        let tag = node.tag;
        if (
          !CompilerDOM.parserOptions.isNativeTag(tag) &&
          !CompilerDOM.parserOptions.isBuiltInComponent(tag)
        ) {
          ids.add(camelize(tag));
          ids.add(capitalize(camelize(tag)));
        }
        node.children.forEach(walk);
        break;
      case NodeTypes.INTERPOLATION:
      // ...省略
    }
  }
  return ids;
}

sfc.template.ast就是vue檔案中的template模組對應的AST抽象語法樹。遍歷AST抽象語法樹,如果當前節點型別是一個element元素節點,比如div節點、又或者<Child />這種節點。

node.tag就是當前節點的名稱,如果是普通div節點,他的值就是div。如果是<Child />節點,他的值就是Child

然後呼叫isNativeTag方法和isBuiltInComponent方法,如果當前節點標籤既不是原生html標籤,也不是vue內建的元件,那麼就會執行兩行ids.add方法,將當前自定義元件變數收集到名為ids的set集合中。

我們先來看第一個ids.add(camelize(tag))方法,camelize程式碼如下:

const camelizeRE = /-(\w)/g;
const camelize = (str) => {
  return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ""));
};

camelize函式使用正規表示式將kebab-case命名法,轉換為首字母為小寫的駝峰命名法。比如my-component經過camelize函式的處理後就變成了myComponent。這也就是為什麼以 myComponent 為名註冊的元件,在模板中可以透過 <myComponent><my-component> 引用。

再來看第二個ids.add(capitalize(camelize(tag)))方法,經過camelize函式的處理後已經變成了首字母為小寫的小駝峰命名法,然後執行capitalize函式。程式碼如下:

const capitalize = (str) => {
  return str.charAt(0).toUpperCase() + str.slice(1);
};

capitalize函式的作用就是將首字母為小寫的駝峰命名法轉換成首字母為大寫的駝峰命名法。這也就是為什麼以 MyComponent 為名註冊的元件,在模板中可以透過 <myComponent><my-component>或者是 <myComponent> 引用。

我們這個場景中是使用<Child />引用子元件,所以set集合中就會收集Child。再回到isImportUsed函式,程式碼如下:

function isImportUsed(local, sfc) {
  return resolveTemplateUsedIdentifiers(sfc).has(local);
}

前面講過了local變數的值是ChildresolveTemplateUsedIdentifiers(sfc)返回的是包含Child的set集合,所以resolveTemplateUsedIdentifiers(sfc).has(local)的值是true。也就是isUsedInTemplate變數的值是true,表示當前import匯入變數是在template中使用。後面生成return物件時判斷是否要將當前import匯入加到return物件中,會去讀取ctx.userImports[key].isUsedInTemplate屬性,其實就是這個isUsedInTemplate變數。

總結

執行compileScript函式會將setup語法糖編譯成setup函式,在compileScript函式中會去遍歷<script setup>對應的AST抽象語法樹。

如果是頂層變數、函式、類、列舉宣告,就會將其收集到setupBindings物件中。

如果是import語句,就會將其收集到ctx.userImports物件中。還會根據import匯入的資訊判斷當前import匯入是否是ts的型別匯入,並且賦值給isType屬性。然後再去遞迴遍歷template模組對應的AST抽象語法樹,看import匯入的變數是否在template中使用,並且賦值給isUsedInTemplate屬性。

遍歷setupBindings物件和ctx.userImports物件中收集的所有頂層繫結,生成setup函式中的return物件。在遍歷ctx.userImports物件的時候有點不同,會去判斷當前import匯入不是ts的型別匯入並且在還在template中使用了,才會將其加到setup函式的return物件中。在我們這個場景中setup函式會返回{ Child }物件。

在render函式中使用$setup["Child"]將子元件渲染到頁面上去,而這個$setup["Child"]就是在setup函式中返回的Child屬性,也就是Child子元件的引用。

關注公眾號:【前端歐陽】,給自己一個進階vue的機會

相關文章