Migrating to Vite

Alex O'Callaghan

遷移至 Vite

原文由 Alex O'Callaghan 發布,訂閱此部落格

我們的設計系統 React 元件庫原本使用 Webpack 來建置發布的 JS 檔案。我們剛完成遷移至 Vite,透過支援 tree-shaking 帶來開發體驗的改善,並讓最終應用程式的 bundle 體積更小。

為什麼選擇 Vite?

我們當初使用 Webpack 來建置元件庫的主要原因之一,是為了讓 Storybook 開發環境與最終的函式庫建置結果保持一致。開發期間,元件的變更往往只在 Storybook 中測試,若兩者的建置結果不一致,元件在實際應用程式中使用時就可能出現非預期的問題。

Storybook 現已支援 Vite 作為建置工具,因此我們仍能在不同建置之間維持一致性。我們也能使用 Vitest,讓函式庫建置、Storybook 與單元測試共用同一份設定。

相較於 Webpack,Vite 也帶來了一些速度上的提升,並提供更簡潔的設定,開箱即支援 ESM、TS 與 JSX。其中 ESM 是最大的吸引力,因為我們的應用程式即使只用到少數幾個元件,也會把整個元件庫都打包進去。透過 ESM 與 tree-shaking,就能在最終的應用程式 bundle 中移除未使用的 JS,解決這個問題。

遷移過程

函式庫模式與 ESM

首先,我們新增了一個基本的 vite.config.ts 檔案來啟用 Vite 的函式庫模式。我們沒有保留原有的模組結構,而是將每個 index.tsindex.js 檔案視為一個進入點(參見 Rollup 文件)。這樣就能為函式庫中的每個套件或資料夾建立獨立的進入點。我們將 Vite 設定為同時輸出 CommonJS(cjs)與 ES module(es)兩種格式,並將檔名設為 ${filename}.${format}.js。這有助於避免在部分使用端應用程式中出現 .mjs 相容性問題。

// vite.config.ts
import { defineConfig } from 'vite';
import { dirname, resolve, relative, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { globSync } from 'glob';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default defineConfig({
  build: {
    outDir: resolve(__dirname, 'dist/package'),
    lib: {
      entry: {
        'our-package-name': resolve(__dirname, 'index.ts'),
        ...globSync('src/**/index.{ts,js}').reduce((acc, file) => {
          acc[
            relative('src', file.slice(0, file.length - extname(file).length))
          ] = fileURLToPath(new URL(file, import.meta.url));
          return acc;
        }, {}),
      },
      name: 'our-package-name',
      fileName: (format, fileName) => `${fileName}.${format}.js`,
      formats: ['cjs', 'es'],
    },
  },
  ...
});

最後,我們更新了 package.json,正確設定 main 與 module 欄位,讓使用端能解析到對應的格式。

{
  "name": "our-package-name",
  "main": "dist/package/our-package-name.cjs.js",
  "module": "dist/package/our-package-name.es.js"
}

React

我們使用官方的 Vite React 外掛 來支援 JSX,並將其設定為 jsxRuntime: 'classic' 以相容於 React 17。我們也將 interop: 'auto' 設為 'auto',以確保在匯入 React 時 CommonJS 與 ESM 之間的相容性。這讓許多 仍在使用 React 17 的使用端得以維持向下相容。

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        globals: {
          react: "React",
          "react-dom": "ReactDOM",
        },
        interop: "auto",
      },
    },
  },
  plugins: [
    react({
      jsxRuntime: "classic",
    }),
  ],
});

外部依賴

為了避免將外部依賴打包進最終產物,類似於 webpack-node-externals 的做法,我們使用了 vite-plugin-externalize-deps。然而,在使用舊版 React 時,我們遇到了 radix-ui 套件解析 react/jsx-runtime 匯入的問題(參見 React issue)。為了解決這個問題,我們透過 except 選項,將這些套件選擇性地包含在建置後的函式庫中。

