React 製アプリケーションのビルドシステムを webpack から Vite に移行して爆速な開発体験を手に入れよう

wakamsha
167

Vite (ヴィート)とは

Vue.js の作者である Evan You 氏が中心となって開発されているビルドツールです。

ES Modules 形式のままブラウザからインポートする Dev サーバを搭載し、ソースコードをバンドルすることなく高速で動作させるのが特徴です。もちろん npm パッケージもブラウザから読み込み可能な ES Modules 形式に変換します。プロダクションビルド時は Rollup を使ってバンドルします。

Vue.js だけでなく React、Preact、Svelte のビルドもサポートしており、GitHub トレンドの上位にも頻繁に登場していることからその注目度合いがうかがえます。

本エントリでは、 React 製アプリケーションのビルドシステムを webpack から Vite に移行するサンプルをご紹介します。

ソースコードはこちら。

まずはデモ動画を

対象アプリケーションの移行前の技術スタック

  • TypeScript
  • React + React Router
  • Emotion
  • webpack v5

非常にシンプルな SPA です。極力 JavaScript ( TypeScript ) の世界だけで完結するよう CSS は CSS-in-JS を使っています。本サンプルでは @emotion/css を使用していますが、 styled-components に読み替えていただいても大丈夫です。

また、本サンプルの技術スタックに Babel は含めていません。Microsoft の IE11 のサポート終了に関する情報が公開されるにつれてアプリケーション開発界隈でも IE11 のサポートを終了する動きが目立ってきています。よって本エントリも IE11 終了後を見据えたうえで話を進めます。

webpack でのビルド設定

比較のため、まずは webpack ( v5 ) によるビルド設定を確認しておきます。使用する npm パッケージは下記のとおり。

  • ts-loader
  • webpack
  • webpack-cli
  • webpack-dev-server
// @ts-check
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = (_env, { mode = 'development' }) => {
  const develop = mode === 'development';

  return {
    mode,
    entry: {
      app: ['./src/index.tsx'],
    },
    output: {
      path: path.resolve(__dirname, 'dist/'),
      filename: '[name].js',
      assetModuleFilename: 'assets/[name][ext]',
    },
    resolve: {
      extensions: ['.ts', '.tsx', '.js'],
      modules: [
        path.resolve(__dirname, './src'),
        path.resolve(__dirname, './node_modules'),
      ],
    },
    module: {
      rules: [
        // 1.
        {
          test: /\.tsx?$/,
          exclude: /node_modules/,
          // 1-a.
          use: [{ loader: 'ts-loader', options: { transpileOnly: develop } }],
        },
        // 2.
        {
          test: /\.(ico|svg|jpe?g|png|webp|woff)$/,
          type: 'asset/resource',
        },
      ],
    },
    optimization: {
      // 3.
      splitChunks: {
        name: 'vendor.bundle',
        // Dynamic import に関連しない node_modules から呼び出すライブラリをひとまとめにする
        chunks: ({ name }) => name === 'app',
      },
      minimizer: [
        // 4.
        new TerserPlugin({
          extractComments: {
            condition: /^\**!|@preserve|@license|@cc_on/i,
            filename: 'licenses.txt',
          },
          terserOptions: {
            output: {
              // 対象とするコメントの pattern
              comments: /^\**!|@preserve|@license|@cc_on/,
            },
          },
        }),
      ],
    },
    // 5.
    devtool: develop ? 'eval-cheap-module-source-map' : 'source-map',
    // 6.
    ...(develop
      ? {
          devServer: {
            port: 3000,
            open: true,
            hot: true,
            publicPath: '/',
            contentBase: path.join(__dirname, 'dist/'),
            historyApiFallback: {
              index: '/',
            },
          },
        }
      : {}),
  };
};
  1. ts-loader で TypeScript をトランスパイルする
    • 1-a. 型チェックはせずトランスパイルのみを行う
    • 1-b. 型チェックは tsc --noEmit コマンドを別途実行することで並列で行う
  2. 画像などアセットファイルを読み込む(いわゆる file-loader)
  3. Split Chunk(アプリケーションコードとライブラリコードを別々にバンドルする)
  4. minify 時にライブラリのライセンス用コメント部分を消さないようにする
  5. TypeScript の source maps を生成する
  6. 開発時は Dev server を起動する

Babel や CSS Modules も使わない至ってシンプルな構成です。にも関わらず結構な行数のコード量となってしまいました。プロジェクトによってはここから更に多くの設定を追加することになり、開発者は少しでも保守性を維持するべく関数を小分けにして切り出すなどの工夫を凝らすこととなるでしょう。それ自体は決して悪いことではありませんが、秘伝のタレ化してしまう印象は避けられません。せめて定型的な処理はビルドツールの方で良しなにお世話しつつ隠蔽していただきたいものです。

Vite で実現できること

webpack で行ったものの多くは Vite で再現可能です。

webpack Vite
TypeScript トランスパイル
※ 別途 ts-loader が必要

※ ただし型チェックは無し
ES Modules 向けビルド
config ファイルを TypeSciript で書く
※ 別途ts-node が必要1)Configuration Languages - webpack
Split Chunk
※ 分割する処理を自前で定義する必要がある

※ 設定無しで npm パッケージとアプリケーションコードとで分割してくれる
Minify
※ npm パッケージのライセンスコメントを残す処理を別途定義する必要がある

