前言
眾所周知,在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
終端。
然後在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
物件中。
最後就是使用擴充套件運算子...setupBindings
將setupBindings
物件中的屬性合併到allBindings
物件中。
對於ctx.userImports
的處理就不一樣了,不會將其全部合併到allBindings
物件中。而是遍歷ctx.userImports
物件,如果當前import匯入不是ts的型別匯入,並且匯入的東西在template模版中使用了,才會將其合併到allBindings
物件中。
經過前面的處理allBindings
物件中已經收集了setup語法糖中的所有頂層繫結,然後遍歷allBindings
物件生成setup函式中的return物件。
我們在debug終端來看看生成的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程式碼字串是什麼樣的,如下圖:
從上圖中可以看到此時的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程式碼字串,如下圖:
從上圖中可以看到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
陣列是什麼樣的,如下圖:
從上圖中可以看到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匯入的資訊。如下圖:
從上圖中可以看到收集到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
變數的值是Child
,resolveTemplateUsedIdentifiers(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的機會