背景
我們在使用 Vite 進行打包時,經常會遇到這個問題:隨著業務的展開,版本迭代,頁面越來越多,第三方依賴也越來越多,打出來的包也越來越大。如果把頁面都進行動態匯入,那麼凡是幾個頁面共用的檔案都會進行獨立拆包,從而導致大量 chunk 碎片的產生。許多 chunk 碎片體積都很小,比如:1k,2k,3k,從而顯著增加了瀏覽器的資源請求。
雖然可以透過rollupOptions.output.manualChunks
定製分包策略,但是檔案之間的依賴關係錯綜複雜,分包配置稍有不慎,要麼導致初始包體積過大,要麼導致出現迴圈依賴錯誤,因此心智負擔很重。那麼有沒有自動化的分包機制來徹底解決打包碎片化的問題呢?
拆包合併的兩種隱患
前面提到使用rollupOptions.output.manualChunks
定製分包策略有兩種隱患,這裡展開說明一下:
1. 導致初始包體積過大
如圖所示,檔案 A 本來只依賴檔案 C,但是按照圖中所示分包配置,導致在使用檔案 A 之前必須先下載 Chunk1 和 Chunk2。在稍微大一點的專案中,由於檔案之間的依賴關係非常複雜,這種依賴關係會隨著大量小檔案的合併而快速蔓延,導致初始包體積過大。
2. 導致出現迴圈依賴錯誤
如圖所示,由於檔案之間的相互依賴,導致打包後的 Chunk1 和 Chunk2 出現迴圈依賴的錯誤。那麼在複雜的專案中,業務之間相互依賴的情況就更加常見。
解決之道:模組化體系
由於分包配置會導致以上兩個隱患,所以往往步履維艱,很難有一個可以遵循的簡便易用的配置規則。因為分包配置與業務的當前狀態密切相關。一旦業務有所變更,分包配置也需要做相應的改變。
為了解決這個難題,我在專案中引入了模組化體系。也就是將專案的程式碼依據業務特點進行拆分,形成若干個模組的組合。每一個模組都可以包含頁面、元件、配置、語言、工具等等資源。然後一個模組就是一個天然的拆包邊界,在 build 構建時,自動打包成一個獨立的非同步 chunk,告別 Vite 配置的煩惱,同時可以有效避免構建產物的碎片化。特別是在大型業務系統中,這種優勢尤其明顯。當然,採用模組化體系也有利於程式碼解耦,便於分工協作。
由於一個模組就是一個拆包邊界,我們可以透過控制模組的內容和數量來控制產物 chunk 的大小和數量。而模組劃分的依據是業務特點,具有現實的業務意義,相較於rollupOptions.output.manualChunks
定製,顯然心智負擔很低。
檔案結構
隨著專案不斷迭代演進,建立的業務模組也會隨之膨脹。對於某些業務場景,往往需要多個模組的配合實現。因此,我還在專案中引入了套件的概念,一個套件就是一組業務模組的組合。這樣,一個專案就是由若干套件和若干模組組合而成的。下面是一個專案的檔案結構:
project
├── src
│ ├── module
│ ├── module-vendor
│ ├── suite
│ │ ├── a-demo
│ │ └── a-home
│ │ ├── modules
│ │ │ ├── home-base
│ │ │ ├── home-icon
│ │ │ ├── home-index
│ │ │ └── home-layout
│ └── suite-vendor
名稱 | 說明 |
---|---|
src/module | 獨立模組(不屬於套件) |
src/module-vendor | 獨立模組(來自第三方) |
src/suite | 套件 |
src/suite-vendor | 套件(來自第三方) |
名稱 | 說明 |
---|---|
a-demo | 測試套件:將測試程式碼放入一個套件中,從而方便隨時禁用 |
a-home | 業務套件:包含 4 個業務模組 |
打包效果
下面就來看一下實際的打包效果:
以模組home-base為例,圖左顯示的就是該模組的程式碼,圖右顯示的就是該模組打包後的檔案體積 12K,壓縮後是 3K。要達到這種分包效果,不需要做任何配置。
再比如,我們還可以把佈局元件集中放入模組home-layout進行管理。該模組打包成獨立的 Chunk,體積為 29K,壓縮後是 6K。
原始碼分析
1. 動態匯入模組
由於專案的模組目錄結構都是有規律的,我們可以在專案啟動之前提取所有的模組清單,然後生成一個 js 檔案,集中實現模組的動態匯入:
const modules = {};
...
modules['home-base'] = { resource: () => import('home-base')};
modules['home-layout'] = { resource: () => import('home-layout')};
...
export const modulesMeta = { modules };
由於所有模組都是透過 import 方法動態匯入的,那麼在進行 Vite 打包時就會自動拆分為獨立的 chunk。
2. 拆包配置
我們還需要透過rollupOptions.output.manualChunks
定製拆包配置,從而確保模組內部的程式碼統一打包到一起,避免出現碎片化檔案。
const __ModuleLibs = [
/src\/module\/([^\/]*?)\//,
/src\/module-vendor\/([^\/]*?)\//,
/src\/suite\/.*\/modules\/([^\/]*?)\//,
/src\/suite-vendor\/.*\/modules\/([^\/]*?)\//,
];
const build = {
rollupOptions: {
output: {
manualChunks: (id) => {
return customManualChunk(id);
},
},
},
};
function customManualChunk(id: string) {
for (const moduleLib of __ModuleLibs) {
const matched = id.match(moduleLib);
if (matched) return matched[1];
}
return null;
}
透過正規表示式匹配每一個檔案路徑,如果匹配成功就使用相應的模組名稱作為 chunk name。
兩種隱患的解決之道
如果模組之間相互依賴,那麼也有可能存在前面所言的兩種隱患,如圖所示:
為了防止兩種隱患情況的發生,我們可以實現一種更精細的動態載入和資源定位的機制。簡而言之,當我們在模組 1中訪問模組 2的資源時,首先要動態載入模組 2,然後找到模組 2 的資源,返回給使用方。
比如,在模組 2 中有一個 Vue 元件Card
,模組 1 中有一個頁面元件FirstPage
,我們需要在頁面元件FirstPage
中使用Card
元件。那麼,我們需要這樣來做:
// 動態載入模組
export async function loadModule(moduleName: string) {
const moduleRepo = modulesMeta.modules[moduleName];
return await moduleRepo.resource();
}
// 生成非同步元件
export function createDynamicComponent(moduleName: string, name: string) {
return defineAsyncComponent(() => {
return new Promise((resolve) => {
// 動態載入模組
loadModule(moduleName).then((moduleResource) => {
// 返回模組中的元件
resolve(moduleResource.components[name]);
});
});
});
}
const ZCard = createDynamicComponent('模組2', 'Card');
export class RenderFirstPage {
render() {
return (
<div>
<ZCard />
</div>
);
}
}
高階匯入機制
雖然使用createDynamicComponent
可以達到預期的目的,但是,程式碼不夠簡潔,無法充分利用 Typescript 提供的自動匯入機制。我們希望仍然像常規的方式一樣使用元件:
import { ZCard } from '模組2';
export class RenderFirstPage {
render() {
return (
<div>
<ZCard />
</div>
);
}
}
這樣的程式碼,就是靜態匯入的形式,就會導致模組 1和模組 2強相互依賴。那麼,有沒有兩全其美的方式呢?有的。我們可以開發一個 Babel 外掛,對 AST 語法樹進行解析,自動將 ZCard 的匯入改為動態匯入形式。這樣的話,我們的程式碼不僅簡潔直觀,而且還可以實現動態匯入,規避分包時兩種隱患的發生。為了避免主題分散,Babel 外掛如何開發不在這裡展開,如果感興趣,可以直接參考原始碼:babel-plugin-zova-component
結語
本文對 Vite 打包碎片化的成因進行了分析,並且提出了模組化體系,從而簡化分包配置,同時又採用動態載入機制,完美規避了分包時兩種隱患的發生。
當然,實現一個完整的模組化系統,需要考慮的細節還有很多,如果想體驗開箱即用的效果,可以訪問我開源的 Zova.js 框架:https://github.com/cabloy/zova。可新增我的微信,入群交流:yangjian2025