Migrating to Vite

Alex O'Callaghan

Viteへの移行

デザインシステムのReactコンポーネントライブラリでは、配布するJSファイルのビルドにWebpackを使用していました。今回、Viteへの移行を完了しました。これにより開発体験が改善され、tree-shakingのサポートによって最終アプリケーションのバンドルサイズも小さくなりました。

なぜViteなのか

ライブラリのビルドにWebpackを使用していた主な理由の1つは、Storybookの開発環境と最終的なライブラリビルドとの一貫性を保つためでした。開発中、コンポーネントの変更はStorybookだけでテストされることがよくあります。Storybookとライブラリのビルドに違いがあると、アプリケーションでコンポーネントを使用した際に予期しない問題につながることがあります。

Storybookは現在、ビルダーとしてViteをサポートしています。そのため、ビルド間で同じ一貫性を保てるようになりました。また、Vitestを使えば、ライブラリビルド、Storybook、ユニットテストで設定を共有できます。

ViteはWebpackよりも高速で、ESM、TS、JSXを標準でサポートしているため、設定もよりシンプルです。特に魅力的だったのはESMでした。アプリケーションでは数個のコンポーネントしか使っていない場合でも、コンポーネントライブラリ全体をバンドルしていました。ESMとtree-shakingを使えば、未使用のJSを最終アプリケーションバンドルから除外して、この問題を解決できます。

移行

ライブラリモードとESM

まず、Viteのライブラリモードを有効にするため、基本的なvite.config.tsファイルを追加しました。モジュール構造をそのまま維持するのではなく、各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

JSXをサポートするため、公式のVite Reactプラグインを使用し、React 17との互換性のためにjsxRuntime: 'classic'を設定しました。Reactをインポートする際にCommonJSとESMの互換性を確保するため、interop: 'auto'も設定しました。これにより、現在も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()],
});

tree-shaking中にCSSファイルが削除されないよう、package.jsonにもsideEffectsを追加しました。

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

残念ながら、この方法では未使用コンポーネントのCSSも最終アプリケーションバンドルに含まれてしまいます。しかし、ライブラリからコンポーネントをシンプルにインポートできることとのトレードオフでした。この問題の複雑さについてはこちらで議論されています。

また、ViteはCSS Modulesを使用するかどうかを.module.scssサフィックスで判断するため、すべてのSCSSファイルをこのサフィックスに変更する必要がありました。

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

型定義

ライブラリ用の型定義も提供しているため、tscを別途呼び出すのではなく、vite buildコマンド中に生成されるよう、vite-plugin-dtsを使用することにしました。

// 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へ移行しました。

移行は比較的簡単で、モックとスパイではjestviに置き換えました。globalsを有効にし、jsdomを使用するとともに、ライブラリビルド時に見られたものと同様のreact/jsx-runtime解決エラーを回避するためにradix-ui依存関係をインライン化する、基本的なテスト設定をvite.config.tsに追加しました。

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

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

クリーンアップ

移行後、不要になった設定ファイルや依存関係をいくつか整理できました。

  • 直接指定していたBabel依存関係と設定ファイルの削除
  • 各種windowプロパティとglobalsをモックしていたjestセットアップファイルの削除
  • ViteがCSSとアセットのインポートを標準で処理するため、Storybook設定を簡素化

結果

この変更は、利用者にとって破壊的変更なしにマイナーリリースとしてアプリケーション全体へ展開できました。

コンポーネントライブラリを使用するアプリケーション全体で、平均25%のバンドルサイズ削減を達成しました。

Storybookの起動時間とビルド時間も改善しました。以前は、開発環境でStorybookを実行するには約30秒かかるWebpackビルドの完了を待つ必要がありましたが、現在は10秒未満で開きます(66%削減)。Storybook全体のビルドは、以前の約115秒から約50秒になりました(約57%削減)。

原文は Alex O'Callaghan により に公開されました。

この記事は「gpt-5.6-terra」を使用して翻訳されました。