この記事は「はてなエンジニア Advent Calendar 2023」の12月16日の記事です。先日は何故僕たちは React のコンポーネントを分割するのかでした。
皆さんはフロントエンドで使う各種HTMLやJS, CSSをビルドツールで加工したり、バンドルしたりしていますか?
私は去年ぐらいから、Viteを使ってCSSを組み立て、開発サーバーを立ててライブリロードを実現しています。
この記事では、Viteをカスタマイズする手段としてViteのプラグインの作り方と、便利に使っている自作プラグインを解説します。
Viteのプラグインを使うことで、バンドル前にコードに変更を加えたり、開発サーバーが立ち上がる時に何らかの処理を挟み込むことができます。
ViteとRollupについて
Viteはバンドラー、兼、開発サーバーを立てるアプリケーションで、RollupはViteが内部的に使っているバンドラーです。したがって、ViteのプラグインはRollupのプラグインにVite用のオプションが追加された形式になっています。Viteのプラグインの作り方のドキュメントに当たっても、まずはRollupのプラグインのドキュメントを読んでみましょうとの旨が書かれていますね。
Rollupプラグインの作り方
Rollupプラグインは、プラグインのネームやバージョンを表すPropertiesと、ビルドの各段で呼び出される関数であるHooksを一つ以上含むオブジェクトとして記述します。
今回は、一番お手軽にvite.config.jsにプラグインオブジェクトを直書きして、それをViteに読み込ませます。次の例は、ビルドの開始時に設定ファイルの内容をログに書き出し、ビルド終了時にビルド完了とログを書き出すものです。
import { defineConfig, Plugin } from "vite";
export default defineConfig(({ command }) => {
  return {
    manifest: true,
    plugins: [logConfig()],
  };
});
function logConfig(): Plugin {
  return {
    name: "vite-plugin-log-config",
    // Rollupに元々あるHooks
    // Viteの設定を受け取って設定を返す
    // 設定を変更する時に使う
    async options(inputOption) {
      // Plugin Contextの一つ
      // infoログを出す
      // ref: https://rollupjs.org/plugin-development/#plugin-context
      this.info?.(JSON.stringify(inputOption));
      return inputOption;
    },
    // Rollupに元々あるHooks
    // ビルドの一番最後に呼ばれる
    buildEnd() {
      this.info?.("ビルド完了");
    },
  };
}
package.json
```package.json
{
  "name": "untitled",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "vite build"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "typescript": "^5.3.3",
    "vite": "^5.0.10"
  }
}
``` 
ディレクトリ構造
```terminal
.
├── dist
│   └── index.html
├── index.html
├── package-lock.json
├── package.json
└── vite.config.ts
``` 
~/I/untitled ❯❯❯ npm run build
> untitled@1.0.0 build
> vite build
The CJS build of Vite's Node API is deprecated. See https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.
vite v5.0.10 building for production...
(vite-plugin-log-config plugin) {"preserveEntrySignatures":false,"cache":false,"input":"/Users/nanimono_demonai/IdeaProjects/untitled/index.html","plugins":[{"name":"vite:build-metadata"},{"name":"vite:watch-package-data"},{"name":"vite:pre-alias"},{"name":"alias"},{"name":"vite:modulepreload-polyfill"},{"name":"vite:resolve"},{"name":"vite:html-inline-proxy"},{"name":"vite:css"},{"name":"vite:esbuild"},{"name":"vite:json"},{"name":"vite:wasm-helper"},{"name":"vite:worker"},{"name":"vite:asset"},{"name":"vite-plugin-log-config"},{"name":"vite:wasm-fallback"},{"name":"vite:define"},{"name":"vite:css-post"},{"name":"vite:build-html"},{"name":"vite:worker-import-meta-url"},{"name":"vite:asset-import-meta-url"},{"name":"vite:force-systemjs-wrap-complete"},{"name":"commonjs","version":"25.0.7"},{"name":"vite:data-uri"},{"name":"vite:dynamic-import-vars"},{"name":"vite:import-glob"},{"name":"vite:build-import-analysis"},{"name":"vite:esbuild-transpile"},{"name":"vite:terser"},{"name":"vite:reporter"},{"name":"vite:load-fallback"}]}
transforming (1) index.html(vite-plugin-log-config plugin) ビルド完了
✓ 1 modules transformed.
dist/index.html  0.13 kB │ gzip: 0.13 kB
✓ built in 24ms
なお、パッケージとして公開するときには、プラグインが守るべき各種規約もドキュメントにされているので確認してください。
プラグインの書き方詳細
Plugin Object
前項で示したとおり、Vite,Rollupのプラグインはプラグインオブジェクト、つまり「所定のPropertiesとHooksを複数もつオブジェクト」をViteのpluginsの配列の値に加えてやることでViteの実行時に呼び出されます。オブジェクトをそのまま渡しても動きますが、プラグインオブジェクトを返すファクトリ関数とすることが慣習です。
Vite/Rollup プラグインは、実際のプラグインオブジェクトを返すファクトリ関数として作成するのが一般的です。この関数はユーザーがプラグインの動作をカスタマイズするためのオプションを受け付けます。
https://ja.vitejs.dev/guide/api-plugin.html#configureserver
Hooks
Hooksはビルドの各段で呼び出される関数で複数種類あります。Hooksの種類ごとに満たすべき引数と戻り値が指定されていて、引数に基づいてHooksの中でビルドの情報に関するログを吐き出したり、処理を行なったり、はたまた戻り値を変えることでビルドの仕方を変更することができます。
Viteのプラグインでは、Rollupに元々あるものに加えてVite特有のHooksが含まれます。
例えば、
- Viteの
configureServer Hooksを指定することで、開発サーバーの立ち上げに必要な設定を行う - Rollupの
options Hooksを指定することで、追加のビルド対象を設定する - Rollupの
buildEnd Hooksを指定することで、ビルドできた結果を特定の位置に配置する 
などができます。
Hooksの一覧は次のドキュメントに一覧されています。なおHooksの種類によって「非同期関数が書けるか?」「複数のプラグインが同じHooksを実装している時、それは逐次的に実行されるか、並列に実行されるか?」が違います。
またHooksではthisコンテキスト経由で後述するPlugin Contextが使えます。
Plugin Context
Hooksではthis.info()やthis.error()などプラグインからログを吐き出したり、ビルドを例外終了させたりする関数をthisコンテキスト経由で呼び出せます。ログの他にもthis.load()など、ビルド対象を追加することもできるようです。
前項の例では、this.info()を使ってプラグインからログを吐き出しています。
便利に使っている自作プラグインの例
次の記事で紹介したように、CSSのビルドをViteでやっているので、その結果を操作するViteのプラグインを紹介します。
ビルド後にdigest.jsonに基づいて、ビルド結果をどこかにコピーする関数
ビルド後ならば元のファイルと出力ファイルの対応表のmanifest.jsonがあるので、manifest.jsonを見てファイルを移動することなどもできます。
/**
 * Viteのビルド完了後、manifest.jsonの内容に沿ってビルドされた特定のアセットを特定の位置にコピーする
 */
