Migrating to Vite

Alex O'Callaghan

Viteへの移行

原文は Alex O'Callaghan により に公開されました。 このブログを購読する

私たちのデザインシステムのReactコンポーネントライブラリでは、これまで配布用のJSファイルのビルドにWebpackを使用していました。今回、Viteへの移行が完了し、tree-shakingのサポートにより開発体験の向上と最終的なアプリケーションのバンドルサイズ削減を実現できました。

なぜViteなのか?

ライブラリのビルドにWebpackを採用していた主な理由の一つは、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.jsonsideEffectsも追加しました。

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

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

また、ViteがCSS Modulesを使用するかどうかを判断する方法であるため、すべてのSCSSファイルの拡張子を.module.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に置き換えました。vite.config.tsに、globalsの有効化、jsdomの使用、ライブラリのビルド時に見られたのと同様のreact/jsx-runtime解決エラーを避けるためのradix-ui依存関係のインライン化など、基本的なテスト設定を追加しました。

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

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

クリーンアップ

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

  • Babelへの直接の依存関係や設定ファイルの削除
  • さまざまなwindowプロパティやグローバルをモックしていたJestのセットアップファイルの削除
  • ViteがCSSやアセットのインポートを標準で処理するため、Storybookの設定の簡素化

成果

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

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

また、Storybookの起動時間とビルド時間も改善しました。以前は開発時にStorybookを起動するのにWebpackのビルドが完了するまで約30秒かかっていましたが、現在は10秒未満で起動します(66%削減)。Storybook全体のビルドも、以前の約115秒から約50秒に短縮されました(約57%削減)。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント