webpack と ESLint やめて Vite と Biome にした話

拙作の React 製 Web アプリケーションのバンドラーを webpack から Vite へ、リンター・フォーマッターを ESLint / Prettier から Biome へ変更した話をしようと思います。結構荒っぽいやり方ではありますが、どなたかの参考になれば幸いです。このアプリケーションは、記事執筆時点で 56 Stars 頂いており、 3 人の方がアプリケーションの翻訳に貢献してくださっています。ありがとうございます。

どうして移行しようと思ったのか

まずバンドラーの移行以前に、依存関係のアップデートを行いたかったというのが最初にあったのですが、開発に使うパッケージ (devDependencies) で deprecated なパッケージが増えて代替を探さなければいけなかったり、メジャーバージョンが上がって破壊的変更マシマシになったりと、かな〜り面倒そうだったので、この際 devDependencies を全部見直そうという事で、移行を決断しました。バンドラーを webpack から Vite に、リンター・フォーマッターを ESLint / Prettier から Biome に移行しました。

webpack に比べて Vite はビルド時間が短いのは散々言われていることですが、それ以外にも、 webpack ではプラグインを入れないと使えない機能が Vite ではビルトインになっていたり、設定もシンプルになっています。依存関係が大量にあると、依存パッケージの破壊的変更を調べる数が増えるなど色々面倒なので、インストールするプラグインが減るのは良いことです。

また Vite は最初から TypeScript をサポートしています。 webpack では ts-node を使用し、型定義も別途インストールしないと TypeScript で設定を書けないですが、 Vite は何もすることなく TypeScript で設定を書けますし、型定義も同梱されています(tsconfig.json の設定は少し必要になりますが)。 TypeScript で書かれたソースコードを JavaScript へトランスパイルする設定は当たり前のように書く必要がありません。嬉しいね

ESLint と Prettier から Biome に移行したのも同じ理由で、動作が高速であり、プラグインをごちゃごちゃ入れなくても済むためです。また、リンターとフォーマッターが 1 つのツールで済むので、設定の混乱が起きないのもポイントです。 .editorconfig ファイルを読み込み、インデント設定してくれますし、 .gitignore ファイルの内容も考慮し、チェックやフォーマットを無視してくれます。モダンだねぇ

移行手順

devDependencies を総とっかえします。アプリケーションのソースファイルは基本的に操作しませんが、 webpack 特有の部分は変更する必要があります。

別ブランチを切って、ちょくちょくコミットしながら作業しましょう。

devDependencies を別ファイルに控える

かなり手荒ですが、 package.jsondevDependencies メンバーの内容を別ファイルにバックアップしておいて、内容を空にします。

package.json
{
  // ...
  "devDependencies": {}
}

別ファイルにバックアップした依存関係を見ながら、必要なパッケージを取捨選択していきます。適当にメモを取りましょう。少々面倒ではありますが、必要なパッケージはリリースノートを見て、破壊的変更が無いかどうかチェックしましょう。アップグレードで移行操作が必要ならこれもメモします。

@tsconfig/strictest         OK
@types/html-webpack-plugin  DEPRECATE: Use vite
@types/jest                 DEPRECATE: Use vitest
...
eslint                      DEPRECATE: Use biome
husky                       Needs migration
...
webpack                     DEPRECATE: Use vite
...

メモを参考に、 devDependencies をインストールしていきます。

$ npm i -D vite @vitejs/plugin-react-swc vite-plugin-pwa vitest ...

設定ファイルを移行する

さてここからが本番です。各種設定を移行しましょう。

TypeScript

TypeScript の設定 tsconfig.json は、 Project Reference 機能を使用して、アプリケーション用 ./src と Vite 用の設定 ./vite.config.ts を分けます。この構成は npm create vite でプロジェクトを新規作成したときと同じものです。要件に合わせて調整します。自分は厳しめの型設定をする @tsconfig/strictest を extend しています。

tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}
tsconfig.app.json
{
  "extends": "@tsconfig/strictest/tsconfig.json",
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "esnext",
    "module": "esnext",
    "lib": ["dom", "dom.iterable", "esnext"],
    "types": ["vite/client", "vite-plugin-svgr/client"],
    "jsx": "react-jsx",
    "sourceMap": true,
    "moduleResolution": "bundler",
    "moduleDetection": "force",
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true,
    "noPropertyAccessFromIndexSignature": false,
    "noUncheckedSideEffectImports": true,
    "noEmit": true
  },
  "include": ["./src"]
}
tsconfig.node.json
{
  "extends": "@tsconfig/strictest/tsconfig.json",
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "esnext",
    "lib": ["esnext", "dom"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "moduleDetection": "force",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "noUncheckedSideEffectImports": true
  },
  "files": ["./vite.config.ts"]
}

