Viteで使うRollupプラグインの作り方と便利に使っている自作プラグインの解説

この記事は「[はてなエンジニア Advent Calendar 2023](https://qiita.com/advent-calendar/2023/hatena)」の12月16日の記事です。先日は[何故僕たちは React のコンポーネントを分割するのか](https://rokoucha.net/何故僕たちは%20React%20のコンポーネントを分割するのか)でした。

皆さんはフロントエンドで使う各種HTMLやJS, CSSをビルドツールで加工したり、バンドルしたりしていますか？

私は去年ぐらいから、[Vite](https://ja.vitejs.dev)を使ってCSSを組み立て、開発サーバーを立ててライブリロードを実現しています。

https://qiita.com/NanimonoDaemon/items/920e56b2e995396aac0e

この記事では、Viteをカスタマイズする手段としてViteのプラグインの作り方と、便利に使っている自作プラグインを解説します。

Viteのプラグインを使うことで、バンドル前にコードに変更を加えたり、開発サーバーが立ち上がる時に何らかの処理を挟み込むことができます。

# ViteとRollupについて

Viteはバンドラー、兼、開発サーバーを立てるアプリケーションで、[Rollup](https://rollupjs.org)はViteが内部的に使っているバンドラーです。したがって、ViteのプラグインはRollupのプラグインにVite用のオプションが追加された形式になっています。Viteのプラグインの作り方のドキュメントに当たっても、まずはRollupのプラグインのドキュメントを読んでみましょうとの旨が書かれていますね。

* [プラグイン API | Vite](https://ja.vitejs.dev/guide/api-plugin.html)
* [Plugin Development | Rollup](https://rollupjs.org/plugin-development/)

# Rollupプラグインの作り方

Rollupプラグインは、プラグインのネームやバージョンを表す`Properties`と、ビルドの各段で呼び出される関数である`Hooks`を一つ以上含むオブジェクトとして記述します。

今回は、一番お手軽に`vite.config.js`にプラグインオブジェクトを直書きして、それをViteに読み込ませます。次の例は、ビルドの開始時に設定ファイルの内容をログに書き出し、ビルド終了時に`ビルド完了`とログを書き出すものです。

```ts:vite.config.ts
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?.("ビルド完了");
    },
  };
}
```

<details>

<summary>package.json</summary>

    ```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"
      }
    }
    ```　
</details>

<details>
<summary>ディレクトリ構造</summary>
<div>
    
    ```terminal
    .
    ├── dist
    │   └── index.html
    ├── index.html
    ├── package-lock.json
    ├── package.json
    └── vite.config.ts
    ```　

</div>
</details>

```teminal
~/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
```


なお、パッケージとして公開するときには、プラグインが守るべき各種規約もドキュメントにされているので確認してください。

* [プラグイン API#規約 | Vite](https://ja.vitejs.dev/guide/api-plugin.html#規約)
* [Plugin Development#Conventions | Rollup](https://rollupjs.org/plugin-development/#conventions)

## プラグインの書き方詳細

### 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`を実装している時、それは逐次的に実行されるか、並列に実行されるか？」が違います。

* [プラグイン API#vite-特有のフック | Vite](https://ja.vitejs.dev/guide/api-plugin.html#vite-特有のフック)
* [Plugin Development#Output Generation Hooks | Rollup](https://rollupjs.org/plugin-development/#output-generation-hooks)


また`Hooks`では`thisコンテキスト`経由で後述する`Plugin Context`が使えます。

### Plugin Context

`Hooks`では`this.info()`や`this.error()`などプラグインからログを吐き出したり、ビルドを例外終了させたりする関数を`thisコンテキスト`経由で呼び出せます。ログの他にも`this.load()`など、ビルド対象を追加することもできるようです。

前項の例では、`this.info()`を使ってプラグインからログを吐き出しています。

* [Plugin Development#Plugin Context | Rollup](https://rollupjs.org/plugin-development/#plugin-context)

# 便利に使っている自作プラグインの例

次の記事で紹介したように、CSSのビルドをViteでやっているので、その結果を操作するViteのプラグインを紹介します。

https://qiita.com/NanimonoDaemon/items/920e56b2e995396aac0e


## ビルド後に`digest.json`に基づいて、ビルド結果をどこかにコピーする関数

ビルド後ならば元のファイルと出力ファイルの対応表の`manifest.json`があるので、`manifest.json`を見てファイルを移動することなどもできます。

```ts
/**
 * 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を変えることで、変数違いのスタイルシートでもライブリロードが効かせられるという利点があります。[スタイルシートがライブリロードできる開発サーバーの建て方はこちら。](https://qiita.com/NanimonoDaemon/items/920e56b2e995396aac0e)

プラグインでビルド対象を追加するにあたっては、ビルド対象を指定する`input オプション`は文字列型や配列が入るので、型次第で追加の仕方を変える必要があります。実装例をスケッチに書いておきました。

<details>
<summary>スケッチ</summary>
<div>
    
```ts
/**
 * 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;
    },
  };
}
```

</div>
</details>