遷移至 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.ts 或 index.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"; // afterJest 不支援這種匯入寫法,因此我們將測試遷移至 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%)。
隨機一篇部落格
留言
登入後參與討論