// vite.config.ts
import { defineConfig } from "vite";
import { externalizeDeps } from "vite-plugin-externalize-deps";

export default defineConfig({
  plugins: [
    externalizeDeps({
      except: [/radix-ui/],
    }),
  ],
});

樣式

我們的函式庫使用 SASS 與 CSS modules 來為元件設定樣式。為了讓樣式能隨元件一併包含,而不需要使用端應用程式另外匯入,我們使用 vite-plugin-lib-inject-css 為每個元件模組加入 import 陳述式。

// vite.config.ts
import { defineConfig } from "vite";
import { libInjectCss } from "vite-plugin-lib-inject-css";

export default defineConfig({
  plugins: [libInjectCss()],
});

為了避免 CSS 檔案在 tree-shaking 過程中被移除,我們也在 package.json 中加入了 sideEffects

{
  "sideEffects": ["**/*.css"]
}

可惜的是,這會導致未使用元件的 CSS 仍被包含在最終的應用程式 bundle 中,但這是在函式庫元件匯入簡易性之間的取捨。關於這個問題的複雜性,這裡有一些討論。

我們也必須將所有 SCSS 檔案重新命名為 .module.scss 後綴,因為 Vite 是藉此來判斷是否要使用 CSS modules:

find . -type f -name "*.scss" -exec sh -c 'mv "$0" "${0%.scss}.module.scss"' {} \;

型別定義

我們也為函式庫提供型別定義,並選擇使用 vite-plugin-dts,讓型別能在執行 vite build 指令時一併產生,而不需要另外呼叫 tsc

// vite.config.ts
import { defineConfig } from "vite";
import { libInjectCss } from "vite-plugin-lib-inject-css";

export default defineConfig({
  plugins: [
    dts({
      tsconfigPath: "./tsconfig.json",
    }),
  ],
});

我們也確保函式庫 package.json 檔案中的 types 欄位指向進入點的 .d.ts 檔案:

{
  "name": "our-package-name",
  "types": "dist/package/our-package-name.d.ts"
}

SVG 匯入與 Vitest

我們先前使用 Babel 外掛將 *.svg 匯入轉換為原始字串,而 Vite 只要在匯入路徑後加上 ?raw 後綴就能支援這項功能(參見 文件)。

import icon from "./icon.svg"; // before
import icon from "./icon.svg?raw"; // after

Jest 不支援這種匯入寫法,因此我們將測試遷移至 Vitest,以便在建置與單元測試之間共用設定。

遷移過程相當直接,只要將用於 mock 與 spy 的 jest 替換為 vi 即可。我們在 vite.config.ts 中加入了一些基本的測試設定,以啟用 globals、使用 jsdom,並將 radix-ui 依賴 inline 處理,以避免在函式庫建置時遇到的類似 react/jsx-runtime 解析錯誤。

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  test: {
    globals: true,
    environment: "jsdom",
    deps: {
      inline: [/radix-ui/],
    },
  },
});

清理工作

完成遷移後,我們得以清理許多不再需要的設定檔與依賴:

  • 移除直接依賴的 Babel 及其設定檔
  • 移除用於 mock 各種 window 屬性與全域變數的 Jest 設定檔
  • 簡化 Storybook 設定,因為 Vite 已開箱支援 CSS 與資源檔的匯入

成果

我們得以將這項變更作為次要版本更新推送到所有應用程式中,且對使用端沒有任何破壞性變更。

在使用該元件庫的應用程式中,我們發現 bundle 體積平均減少了 25%

我們也看到 Storybook 的啟動與建置時間有所改善。以往在開發模式下啟動 Storybook 需要約 30 秒的 Webpack 建置才能完成,現在則在 <10 秒內即可開啟(減少 66%)。完整建置 Storybook 的時間也從約 115 秒縮短至約 50 秒(減少約 57%)。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言