前言
在今年的Vue Conf 2024大會上,沈青川大佬(維護Vue/Vite 中文文件)在會上介紹了他的新專案Vue Vine。Vue Vine提供了全新Vue元件書寫方式,主要的賣點是可以在一個檔案裡面寫多個vue元件。相信你最近應該看到了不少介紹Vue Vine的文章,這篇文章我們另闢蹊徑來講講Vue Vine是如何實現在一個檔案裡面寫多個vue元件。
關注公眾號:【前端歐陽】,給自己一個進階vue的機會
看個demo
我們先來看普通的vue元件,about.vue
程式碼如下:
<template>
<h3>i am about page</h3>
</template>
<script lang="ts" setup></script>
我們在瀏覽器中來看看編譯後的js程式碼,程式碼如下:
const _sfc_main = {};
function _sfc_render(_ctx, _cache) {
return _openBlock(), _createElementBlock("h3", null, "i am about page");
}
_sfc_main.render = _sfc_render;
export default _sfc_main;
從上面的程式碼可以看到普通的vue元件編譯後生成的js檔案會export default
匯出一個_sfc_main
元件物件,並且這個元件物件上面有個大名鼎鼎的render
函式。父元件只需要import匯入子元件裡面export default
匯出的_sfc_main
元件物件就可以啦。
搞清楚普通的vue元件編譯後是什麼樣的,我們接著來看一個Vue Vine的demo,Vue Vine的元件必須以.vine.ts
結尾,home.vine.ts
程式碼如下:
async function ChildComp() {
return vine`
<h3>我是子元件</h3>
`;
}
export async function Home() {
return vine`
<h3>我是父元件</h3>
<ChildComp />
`;
}
如果你熟悉react,你會發現Vine 元件函式和react比較相似,不同的是return的時候需要在其返回值上顯式使用 vine
標記的模板字串。
在瀏覽器中來看看home.vine.ts
編譯後的程式碼,程式碼如下:
export const ChildComp = (() => {
const __vine = _defineComponent({
name: "ChildComp",
setup(__props, { expose: __expose }) {
// ...省略
},
});
function __sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return _openBlock(), _createElementBlock("h3", null, "我是子元件");
}
__vine.render = __sfc_render;
return __vine;
})();
export const Home = (() => {
const __vine = _defineComponent({
name: "Home",
setup(__props, { expose: __expose }) {
// ...省略
},
});
function __sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
_Fragment,
null,
[_hoisted_1, _createVNode($setup["ChildComp"])],
64,
)
);
}
__vine.render = __sfc_render;
return __vine;
})();
從上面的程式碼可以看到元件ChildComp
和Home
編譯後是一個立即呼叫函式,在函式中return了__vine
元件物件,並且這個元件物件上面也有render函式。想必細心的你已經發現了在同一個檔案裡面定義的多個元件經過編譯後,從常規的export default匯出一個預設的vue元件物件變成了export匯出多個具名的vue元件物件。
接下來我們將透過debug的方式帶你搞清楚Vue Vine是如何實現一個檔案內匯出多個vue元件物件。
createVinePlugin
函式
我們遇見的第一個問題是需要找到從哪裡開始著手debug?
來看一下官方文件是接入vue vine的,如下圖:
從上圖中可以看到vine是一個vite外掛,以外掛的形式起作用的。
現在我們找到了一切起源就是這個VineVitePlugin
函式,所以我們需要給vite.config.ts
檔案中的VineVitePlugin
函式打個斷點。如下圖:
接下來我們需要啟動一個debug終端。這裡以vscode
舉例,開啟終端然後點選終端中的+
號旁邊的下拉箭頭,在下拉中點選Javascript Debug Terminal
就可以啟動一個debug
終端。
在debug終端執行yarn dev
,在瀏覽器中開啟對應的頁面,比如:http://localhost:3333/ 。此時程式碼將會停留在我們打的斷點VineVitePlugin
函式呼叫處,讓程式碼走進VineVitePlugin
函式,發現這個函式實際定義的名字叫createVinePlugin
,在我們這個場景中簡化後的createVinePlugin
函式程式碼如下:
function createVinePlugin() {
return {
name: "vue-vine-plugin",
async resolveId(id) {
// ...省略
},
async load(id) {
// ...省略
},
async transform(code, id) {
const { fileId, query } = parseQuery(id);
if (!fileId.endsWith(".vine.ts") || query.type === QUERY_TYPE_STYLE) {
return;
}
return runCompileScript(code, id);
},
async handleHotUpdate(ctx) {
// ...省略
}
};
}
從上面的程式碼可以看到外掛中有不少鉤子函式,vite
會在對應的時候呼叫這些外掛的鉤子函式,比如當vite
解析每個模組時就會呼叫transform
等函式。
transform
鉤子函式的接收的第一個引數為code
,是當前檔案的code程式碼字串。第二個引數為id,是當前檔案路徑,這個路徑可能帶有query。
在transform
鉤子函式中先呼叫parseQuery
函式根據當前檔案路徑拿到去除query的檔案路徑,以及query物件。
!fileId.endsWith(".vine.ts")
的意思是判斷當前檔案是不是.vine.ts
結尾的檔案,如果不是則不進行任何處理,這也就是為什麼文件中會寫Vue Vine只支援.vine.ts
結尾的檔案。
query.type === QUERY_TYPE_STYLE
的意思是判斷當前檔案是不是css檔案,因為同一個vue檔案會被處理兩次,第一次處理時只會處理template和script這兩個模組,第二次再去單獨處理style模組。
在transform
鉤子函式的最後就是呼叫runCompileScript(code, id)
函式,並且將其執行結果進行返回。
runCompileScript
函式
接著將斷點走進runCompileScript
函式,在我們這個場景中簡化後的runCompileScript
函式程式碼如下:
const runCompileScript = (code, fileId) => {
const vineFileCtx = compileVineTypeScriptFile(
code,
fileId,
compilerHooks,
fileCtxMap,
);
return {
code: vineFileCtx.fileMagicCode.toString(),
};
};
從上面的程式碼可以看到首先會以code
(當前檔案的code程式碼字串)為引數去執行compileVineTypeScriptFile
函式,這個函式會返回一個vineFileCtx
上下文物件。這個上下文物件的fileMagicCode.toString(),
就是前面我們在瀏覽器中看到的最終編譯好的js程式碼。
compileVineTypeScriptFile
函式
接著將斷點走進compileVineTypeScriptFile
函式,在我們這個場景中簡化後的compileVineTypeScriptFile
函式程式碼如下:
function compileVineTypeScriptFile(
code: string,
fileId: string,
compilerHooks: VineCompilerHooks,
fileCtxCache?: VineFileCtx,
) {
const vineFileCtx: VineFileCtx = createVineFileCtx(
code,
fileId,
fileCtxCache,
);
const vineCompFnDecls = findVineCompFnDecls(vineFileCtx.root);
doAnalyzeVine(compilerHooks, vineFileCtx, vineCompFnDecls);
transformFile(
vineFileCtx,
compilerHooks,
compilerOptions?.inlineTemplate ?? true,
);
return vineFileCtx;
}
在執行compileVineTypeScriptFile
函式之前,我們在debug終端來看看接收的第一個引數code
,如下圖:
從上圖中可以看到第一個引數code
就是我們寫的home.vine.ts
檔案中的原始碼。
createVineFileCtx
函式
接下來看第一個函式呼叫createVineFileCtx
,這個函式返回一個vineFileCtx
上下文物件。將斷點走進createVineFileCtx
函式,在我們這個場景中簡化後的createVineFileCtx
函式程式碼如下:
import MagicString from 'magic-string'
function createVineFileCtx(code: string, fileId: string) {
const root = babelParse(code);
const vineFileCtx: VineFileCtx = {
root,
fileMagicCode: new MagicString(code),
vineCompFns: [],
// ...省略
};
return vineFileCtx;
}
由於Vue Vine中的元件和react相似是元件函式,元件函式中當然全部都是js程式碼。既然是js程式碼那麼就可以使用babel的parser
函式將元件函式的js程式碼編譯成AST抽象語法樹,所以第一步就是使用code
去呼叫babel的parser
函式生成AST抽象語法樹,然後賦值給root
變數。
我們在debug終端來看看得到的AST抽象語法樹是什麼樣的,如下圖:
從上圖中可以看到在body陣列中有兩項,分別對應的就是ChildComp
元件函式和Home
元件函式。
接下來就是return返回一個vineFileCtx
上下文物件,物件上面的幾個屬性我們需要講一下。
-
root
:由.vine.ts
檔案轉換後的AST抽象語法樹。 -
vineCompFns
:陣列中存了檔案中定義的多個vue元件,初始化時為空陣列。 -
fileMagicCode
:是一個由magic-string
庫new的一個物件,物件中存了在編譯時生成的js程式碼字串。magic-string
是由svelte的作者寫的一個庫,用於處理字串的JavaScript
庫。它可以讓你在字串中進行插入、刪除、替換等操作,在編譯時就是利用這個庫生成編譯後的js程式碼。toString
方法返回經過處理後的字串,前面的runCompileScript
函式中就是最終呼叫vineFileCtx.fileMagicCode.toString()
方法返回經過編譯階段處理得到的js程式碼。
findVineCompFnDecls
函式
我們接著來看compileVineTypeScriptFile
函式中的第二個函式呼叫findVineCompFnDecls
:
function compileVineTypeScriptFile(
code: string,
fileId: string,
compilerHooks: VineCompilerHooks,
fileCtxCache?: VineFileCtx,
) {
// ...省略
const vineCompFnDecls = findVineCompFnDecls(vineFileCtx.root);
// ...省略
}
透過前一步我們拿到了一個vineFileCtx
上下文物件,vineFileCtx.root
中存的是編譯後的AST抽象語法樹。
所以這一步就是呼叫findVineCompFnDecls
函式從AST抽象語法樹中提取出在.vine.ts
檔案中定義的多個vue元件物件對應的Node節點。我們在debug終端來看看元件物件對應的Node節點組成的陣列vineCompFnDecls
,如下圖:
從上圖中可以看到陣列由兩個Node節點組成,分別對應的是ChildComp
元件函式和Home
元件函式。
doAnalyzeVine
函式
我們接著來看compileVineTypeScriptFile
函式中的第三個函式呼叫doAnalyzeVine
:
function compileVineTypeScriptFile(
code: string,
fileId: string,
compilerHooks: VineCompilerHooks,
fileCtxCache?: VineFileCtx,
) {
// ...省略
doAnalyzeVine(compilerHooks, vineFileCtx, vineCompFnDecls);
// ...省略
}
經過上一步的處理我們拿到了兩個元件物件的Node節點,並且將這兩個Node節點存到了vineCompFnDecls
陣列中。
由於元件物件的Node節點是一個標準的AST抽象語法樹的Node節點,並不能清晰的描述一個vue元件物件。所以接下來就是呼叫doAnalyzeVine
函式遍歷元件物件的Node節點,將其轉換為能夠清晰的描述一個vue元件的物件,將這些vue元件物件組成陣列塞到vineFileCtx
上下文物件的vineCompFns
屬性上。
我們在debug終端來看看經過doAnalyzeVine
函式處理後生成的vineFileCtx.vineCompFns
屬性是什麼樣的,如下圖:
從上圖中可以看到vineCompFns
屬性中存的元件物件已經能夠清晰的描述一個vue元件,上面有一些我們熟悉的屬性props
、slots
等。
transformFile
函式
我們接著來看compileVineTypeScriptFile
函式中的第四個函式呼叫transformFile
:
function compileVineTypeScriptFile(
code: string,
fileId: string,
compilerHooks: VineCompilerHooks,
fileCtxCache?: VineFileCtx,
) {
// ...省略
transformFile(
vineFileCtx,
compilerHooks,
compilerOptions?.inlineTemplate ?? true,
);
// ...省略
}
經過上一步的處理後在vineFileCtx
上下文物件的vineCompFns
屬性陣列中已經存了一系列能夠清晰描述vue元件的物件。
在前面我們講過了vineFileCtx.vineCompFns
陣列中存的物件能夠清晰的描述一個vue元件,但是物件中並沒有我們期望的render函式、setup函式等。
所以接下來就需要呼叫transformFile
函式,遍歷上一步拿到的vineFileCtx.vineCompFns
陣列,將所有的vue元件轉換成對應的立即呼叫函式。在每個立即呼叫函式中都會return一個__vine
元件物件,並且這個__vine
元件物件上都有一個render屬性。
之所以包裝成一個立即呼叫函式,是因為每個元件都會生成一個名為__vine
元件物件,所以才需要立即呼叫函式將作用域進行隔離。
我們在debug終端來看看經過transformFile
函式處理後拿到的js code程式碼字串,如下圖:
從上圖中可以看到此時的js code程式碼字串已經和我們之前在瀏覽器中看到的編譯後的程式碼一模一樣了。
總結
Vue Vine是一個vite外掛,vite解析每個模組時都會觸發外掛的transform
鉤子函式。在鉤子函式中會去判斷當前檔案是否以.vine.ts
結尾的,如果不是則return。
在transform
鉤子函式中會去呼叫runCompileScript
函式,runCompileScript
函式並不是實際幹活的地方,而是去呼叫compileVineTypeScriptFile
函式。
在compileVineTypeScriptFile
函式中先new一個vineFileCtx
上下文物件,物件中的root
屬性存了由.vine.ts
檔案轉換成的AST抽象語法樹。
接著就是呼叫findVineCompFnDecls
函式從AST抽象語法樹中找到元件物件對應的Node節點。
由於Node節點並不能清晰的描述一個vue元件,所以需要呼叫doAnalyzeVine
函式將這些Node節點轉換成能夠清晰描述vue元件的物件。
最後就是遍歷這些vue元件物件將其轉換成立即呼叫函式。在每個立即呼叫函式中都會return一個__vine
元件物件,並且這個__vine
元件物件上都有一個render屬性。
關注公眾號:【前端歐陽】,給自己一個進階vue的機會