CRXJS Vite PluginでTailwind CSSを使う注意点

Chrome拡張機能のフレームワークCRXJS Vite PluginTailwind CSSを組み合わせて使うと、Tailwindの部分だけHMRが効きません。
たとえば、
class="bg-red-50"class="bg-cyan-900"に変えて保存したけど自動で反映されない……!」
となります。

対策としてViteのバージョンを下げるという手もありますが、さまざまな制約を受けるため賢明とはいえません。

リポジトリのIssue #671を漁っていたところ、Viteの設定を追加すれば解決できると判明しました。
本記事では、Issueにコメントされていた設定を改変した解決策を解説します。

しくみ

Tailwind CSSに関係するCSSファイルのタイプスタンプを更新することで、HMRを発火させます。これをViteのプラグインとして設定してあげればOKです。

プラグインのコード

まずはプラグインのコードをお見せします。今回はプロジェクトファイル直下にvite-plugin-touch-global-css.tsという名前で作りました。

先に全体を載せ、次の項目から小分けで解説します。

vite-plugin-touch-global-css.ts
import fs from "node:fs";
import type { Plugin, ViteDevServer } from "vite";
function touchFile(filePath: string): void {
const time = new Date();
fs.utimesSync(filePath, time, time);
}
type TouchGlobalCSSPluginOptions = {
cssFilePath: string;
watchMatch: RegExp;
};
export default function touchGlobalCSSPlugin({
cssFilePath,
watchMatch,
}: TouchGlobalCSSPluginOptions): Plugin {
return {
name: "touch-global-css",
configureServer(server: ViteDevServer) {
server.watcher.on("change", (path: string) => {
if (watchMatch.test(path)) {
touchFile(cssFilePath);
}
});
},
};
}

タイムスタンプの更新

touchFileがファイルのタイムスタンプを更新する関数です。

function touchFile(filePath: string): void {
const time = new Date();
fs.utimesSync(filePath, time, time);
}

プラグインのオプション

プラグインのオプションは2つあります。

type TouchGlobalCSSPluginOptions = {
cssFilePath: string;
watchMatch: RegExp;
};

cssFilePathは次のようなTailwind CSSのディレクティブを書いたファイルを指定します。

例: src/content/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

プラグインのオプションwatchMatchは「保存したらHMRを発火させたいパスの正規表現」にしました。
元のIssueはディレクトリではなくファイルのリストを扱っていましたが、筆者は変更しました。これで「srcディレクトリ以下のファイル」のように指定できます。

プラグインとして返す

最後のtouchGlobalCSSPluginが実際にプラグインとして呼ばれるコードです。
ファイルを監視して、変更ファイルが「watchMatchで指定したパス」とマッチしたらcssFilePathのタイムスタンプを更新します。

export default function touchGlobalCSSPlugin({
cssFilePath,
watchMatch,
}: TouchGlobalCSSPluginOptions): Plugin {
return {
name: "touch-global-css",
configureServer(server: ViteDevServer) {
server.watcher.on("change", (path: string) => {
if (watchMatch.test(path)) {
touchFile(cssFilePath);
}
});
},
};
}

Viteプラグインの書き方をもっと詳しく知りたい方は公式ドキュメントを読みましょう。

vite.config.ts

後はvite.config.tsでプラグインを使うように書き換えるだけです。次の例ではReactを使っていますが、他の場合でもやり方自体は同じです。

vite.config.ts
import path from "node:path";
import { crx } from "@crxjs/vite-plugin";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import manifest from "./src/manifest";
import viteTouchGlobalCss from "./vite-plugin-touch-global-css";
export default defineConfig({
plugins: [
react(),
crx({ manifest }),
viteTouchGlobalCss({
cssFilePath: path.resolve(__dirname, "src/content/index.css"),
watchMatch: /src/,
}),
],
});

ファイルの監視はsrcディレクトリ以下を対象にしました。

他の対応も列挙

Issueには他の方法も挙がっていましたので一応紹介します。筆者としては上記のTouchプラグインでの対応を推奨します。

Viteのバージョンを落とす

Viteを3まで落として使う方法があります。ただ他のViteのプラグインの関係でバージョンを落とすのが大変面倒くさくなってしまうケースが多いです。

Tailwindのsafelistを活用する

Tailwind CSSの設定のsafelistに書いたクラスは、実際にそのクラスを使っていないくてもCSSに含まれます。このしくみをdevelopmentモードのときだけ活用する解決方法です。

tailwind.config.js
// ...省略
safelist: process.env.NODE_ENV === 'development' ? [{ pattern: /./ }] : [],

ただこれだとすべてのクラスを含んでしまうため、次のようによく使う特定のクラスのみsafelistに突っ込む方法もあります。

tailwind.config.js
// ...省略
safelist: process.env.NODE_ENV === 'development' ?
[
{
// colors
pattern: /\b((bg|text)-[\w-]+)/,
},
{
// padding and margin
pattern: /\b((p|px|py|my|mx|spacing-x|spacing-y)-\d+)/,
},
{
// width, high, and size
pattern: /\b((w|h|s)-\d+)/,
},
] : [],

最近はChrome拡張機能のフレームワークとしてwxtextension.jsもありますが、まだまだCRXJS Vite Pluginも現役ですね。
本当は本体のコードを読んでPull Requestを送るのが一番ですが、メンテナンスが止まっているっぽいので「CRXJS x Tailwind民」はしばらくこの対応だけで落ち着きそうです。

ちなみに筆者は、この対応で初めてViteのプラグインを書きました。意外と短いコードだけでも書けるんですね。