Vite打包碎片化,如何化解?

濮水大叔發表於2024-10-14

背景

我們在使用 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

相關文章