Vite 插件開發(fā)實(shí)踐:微前端的資源處理
最近實(shí)現(xiàn)的簡(jiǎn)單、透明、組件化微前端方案總體感覺不錯(cuò),也收到了很多人的反饋,很具有學(xué)習(xí)參考價(jià)值。
但有不少朋友使用該方案打包配置出現(xiàn)了一些問題,做事應(yīng)有始有終,挖的坑總得完善一下。今天分享一下 Vite 針對(duì)微應(yīng)用方案插件開發(fā)歷程。
通過文章你可以學(xué)到:
問題點(diǎn)
總結(jié)下來,在 Vite 中使用該微前端方案會(huì)遇到如下問題:
Vite 打包后的資源默認(rèn)是以 HTML 為入口,我們的微前端方案需要以 JS 為入口
JS 為入口方案打包導(dǎo)出代碼被移除掉了
import.meta 語句打包被轉(zhuǎn)譯成 {} 空對(duì)象了
chunk 分離后的 CSS 文件,Vite 默認(rèn)以document.head.appendChild 處理
打包后的 CSS 文件默認(rèn)在 main.js 中沒有引用
資源路徑手動(dòng)寫 new URL(image, import.meta.url) 太繁瑣
通過配置解決問題
首先前三個(gè)問題可以通過 Vite 解決。Vite 兼容了 rollup 的配置
問題一,修改 JS 入口則需要修改 Vite 配置,設(shè)置 build.rollupOptions.input 為 src/main.tsx,這樣 Vite 會(huì)默認(rèn)以自定義配置的main.tsx 為入口文件做打包處理,不再生成 index.html。
問題二,rollup 的一個(gè)特性默認(rèn)會(huì)清理掉入口文件的導(dǎo)出模塊,可以配置 preserveEntrySignatures: 'allow-extension' 來保證打包之后 export 的模塊不被移除掉。
問題三,看了 Vite 的 Issue,很多人遇到了這個(gè)問題,最初以為是 Vite 默認(rèn)對(duì)它做了處理,后面看了 Vite 源碼也沒有發(fā)現(xiàn)處理的邏輯所在,應(yīng)該是被 esbuild 做了轉(zhuǎn)譯。因此將 build.target 設(shè)置為 esnext 即可解決問題,即import.meta 屬于 es2020,設(shè)置為具體的 es2020 也行。
配置:
export default defineConfig({
build: {
// es2020 支持 import.meta 語法
target: 'es2020',
rollupOptions: {
// 用于控制 Rollup 嘗試確保入口塊與基礎(chǔ)入口模塊具有相同的導(dǎo)出
preserveEntrySignatures: 'allow-extension',
// 入口文件
input: 'src/main.tsx',
},
},
});
寫 Vite 插件
我們可以寫一個(gè)插件將上面的配置封裝。
一個(gè)普通的 Vite 插件很簡(jiǎn)單
defineConfig({
plugins: [
{
// 可以使用 Vite 和 rollup 提供的鉤子
},
],
});
插件可以做很多事情,通過 Vite 和 rollup提供的鉤子對(duì)代碼解析、編譯、打包、輸出的整體流程進(jìn)行自定義處理。
插件一般不直接寫在 vite.config.ts 中,可以定義一個(gè)方法導(dǎo)出這個(gè)插件,這里可以用config 這個(gè)鉤子來提供默認(rèn)的 Vite 配置,將自定義的配置進(jìn)行封裝:
export function microWebPlugin(): Plugin {
// 插件鉤子
return {
name: 'vite-plugin-micro-web',
config() {
return {
build: {
target: 'es2020',
rollupOptions: {
preserveEntrySignatures: 'allow-extension',
input: 'src/main.tsx',
},
},
};
},
};
}
這樣一個(gè)簡(jiǎn)單的插件就完成了。
Vite 獨(dú)有鉤子
config - 在解析 Vite 配置前調(diào)用,它可以返回一個(gè)將被深度合并到現(xiàn)有配置中的部分配置對(duì)象,或者直接改變配置
configResolved - 在解析 Vite 配置后調(diào)用,使用這個(gè)鉤子讀取和存儲(chǔ)最終解析的配置
configureServer - 是用于配置開發(fā)服務(wù)器的鉤子
transformIndexHtml - 轉(zhuǎn)換 index.html 的專用鉤子。鉤子接收當(dāng)前的 HTML 字符串和轉(zhuǎn)換上下文
handleHotUpdate - 執(zhí)行自定義 HMR 更新處理。
rollup 鉤子
rollup 鉤子非常多,一共分兩個(gè)階段
編譯階段:
輸出階段:
這里我們會(huì)用到的鉤子有:
- transform - 用于轉(zhuǎn)換已加載的模塊內(nèi)容
- generateBundle - 已經(jīng)編譯過的代碼塊生成階段
樣式插入節(jié)點(diǎn)處理
問題四,document.head.appendChild 處理
使用 transform 鉤子,替換 Vite 默認(rèn)的document.head.appendChild 為自定義節(jié)點(diǎn)
cssCodeSplit 打包為一個(gè) CSS 文件
我們默認(rèn)采用 cssCodeSplit 打包為一個(gè) CSS 文件,免去了用插件 transform 修改 Vite 的邏輯。
問題五,即打包后的 CSS 沒有引用的問題,獲取這個(gè)帶 hash 的 CSS 我們可以有多種解決方案
使用 HTML 打包模式,抽取 index.html 中的 JS 、CSS 文件再單獨(dú)處理
不添加樣式文件名 hash ,通過約定固定該樣式名稱
通過鉤子提取文件名處理
權(quán)衡之下,最終采用 generateBundle 階段提取 Vite 編譯生成的 CSS 文件名,通過修改入口代碼將其插入。但 generateBundle 已經(jīng)在輸出階段,不會(huì)再走 transform 鉤子。
發(fā)現(xiàn)一個(gè)兩全其美的辦法:創(chuàng)建極小的入口文件main.js,還可以配合 hash 和主應(yīng)用時(shí)間戳緩存處理。
async generateBundle(options, bundle) {
// 主入口文件
let entry: string | undefined;
// 所有的 CSS 模塊
const cssChunks: string[] = [];
// 找出入口文件和 CSS 文件
for (const chunkName of Object.keys(bundle)) {
if (chunkName.includes('main') && chunkName.endsWith('.js')) {
entry = chunkName;
}
if (chunkName.endsWith('.css')) {
// 使用相對(duì)路徑,避免后續(xù) ESM 無法解析模塊
cssChunks.push(`./${chunkName}`);
}
}
// 接下面代碼
},
生成新的入口文件
通過 bundle 提取可以獲取到帶 hash 的JS、CSS 入口文件了?,F(xiàn)在需要寫入一個(gè)新的文件 main.js。rollup 中有個(gè) API emitFile可以觸發(fā)創(chuàng)建一個(gè)資源文件。
接下來對(duì)它進(jìn)行處理:
// 接上面代碼
if (entry) {
const cssChunksStr = JSON.stringify(cssChunks);
// 創(chuàng)建極小的入口文件,配合 hash 和主應(yīng)用時(shí)間戳緩存處理
this.emitFile({
fileName: 'main.js',
type: 'asset',
source: `
// 帶上 microAppEnv 參數(shù),使用相對(duì)路徑避免報(bào)錯(cuò)
import defineApp from './${entry}?microAppEnv';
// 創(chuàng)建 link 標(biāo)簽
function createLink(href) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
return link;
}
// 入口文件導(dǎo)出一個(gè)方法,將打包的 CSS 文件通過 link 的方式插入到對(duì)應(yīng)的節(jié)點(diǎn)中
defineApp.styleInject = (parentNode) => {
${cssChunksStr}.forEach((css) => {
// import.meta.url 讓路徑保持正確,中括號(hào)取值避免被 rollup 轉(zhuǎn)換掉
const link = createLink(new URL(css, import.meta['url']));
parentNode.prepend(link);
});
};
export default defineApp;
`,
});
}
插件需要應(yīng)用入口配合導(dǎo)出一個(gè) styleInject方法提供樣式插入,我們通過封裝入口方法得以解決。
封裝一個(gè)方法給應(yīng)用入口調(diào)用:
export function defineMicroApp(callback) {
const defineApp = (container) => {
const appConfig = callback(container);
// 處理樣式局部插入
const mountFn = appConfig.mount;
// 獲取到插件中的方法
const inject = defineApp.styleInject;
if (mountFn && inject) {
appConfig.mount = (props) => {
mountFn(props);
// 裝載完畢后,插入樣式
inject(container);
};
}
return appConfig;
};
return defineApp;
}
現(xiàn)在 build 之后會(huì)生成一個(gè)不帶 hash 的 main.js 文件,主應(yīng)用可以正常加載打包后的資源了。
進(jìn)一步優(yōu)化,main.js 的壓縮混淆,可以用 Vite 導(dǎo)出 transformWithEsbuild 進(jìn)行編譯:
const result = await transformWithEsbuild(customCode, 'main.js', {
minify: true,
});
this.emitFile({
fileName: 'main.js',
type: 'asset',
source: result.code,
});
子應(yīng)用路徑問題
之前我們需要手動(dòng)添加 new URL(image, import.meta.url) 來修復(fù)子應(yīng)用路徑問題。通過 transform 鉤子自動(dòng)處理該邏輯。
在這個(gè)插件之前,Vite 會(huì)將所有的資源文件轉(zhuǎn)換為路徑
import logo from './logo.svg';
// 轉(zhuǎn)換為:
export default '/src/logo.svg';
因此,我們只需要將 export default "資源路徑" 替換為 export default new URL("資源路徑", import.meta['url']).href 就可以了。
const imagesRE = new RegExp(`\\.(png|webp|jpg|gif|jpeg|tiff|svg|bmp)($|\\?)`);
transform(code, id) {
// 修正圖片資源使用絕對(duì)地址
if (imagesRE.test(id)) {
return {
code: code.replace(
/(export\s+default)\s+(".+")/,
`$1 new URL($2, import.meta['url']).href`
),
map: null,
};
}
return undefined;
},
完成,一個(gè)比較完善的 Vite 微應(yīng)用方案由此而生。
看看效果:
更多
有了插件,可以發(fā)揮出意想不到的事情。本微前端方案沒有實(shí)現(xiàn)以下的隔離方式,不保證后續(xù)會(huì)實(shí)現(xiàn),大家可以發(fā)揮更多的想象力。
CSS 樣式隔離
通過插件將主應(yīng)用節(jié)點(diǎn)中的 id 添加并修改CSS
.name {
color: red;
}
/* 轉(zhuǎn)換為 */
#id .name {
color: red;
}
但前提是需要為每個(gè) 設(shè)置一個(gè)唯一的 id。并且樣式性能會(huì)受到影響,CSSModules 方案會(huì)更好。
JS 沙箱
雖然在 ESM 中做運(yùn)行時(shí)沙箱目前沒有現(xiàn)成的方案,但運(yùn)行時(shí)沙箱性能非常差。換個(gè)思路,可以從編譯時(shí)沙箱入手。用 transform 鉤子將應(yīng)用所有的 window 轉(zhuǎn)譯為沙箱fakeWindow,從而達(dá)到隔離效果。
代碼示例
大家可以 clone 下來學(xué)習(xí)
插件倉庫:https://github.com/MinJieLiu/micro-app/tree/main/packages/micro-vite-plugin
微前端示例:https://github.com/MinJieLiu/micro-app-demo