function copyAssets(): Plugin {
  return {
    name: 'copy-assets',
    async closeBundle() {
      // ビルド後ならば`manifest.json`があるから利用する
      // 動的にrequireをしているから、jsonから型情報を読み取れないので、明示する
      const manifest: Record<
        string,
        {
          file: string;
          isEntry: boolean;
          src: string;
        }
      > = require(`./${outDir/* 出力ディレクトリ */}/manifest.json`);
      const matcher = new RegExp(
          //何らかの正規表現
          `^${lessSource}\/(?<path>.*)\.less`
      );
      await Promise.all(
        Object.entries(manifest).map(async ([key, entry]) => {
          const matches = key.match(matcher);
          if (!matches?.groups?.path) return;
          const targetPath = `static/css/${matches.groups.path}.css`;
          await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
          await fs.promises.copyFile(`${outDir}/${entry.file}`, targetPath);
          this.info?.(`copied asset: ${entry.file} to ${targetPath}`);
        }),
      );
    },
  };
}
開発サーバとビルド時にスタイルシートの変数違いの複数バージョンを設定する
具体的なコードは省きますが、options Hooksを使うことで実現できます。アイデアとしては、一時ファイルをプラグインで作成してしまい、それをビルド対象に追加することです。ビルド対象に追加することで、開発サーバーでは読み込むURLを変えることで、変数違いのスタイルシートでもライブリロードが効かせられるという利点があります。スタイルシートがライブリロードできる開発サーバーの建て方はこちら。
プラグインでビルド対象を追加するにあたっては、ビルド対象を指定するinput オプションは文字列型や配列が入るので、型次第で追加の仕方を変える必要があります。実装例をスケッチに書いておきました。
スケッチ
/**
 * Viteのビルド開始前に、フィーチャーフラグ用の変数を付与した`.less`ファイルを作成し、そのファイルをビルド対象にする
 * @param featureFlagDir フィーチャーフラグつきのlessの一時置き場
 */
function featureFlags(featureFlagDir: string): Plugin {
  return {
    name: "feature-flags",
    async options(inputOption) {
      // Implement Me 前のビルドで作っていたフラグつきのlessを消す
      /**
       * フラグを受け取ってそのフラグが定義されたlessを一時ファイルに保存する関数
       */
      const flagsToLess = async ({
        sourceLessPath,
        targetLessPath,
        flags,
      }: {
        sourceLessPath: string;
        targetLessPath: string;
        flags: string[];
      }) => {
        const flagVariantsLess = flags.map(
          (e) =>
            // language=less
            `@${e}: true;`,
        );
        const styleSource = [
          ...flagVariantsLess,
          // language=less
          `@import '${sourceLessPath}';`,
        ].join("\n");
        // 次のようなファイルが出来上がる
        /*
         * @FEATURE_FLAG_A: true;
         * @FEATURE_FLAG_B: true;
         *
         * @import index.less;
         */
        await fs.promises.mkdir(path.dirname(targetLessPath), {
          recursive: true,
        });
        // 一時ファイルを書き出す
        await fs.promises.writeFile(targetLessPath, styleSource);
        // Viteのdevサーバー実行時にはinfoが入っていない
        this.info?.(`${sourceLessPath} to ${targetLessPath} is activated`);
      };
      // Implement me 欲しい種類だけ作る
      flagsToLess({
        sourceLessPath: "less/index.less",
        targetLessPath: `${featureFlagDir}/フラグセットX/index.less`,
        flags: ["FEATURE_FLAG_A", "FEATURE_FLAG_B"],
      });
      const { input } = inputOption;
      // inputオプションの型次第で、適合するようにビルド対象を追加する
      const flaggedStyles = ["less/index.less"];
      if (typeof input == "string") {
        inputOption.input = [input, ...flaggedStyles];
      } else if (Array.isArray(input)) {
        inputOption.input = [...input, ...flaggedStyles];
      } else if (input && typeof input == "object") {
        flaggedStyles.map((e) => {
          input[e] = e;
        });
        inputOption.input = input;
      }
      return inputOption;
    },
  };
}