※ 設定無しでライセンスコメントを残してくれる
アセットファイルのインポート
※ v4.x までは file-loader が必要
dev server
※ 別途 webpack-dev-server が必要
tsconfig#compilerOptions#jsxreact-jsx サポート
※ config 設定で(擬似的に)サポート
HMR
source maps
グローバル変数注入(Define)
yarn workspace ( monorepo )
その他エコシステム 膨大なサードパーティ製プラグイン Rollup 用プラグインが流用可能

この他にも多くの機能を有しており、すでに次世代のビルドツールとして充分に現実的な候補と言えるでしょう。

そしてなにより TypeScript トランスパイルの圧倒的な速さは魅力的です。Vite は esbuild を使って TypeScript をビルドするため非常に快適な開発体験が得られます。ただし型チェックは行えないため、 tsc --noEmit コマンドを並列実行するなりして担保します。

Vite の config ファイル自体を .ts として書けるのも大きいです。これにより config ファイル作成時も型チェックや入力補完等の恩恵を充分に受けられ、更に ES Modules にも対応してるので import 句の使用も可能です。

新規参入したシステムの弱点としてエコシステムの未発達が挙げられますが、Vite は Rollup 用プラグインと互換性を持つことでこの弱点を克服しています2)ただし全てが使えるわけではないとのこと

ビルドシステムを Vite に置き換える

ようやく本題です。使用するライブラリは下記の通り。

コアライブラリである vite と React をサポートするプラグインの2つを使います。依存ライブラリが少なく済むのは長期の運用保守を考えると嬉しいですね。

config ファイルを作成する

といっても記述量は非常に少なく簡単です。というのもこれまで webpack.config に自前で記述していたものの多くがデフォルトで設定されているため、自前で書くものがあまりないのです。

import reactRefresh from '@vitejs/plugin-react-refresh';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [reactRefresh()],
  esbuild: {
    jsxInject: `import React from 'react';`,
  },
  build: {
    sourcemap: true,
  },
});

なんとたったのこれだけです。この僅かなコードだけで先程の webpack.config と同等の開発基盤が構築されます。

React のビルドとホットリロード

@vitejs/plugin-react-refresh は、React 公式ライブラリである react-refresh を Vite プラグイン用に薄くラップしたものです。これ一つ導入するだけで React 製アプリのビルドとホットリロードが実現できます。

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

新しい JSX 変換に対応する

React 17 + TypeScript 4.1 より新しい JSX の変換方式が加わったことにより、tsconfig の compilerOptions に下記を追記することで import React from 'react'; をソースコードに記述する必要がなくなりました。

{
  "compilerOptions": {
    ...
    "jsx": "react-jsx"
  }
}

しかしこれはビルドツールに tsc, webpack, Babel を用いた場合の話であって、2021年6月現在 esbuild はこの変換方式をサポートしていません3)Issue は挙がっているものの、コアメンバーがサポートに消極的な様子。
Support jsx automatic runtime · Issue #334 · evanw/esbuild
。そのためオプションを駆使してこの変換方式を擬似的にサポートさせます。

esbuild はその名の通り esbuild トランスパイルオプションを拡張できるプロパティです。Vite は esbuild を使って TypeScript をトランスパイルしますが、jsxInject を使用することで全 tsx ファイルに import 文を注入します。

export default defineConfig({
  ...
  esbuild: {
    jsxInject: `import React from 'react';`,
  },
});

これで Vite ( esbuild ) でも同等の結果を得られます。

エントリポイントとなる HTML ファイルを用意する

webpack ではアプリケーションへのエントリポイント(パス)を webpack.configentry プロパティで指定しました。Vite では HTML ファイル内に直接エントリポイントを記述します。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello Vite</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>

デフォルトでは vite.config は同じディレクトリ4)process.cwd()にある index.html を参照し、そこに記述されている <script type="module"> 要素の src パスをエントリポイントと見なして処理します。今回は /src ディレクトリ直下の index.tsx がエントリポイントとなりますので、 src="/src/index.tsx" と記述します。

index.htmlvite.config とは異なるディレクトリに配置したい場合は、 root オプションを上書きします。

export default defineConfig({
  ...
  root: '/path/to/html',
});

Dev サーバを起動する

下記のコマンドを実行すると http://localhost:3000 が起動します。

yarn vite

コマンド実行からわずか1~2秒でアプリケーションが起動してるのが分かります。

Port の設定

デフォルトの port 番号は 3000 ですが、これもオプションから上書きできます。

export default defineConfig({
  ...
  server: {
    port: 3333,
  },
});

webpack でもビルドしてみる

Vite と比較してアプリケーションが立ち上がるまで 5~6秒は要してるのが分かります。デモアプリが小規模なのでそこまで大きな差は出てませんが、それでも大規模化するにつれてこの差は大きなものとなります。

Production Build

下記のコマンドを実行するとプロダクションビルドが実行されます。

yarn vite build

デフォルトの書き出し先ディレクトリは ./dist ですが、これもオプションから上書きできます。

export default defineConfig({
  ...
  build: {
    outDir: '/path/to/outputDirectory',
  },
});

締め: 実戦投入するうえでも申し分ないクオリティ

駆け足になりましたが、webpack と比較しつつ Vite の導入について解説しました。エコシステムの成熟度合いを鑑みれば webpack に一日の長があることは否定のしようがありませんが、新規の案件であれば充分に実戦投入に足るクオリティを備えていると言って良いでしょう。

脚注

脚注
1 Configuration Languages - webpack
2 ただし全てが使えるわけではないとのこと
3 Issue は挙がっているものの、コアメンバーがサポートに消極的な様子。
Support jsx automatic runtime · Issue #334 · evanw/esbuild
4 process.cwd()