即席Babelプラグインでprocess.envをなかったことにする
Photo by Brett Jordan on Unsplash
最近、WantedlyのとあるサービスをようやくWebpack 5にすることができました。その過程で発生した問題をBabelプラグインで迂回する機会があったので紹介します。
Webpackとprocess.env
初期の代表的なモジュールバンドラーといえばBrowserifyですが、これはその名の示す通り「Node.js向けのコードをWebブラウザでも動かしたい」という動機があったと考えられます。これを実現するにはモジュールを1ファイルにまとめるだけでは済みません。つまり、場合によっては以下のような機能の互換実装をWebブラウザ向けに提供する必要があります。
- Node.jsの提供する独自のグローバル変数 (process, Buffer, setImmediateなど)
- Node.jsの提供する独自のライブラリ (zlib, path, cryptoなど)
これはBrowserifyだけでなく、Webpackでも同じだったようです。実際、WebpackでもNode.jsのグローバル変数やライブラリをモックする機能がありました。
また、 process そのものの機能だけではなく、process.envにセットされている環境変数を使いたいという要求もあります。そのためにWebpackでは EnvironmentPlugin
と DefinePlugin
という機能が提供されています。
plugins: [
// 指定した変数 (または変数の特定フィールドの参照) を別のコードで置き換える
new DefinePlugin({
__DEV__: "true",
"process.env.FOO": "\"bar\"",
}),
// `process.env.*` をビルド時の値で置き換える。中身はDefinePlugin。
new EnvironmentPlugin(["NODE_ENV", "SOMETHING_API_KEY"]),
],
特にnpmエコシステムでは慣習として process.env.NODE_ENV
を参照して振舞いを変える方法が根付いているため、Webpackでもデフォルトで NODE_ENVに対するDefinePluginを設定するようになっています。そのため以下のようなコードはWebpackでコンパイルしてブラウザ上で動作します。
// 開発時だけ警告を出す
if (process.env.NODE_ENV === "development") {
console.warn("You should fix it");
}
(最近では同様の仕組みを達成するのにpackage exportsを使う方法もあります。特にimportを出し分ける必要がある場合は便利です)
process.envが消えない問題
先ほどの条件文で、少し気を利かせて以下のように書くとどうなるでしょうか。
// 開発時だけ警告を出す
if (process.env && process.env.NODE_ENV === "development") {
console.warn("You should fix it");
}
DefinePluginは構文的にマッチしたときだけ置き換える仕組みなので、DefinePlugin適用後は以下のようなコードになります。
// 開発時だけ警告を出す
if (process.env && "production" === "development") {
console.warn("You should fix it");
}
最適化されるとだいたい以下のようになります。
// process.[[Get]]("env") の副作用がわからないので残される (Terserの設定次第?)
process.env;
Webpack 4までは、process変数をモックする機能があった (デフォルトで有効だった) ため、実際には以下のようなコードになります。
// Webpackが提供するprocessのスタブが使われる
const process = require.webpackProcessStub;
// 開発時だけ警告を出す
if (process.env && "production" === "development") {
console.warn("You should fix it");
}
そのため、 process.env
が置換されず残っても大きな問題にはなりません。
しかしWebpack 5ではこの機能は削除されてしまったため、 process.env
へのアクセスがそのまま残されてしまいます。ブラウザ環境では process
は定義されていないため、当該モジュールでstrict modeが有効であれば (現代のモジュールはたいていstrict modeが有効) 未定義変数参照となりエラーになってしまいます。
このようなコードはアプリケーションコード内で発見され対応したのですが、実際にはライブラリにも同じようなコードがあったためこちらの対応には工夫が必要でした。
即席Babelプラグインでprocess.envをなかったことにする
ライブラリ側も修正するのが望ましいですが、まずは暫定対応としてアプリケーション側でなんとか動作するようにすることを考えます。今回はBabelプラグインを書くことにしました。理由は主に以下の通りです。
- babel-loaderに任せておけばsource mapをいい感じに処理してくれる。 (自分でWebpack loaderを書くとここがつらい)
- パッケージソースに直接パッチを当てるわけではないので、postinstallなどのlifecycle scriptでゴニョゴニョするのに比べるとクリーン。
- そもそものprocess.env.ENV の処理自体が構文的なものなので、同じく構文的な解法をぶつけるのが一番自然。
仕様は以下のように決めました。
- process.env.ENV の形の式はそのまま残す。
- それ以外の process.env は {} に変換する。
- それ以外の process は {} に変換する。
こういうちょっとしたプラグイン的なものはトランスパイラの設定が面倒なので、JavaScript + JSDocで書いていくことにします。まず、お目当てのprocessを指しているかどうかは以下のように判定します。
/**
* @param {import("@babel/traverse").NodePath<import("@babel/types").Identifier>}
* @return {boolean}
*/
function isReferenceToProcess(path) {
// 名前がprocessかどうか (当たる確率が低いのでこの条件が最初)
if (path.node.name !== "process") return false;
// processが変数参照として使われているかどうか (メンバ名や束縛は除外)
if (!path.isReferencedIdentifier()) return false;
// bindingを取得して存在した場合 → グローバル変数としてのprocessではないものを参照しているのでスキップ
const binding = path.scope.getBinding(path.node.name);
if (binding) return false;
return true;
}
process というIdentifierを起点に調べることになるので、プラグイン全体の構成は以下のようになります。Identifier nodeだけ監視しています。
/**
* Replaces `process` and `process.env` with `{}`, but retains `process.env.SOMETHING` as-is.
*
* @param {typeof import("@babel/core")} babel
* @return {import("@babel/core").PluginObj}
*/
function processEnvWorkaround(babel) {
const { types: t } = babel;
return {
visitor: {
Identifier(path) {
if (isReferenceToProcess(path)) {
// ...
}
},
},
};
}
module.exports = processEnvWorkaround;
この中では地道に分岐します。nodeではなくpathを使っているので上へ上へと遡りながらマッチングしていくことができます。
if (
path.parentPath.isMemberExpression({
computed: false,
}) &&
path.parentPath.get("property").isIdentifier({ name: "env" }) &&
path.parentPath.node.object === path.node
) {
// process.env
if (
path.parentPath.parentPath.isMemberExpression() &&
path.parentPath.parentPath.node.object === path.parentPath.node
) {
// process.env.SOMETHING
} else {
// process.env but not process.env.SOMETHING
// -> {}
path.parentPath.replaceWith(t.objectExpression([]));
}
} else {
// process but not process.env
// -> {}
path.replaceWith(t.objectExpression([]));
}
processを割とちゃんと判定しているので、基本的にはうまく動いてくれそうです。が、下手に影響範囲を広げたくないので特定のファイルにだけ適用します。Babel configのoverrides + testでいけるはずですが、なぜか上手くいかなかったので今回はWebpack config側のruleを書いてメインのbabel-loaderとは別パスで処理してしまいます。
rules: [
// ...
{
// このファイルにだけ適用する
test: /react-google-recaptcha-v3.*\.js$/,
loader: "babel-loader",
options: {
// babel.config.jsやbabelrc.jsに指定した基本的な変換は別のbabel-loaderで処理する
// ので、ここでは無効化しておく
configFile: false,
babelrc: false,
plugins: [require.resolve("./path/to/my/plugin")],
},
},
// ...
],
以上で、指定したファイル中に出現する process.env を {} に置き換えることができました。