webpack → Vite

webpack.config.{js,ts}vite.config.ts に書き直します。

vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';
import type { UserConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import svgr from 'vite-plugin-svgr';
import wasm from 'vite-plugin-wasm';
import commitHash from './plugins/commit-hash';
import yaml from './plugins/yaml';

export default {
  plugins: [
    tailwindcss(),
    react(),
    wasm(),
    yaml(),
    svgr({
      svgrOptions: { plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'] }
    }),
    commitHash(),
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        skipWaiting: true,
        clientsClaim: true,
        cleanupOutdatedCaches: true
      }
    })
  ],
  build: {
    // top-level await
    target: ['chrome89', 'edge89', 'firefox89', 'safari15', 'es2022']
  }
} satisfies UserConfig;

Vite の設定でビルドターゲットを設定しています。デフォルトでは ['chrome87', 'edge88', 'es2020', 'firefox78', 'safari14'] が設定されていますが、 WebAssembly を top-level await で読み込むため、 ['chrome89', 'edge89', 'firefox89', 'safari15', 'es2022'] を指定しています(ブラウザのシェア的にさすがに大丈夫だと思う)。要件に合わせて要調整ですが、変なことをしなければデフォルトで良いと思います。また、 polyfill プラグインもあるのでサポートするブラウザに合わせて併用してみてください。

移行したプロジェクトでは、 Tailwind CSS や SVGR などを使用しているため、 plugins 配列にプラグインを記述しています。これも要件に合わせて調整します。

webpack で使用していたプラグインを Vite に移行する際のポイントを解説します。

まずは html-webpack-plugin です。 webpack はエントリーポイントとなるスクリプトファイルを指定して、単一の JavaScript ファイルを出力するバンドラーであるため、起点となる HTML ファイルに関してはデフォルトではノータッチです。そのため、 html-webpack-plugin を使用して、 HTML ファイルもバンドル結果ディレクトリにコピーします。対して Vite のエントリーポイントは、プロジェクトルートディレクトリにある HTML ファイル index.html となります。 HTML ファイルに記載している <script> タグから、バンドルするスクリプトファイルを判断しています。 index.html も一緒にコピーされるため、 HTML ファイルをコピーするプラグインはそもそも不要になります。 <body> 要素内の一番下にスクリプトファイルを指定しましょう。

index.html
<body>
  <div id="app"></div>
  <script type="module" src="/src/index.tsx"></script>
</body>

続いて copy-webpack-plugin です。これは Vite ではビルトイン機能となっています。プロジェクトルートの public ディレクトリの内容がすべて出力ディレクトリにコピーされます。

webpack で TypeScript でアプリケーションを記述するには、 ts-loader などが必要でしたが、 Vite は標準で TypeScript をサポートしているので、特にトランスパイルの設定は不要です。 TypeScript の設定は tsconfig.json に従います。

React を使用する場合は、 @vitejs/plugin-react もしくは @vitejs/plugin-react-swc を設定します。 SWC は Rust 製のコンパイラで、 Babel と比較して 20 倍高速、 4 コアなら 70 倍高速らしいです。本プロジェクトでは SWC 版を使用しています。

webpack では DefinePlugin を使用して、グローバルに利用できる定数を宣言できました。 Vite ではそのようなプラグインは無くはないですが、自前でサクッとプラグインを作れてしまうので作ってしまいます。本プロジェクトでは Git コミットハッシュを定義するプラグインを以下のように書き、 vite.config.ts で import して使用しています。これで COMMIT_HASH がビルド時に埋め込まれます。

plugins/commit-hash.ts
import { execFileSync } from 'node:child_process';
import type { Plugin } from 'vite';

const plugin = (): Plugin => ({
  name: 'commit-hash',
  config: () => ({
    define: {
      COMMIT_HASH: JSON.stringify(
        execFileSync('git', ['rev-parse', 'HEAD'], {
          encoding: 'utf-8'
        }).trim()
      )
    }
  })
});
export default plugin;
declare global {
  const COMMIT_HASH: string;
}

webpack では YAML ファイルを import するときに yaml-loader を使用していました。 Vite ではプラグインを自分で書いて実現しています。これも簡単です。 transform 関数の戻り値に code を指定できます。ここで、 js-yaml でパースした YAML ファイルの内容を返すだけです。これで Vite でも YAML ファイルを import できるようになります。

plugins/yaml.ts
import { load } from 'js-yaml';
import type { Plugin } from 'vite';

const yamlFileRegex = /\.ya?ml$/;

const plugin = (): Plugin => ({
  name: 'yaml',
  transform: async (src, id) => {
    if (yamlFileRegex.test(id)) {
      const yaml = load(src, { onWarning: e => console.warn(e.toString()) });
      return {
        code: `export default ${JSON.stringify(yaml)};`,
        map: null
      };
    }
    return null;
  }
});
export default plugin;

