遷移至 Vite
我們的設計系統 React 元件庫原本使用 Webpack 來建置發佈用的 JS 檔案。我們剛完成遷移至 Vite,透過支援 tree-shaking(搖樹最佳化) 帶來了開發體驗的提升,並讓最終應用程式的打包大小變得更小。
為什麼選擇 Vite?
我們當初使用 Webpack 來建置函式庫的主要原因之一,是為了讓 Storybook 開發環境與最終的函式庫建置結果保持一致。元件的變更在開發期間通常只會在 Storybook 中測試,而 Storybook 與函式庫建置結果之間的差異,可能會導致元件在應用程式中使用時出現非預期的問題。
Storybook 現已支援 Vite 作為建置工具,因此我們仍能在不同建置之間維持一致性。我們也可以使用 Vitest,在函式庫建置、Storybook 與單元測試之間共用設定。
Vite 相較於 Webpack 也帶來了一些速度上的提升,並提供更簡潔的設定,開箱即支援 ESM、TS 與 JSX。其中 ESM 是最大的吸引力,因為我們的應用程式即使只使用了少數幾個元件,也會打包整個元件庫。使用 ESM 與 tree-shaking 就能透過捨棄最終應用程式打包中未使用的 JS 來解決這個問題。
遷移
Library mode(函式庫模式)與 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 plugin 來支援 JSX,並將其設定為 jsxRuntime: 'classic' 以相容於 React 17。我們也將 interop: '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 仍被包含在最終的應用程式打包中,但這是在從函式庫匯入元件的簡便性之間所做的取捨。關於這個問題的複雜性,這裡有一些討論。
我們也必須將所有的 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 plugin 將 *.svg 匯入轉換為原始字串,但 Vite 只要在匯入時加上 ?raw 後綴就能支援這項功能(請參閱 文件)。
import icon from "./icon.svg"; // before
import icon from "./icon.svg?raw"; // afterJest 不支援這種匯入寫法,因此我們將測試遷移至 Vitest,以便在建置與單元測試之間共用設定。
遷移過程相當直接,只要將 jest 替換為 vi 來處理 mocks 與 spies 即可。我們在 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 相依套件與設定檔
- 移除用來模擬各種 window 屬性與全域變數的 jest 設定檔
- 簡化 Storybook 設定,因為 Vite 已開箱即支援 CSS 與資源匯入
成果
我們得以將這項變更作為次要版本更新,在所有應用程式中推出,且未對使用端造成任何重大變更。
我們發現,在使用該元件庫的應用程式中,平均打包大小減少了 25%。
我們也看到 Storybook 啟動與建置時間的改善。過去在開發模式下執行 Storybook 需要約 30 秒的 Webpack 建置才能完成,現在則可在不到 10 秒內開啟(減少 66%)。建置整個 Storybook 的時間現在約為 50 秒,相較於先前的約 115 秒(減少約 57%)。
隨機一篇部落格