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

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

前言 眾所周知,在vue2的時候使用一個vue組件要麼全局註冊,要麼局部註冊。但是在setup語法糖中直接將組件import導入無需註冊就可以使用,你知道這是為什麼呢?註:本文中使用的vue版本為3.4.19。 關註公眾號:【前端歐陽】,給自己一個進階vue的機會 看個demo 我們先來看個簡單的d ...


前言

眾所周知,在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的機會


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

-Advertisement-
Play Games
更多相關文章
  • 摘要:本文是一份關於Nuxt 3路由系統的詳盡指南。它從介紹Nuxt 3的基本概念開始,包括Nuxt 3與Nuxt 2的區別和選擇Nuxt 3的理由。然後,它詳細解釋了安裝和配置Nuxt 3的步驟,以及Nuxt 3路由系統的基礎知識,如動態路由和嵌套路由。接著,它介紹了路由中間件的作用和編寫自定義中... ...
  • 最近需要做一個三維場景切換的功能,切換場景後,還可以進行二三維模式的切換,二三維切換時,要定位到當前場景視角,那麼場景的視角參數信息就需要保存到狀態數據中,以供二三維場景切換時使用。 項目是用vue做的,這裡並沒有使用vue的狀態管理庫,我是這樣實現的: 定義狀態數據sceneInfo let sc ...
  • 你習慣在js代碼中使用async await嗎? 我經常在js代碼中寫一些非同步方法,使用await調用的地方,如果方便修改成非同步方法,就修改成非同步方法,如下所示: async setPosition(graphic, lng, lat) { this.lng = lng; this.lat = la ...
  • 你不知道的 CSS 之包含塊 一說到 CSS 盒模型,這是很多小伙伴耳熟能詳的知識,甚至有的小伙伴還能說出 border-box 和 content-box 這兩種盒模型的區別。 但是一說到 CSS 包含塊,有的小伙伴就懵圈了,什麼是包含塊?好像從來沒有聽說過這玩意兒。 好吧,如果你對包含塊的知識一 ...
  • 作為一名前端開發者,vscode想必大家應該都接觸過,就像大多數 IDE 一樣,VSCode 也有一個擴展和主題市場,包含了數以千計質量不同的插件。 作為一名熟練掌握各種前端開發工具安裝和卸載的大師兄來說,為大家安利好玩有用的工具插件是我義不容辭的責任,所以我精挑細選了九款必備的vscode插件 C ...
  • ‍ 寫在開頭 點贊 + 收藏 學會 需求背景 從第三方採購的vue2 + ElementUI實現的雲管平臺,乙方說2011年左右就開始有這個項目了(那時候有Vue了嗎,思考.jpg)。十幾年的項目,我何德何能可以擔此責任。裡面的代碼經過多人多年迭代可以用慘不忍睹來形容,吐槽歸吐槽 ...
  • 摘要:本文深入探討了Nuxt 3的組件開發與管理,從基礎概念、安裝配置、目錄結構、組件分類與開發實踐、生命周期與優化,到測試與維護策略。詳細介紹了Nuxt 3的核心特點,如伺服器端渲染(SSR)、靜態站點生成(SSG)以及與Vue生態系統的無縫集成。文章以Nuxt 3為基礎,指導開發者如何高效構建高... ...
  • 問題分析 當我們需要用摺疊面板的時候,往往會考慮element-ui的el-collaspe,然而大多數時候原生預設的樣式並無法拿來就用。我們往往會自定義組件的樣式,或者在預設的基礎上進行修改。最近在Vue項目中進行組件修改的時候,無意間某個文件自動設置成了scoped(應該是插件自動化生成的結構代 ...
一周排行
    -Advertisement-
    Play Games
  • 問題 有很多應用程式在驗證JSON數據的時候用到了JSON Schema。 在微服務架構下,有時候各個微服務由於各種歷史原因,它們所生成的數據對JSON Object屬性名的大小寫規則可能並不統一,它們需要消費的JSON數據的屬性名可能需要大小寫無關。 遺憾的是,目前的JSON Schema沒有這方 ...
  • 首先下載centos07鏡像,建議使用阿裡雲推薦的地址: https://mirrors.aliyun.com/centos/7.9.2009/isos/x86_64/?spm=a2c6h.25603864.0.0.59b5f5ad5Nfr0X 其實這裡就已經出現第一個坑了 centos 07 /u ...
  • 相信很多.NETer看了標題,都會忍不住好奇,點進來看看,並且順便準備要噴作者! 這裡,首先要申明一下,作者本人也非常喜歡Linq,也在各個項目中常用Linq。 我愛Linq,Linq優雅萬歲!!!(PS:順便吐槽一下,隔壁Java從8.0版本推出的Streams API,抄了個四不像,一點都不優雅 ...
  • 在人生的重要時刻,我站在了畢業的門檻上,望著前方的道路,心中涌動著對未來的無限憧憬與些許忐忑。面前,兩條道路蜿蜒伸展:一是繼續在職場中尋求穩定,一是勇敢地走出一條屬於自己的創新之路。儘管面臨年齡和現實的挑戰,我仍舊選擇勇往直前,用技術這把鑰匙,開啟新的人生篇章。 迴首過去,我深知時間寶貴,精力有限。 ...
  • 單元測試 前言 時隔多個月,終於抽空學習了點新知識,那麼這次來記錄一下C#怎麼進行單元測試,單元測試是做什麼的。 我相信大部分剛畢業的都很疑惑單元測試是乾什麼的?在小廠實習了6個月後,我發現每天除了寫CRUD就是寫CRUD,幾乎用不到單元測試。寫完一個功能直接上手去測,當然這隻是我個人感受,僅供參考 ...
  • 一:背景 1. 講故事 最近在分析dump時,發現有程式的卡死和WeakReference有關,在以前只知道怎麼用,但不清楚底層邏輯走向是什麼樣的,藉著這個dump的契機來簡單研究下。 二:弱引用的玩法 1. 一些基礎概念 用過WeakReference的朋友都知道這裡面又可以分為弱短和弱長兩個概念 ...
  • 最近想把ET打表工具的報錯提示直接調用win系統彈窗,好讓策劃明顯的知道表格哪裡填錯數據,彈窗需要調用System.Windows.Forms庫。操作如下: 需要在 .csproj 文件中添加: <UseWindowsForms>true</UseWindowsForms> 須將目標平臺設置為 Wi ...
  • 從C#3開始,拓展方法這一特性就得到了廣泛的應用。 此功能允許你能夠使用實例方法的語法調用某個靜態方法,以下是一個獲取/創建文件的靜態方法: public static async Task<StorageFile> GetOrCreateFileAsync(this StorageFolder f ...
  • 在Windows 11下,使用WinUI2.6以上版本的ListView長這樣: 然而到了Win10上,儘管其他控制項的樣式沒有改變,但ListViewItem變成了預設樣式(初代Fluent) 最重大的問題是,Win10上的HorizontalAlignment未被設置成Stretch,可能造成嚴重 ...
  • 前言 周六在公司加班,幹完活後越顯無聊,想著下載RabbiitMQ做個小項目玩玩。然而這一下就下載了2個小時,真讓人頭痛。 簡單的講一下如何安裝吧,網上教程和踩坑文章還是很多的,我講我感覺有用的文章放在本文末尾。 安裝地址 erlang 下載 - Erlang/OTP https://www.erl ...