SVG ファイルを React コンポーネントとして import できる SVGR は Vite 版のプラグイン vite-plugin-svgr があります。 @svgr/plugin-svgo も一緒にインストールして使いましょう。 SVG ファイルを React コンポーネントとして import する際は、 import パスの末尾に ?react を追加します。

import SVGLogo from './logo.svg?react';

glob import

本プロジェクトでは、ディレクトリ内にある YAML ファイルすべてを import し、オブジェクトにまとめる処理を書いています。 webpack と Vite ではやり方が異なるので解説します。

webpack では require.context() を使用して、ディレクトリ内すべてのファイルを require していました。

src/lib/i18n.ts
export const getResources = (): Resource => {
  const localesContext = require.context('../../locales');
  return localesContext.keys().reduce((acc, cur) => {
    const m = cur.match(/([^/]*)(?:\.([^.]+$))/);
    if (m === null) throw new Error(`Invalid import path: ${cur}`);
    return {
      ...acc,
      [m[1] as RegExpMatchArray[number]]: localesContext(cur).default
    };
  }, {});
};

Vite では import.meta.glob() を使用します。オプション eagerfalse の場合、 import 結果が直接格納されず、 Promise に包まれた状態になります(遅延ロード)。 true の場合は import 結果が格納されます。

src/lib/i18n.ts
export const getResources = (): Resource => {
  const yamls = import.meta.glob<true, never, ResourceLanguage>(
    ['../../locales/*.yml', '../../locales/*.yaml'],
    {
      import: 'default',
      eager: true
    }
  );
  const ret: Resource = {};
  for (const [key, value] of Object.entries(yamls)) {
    const m = key.match(/([^/]*)(?:\.([^.]+$))/);
    if (m === null) throw new Error(`Invalid import path: ${key}`);
    ret[m[1] as RegExpMatchArray[number]] = value;
  }
  return ret;
};

なんか CommonJS 的なやり方から ES Modules 的なやり方に移った感がすごい。

ESLint / Prettier → Biome

私は個人的に ESLint と Prettier の設定・プラグインを 1 つの npm パッケージに収めていました。こちらはすでに deprecate にしてあります。

このような依存関係マシマシのパッケージは必要なく、 @biomejs/biome をインストールして biome.json ファイル 1 つを書けば良くなりました。 React Hooks のルールなども recommended セットに入っています。 vcsuseEditorConfig の設定をすることで、 .gitignore.editorconfig を読みにいってくれます。細かい設定は適宜チームなどに合わせて調整してください。

biome.json
{
  "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
  "files": {
    "ignore": ["wasm", "package.json"]
  },
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "correctness": {
        "noUnusedFunctionParameters": "warn",
        "noUnusedImports": "warn",
        "noUnusedPrivateClassMembers": "warn",
        "noUnusedVariables": "warn"
      },
      "suspicious": {
        "noArrayIndexKey": "off"
      },
      "performance": {
        "noAccumulatingSpread": "off"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "useEditorconfig": true
  },
  "organizeImports": {
    "enabled": true
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "trailingCommas": "none",
      "arrowParentheses": "asNeeded"
    }
  }
}

旧設定ファイルを削除する

.eslintrc.ymlwebpack.config.{js,ts} など、もう使用しなくなった設定ファイルを削除します。結構さっぱりすると思います。

npm script の更新

npm script を webpack のものから Vite のものに変更します。 lint なども変更します。 cross-envTS_NODE_PROJECT などごちゃごちゃ書いていたものとはおさらばし、かなりスッキリしました。

packaage.json
{
  // ...
  "scripts": {
    "build": "tsc -b && vite build",
    "dev": "vite",
    "lint": "biome check",
    "lint:fix": "biome check --write"
    // ...
  }
  // ...
}

Biome でコードをフォーマットする

npm run lint を実行して、コードをチェックします。 biome.json の設定に抜けが無いか確認します。 ESLint のものからルールを変更した場合は、アプリケーションのコードを修正します。 npm run lint:fix である程度自動で修正してくれますが、自動修正不可もしくは自動修正で動作が変わってしまう可能性があるものに関しては、レビューしながら手動で修正していきます。

ビルドできるか確認する

npm run build を実行してアプリケーションをビルドし、 dist ディレクトリに正しく出力されるかどうか確認します。エラーが出た場合は確認し、 vite.config.ts を見直して修正します。

おわり

簡単にではありますが、 webpack から Vite への移行の概要を紹介しました。依存関係削減と爆速開発を体験できて非常に満足度が高いです。実際の細かい修正内容は GitHub 上の Diff を見ていただければと思います。