フロントエンド開発において、.env
ファイルによる環境変数の管理は一般的ですが、多くの課題も抱えています。.env
ファイルは通常 Git の管理下に置かれないため、チーム内での共有や CI/CD パイプラインへの組み込みが煩雑になりがちです。
この記事では、その問題を解決するための新しいアプローチを提案します。具体的には、開発・本番といった環境ごとに設定ファイルを分割して作成し、Bundler(Vite, Webpack など)のモジュール解決の仕組みを利用して、実行時に適切な設定ファイルを読み込むようにします。これにより、.env
の利用をやめ、設定ファイルを安全に Git で管理し、さらに TypeScript による型の恩恵も受けられるようになります。
私たちが直面した環境差分の苦しみ
私が所属するチームでは、単一のコードベースを複数のテナントで共有するサービスを開発しており、環境差分の管理が大きな課題となっていました。
「開発」「社内確認」「本番」といった基本的な環境に加え、テナントごとの設定が掛け合わさり、管理すべき.env
ファイルの数は100枚弱にまで膨れ上がっていました。連携先のAPIエンドポイントも多岐にわたるため、一つの.env
ファイルに含まれるキーの数も最大で20個ほどになり、決して小さくはありません。
このように、そこそこ大きい.env
ファイルが大量に存在する状況は、開発チームに様々な苦痛をもたらしました。例えば、デプロイされているアプリケーションが、一体どの環境変数の組み合わせで動作しているのかを正確に把握することが困難になりました。また、ある環境変数に誤った値が設定されていることに長期間気づかず、問題の原因特定に時間がかかるケースも頻発しました。
幸いなことに、私たちのアプリケーションはすべてのコードがクライアントサイドで完結する構成へと移行し、サーバーサイドで扱うような秘匿情報を含まなくなったため、設定ファイルそのものを Git で管理する、という大胆な方針転換を決意するに至りました。
.env
管理が抱える根本的な問題点
私たちの経験から見えてきたように、.env
による管理には、大きく分けて2つの根深い問題が存在します。
Git 管理外という非効率性
秘匿情報を守るという観点から、.env
ファイルをGit管理外とすることは正しいプラクティスですが、一方で開発体験を損なう原因となります。例えば、コードベースとは別に.env
ファイルを管理する必要があるため、新しい開発者の環境構築に手間がかかります。複数人での変更時には差分(diff)が確認できず、コンフリクトの解決も困難です。さらに、CI/CD パイプラインで利用する際も、リポジトリの Secrets 機能など、コードとは別の場所で値を管理する必要があり、設定の全体像を把握しにくくなるというデメリットも生じます。
型がなく、安全性が低い
.env
ファイルの値はすべて文字列として扱われるため、API_ENDPOINT を API_ENDPIONT のように typo しても、静的解析ツールはエラーを検知してくれません。このような単純なミスが、実行時エラーや予期せぬ挙動を引き起こし、デバッグに多大な時間を要する原因となります。また、コード内で利用する際には、"true"
という文字列を真偽値のtrue
に変換したり、文字列で書かれた数値をパースしたりといった、値のチェックや型強制の処理を都度記述する必要があり、コードの冗長化を招き可読性を損ないます。
複雑な設定、例えばネストしたオブジェクトなどを管理したい場合、{"featureA":true,"timeout":500}
のような値を一度 JSON 形式の文字列にエンコードし、.env
ファイルに一行で記述しなければなりません。この方法は、設定ファイルの見通しを著しく悪化させるだけでなく、アプリケーション側で毎回デコード処理を行う必要があります。
「Bundler で吸収する」とはどういうことか
これらの問題を解決する鍵となるのが、フロントエンド開発で日常的に使われている Bundler です。Bundler は、複数の JavaScript ファイルや CSS ファイルなどを一つにまとめるツールですが、その中核的な責務の一つに「モジュール解決 (Module Resolution)」があります。
これは、import
文の from
以降に書かれた文字列(Specifier と呼ばれます)を解釈し、実際にどのファイルを読み込むべきかを特定する処理のことです。このモジュール解決のロジックを担う部分を一般に Resolver と呼びます。
今回の提案は、この Bundler の挙動に少しだけ手を加えることで、環境差分を吸収しようというものです。
例えば、以下のようなディレクトリ構造を考えます。
.
├── main.ts
└── config/
├── $NODE_ENV.ts // (1) 型定義と補完のためのダミーファイル
├── development.ts // (2) 開発環境用の設定ファイル
└── production.ts // (3) 本番環境用の設定ファイル
config/$NODE_ENV.ts
には以下のような内容を書いておきます。
config/$NODE_ENV.ts
export interface Config {
readonly apiEndpoint: string;
}
declare const config: Config;
export default config;
config/development.ts
には以下のような内容が書いてあるでしょう。
config/development.ts
import type { Config } from "./$NODE_ENV";
const config: Config = {
apiEndpoint: "https://dev.api.example.com/",
};
export default config;
そして、アプリケーションのメインファイルである main.ts
で、次のように設定ファイルをインポートします。
main.ts
import config from "./config/$NODE_ENV";
console.log(config.apiEndpoint);
これだけだと、Bundler がconfig/$NODE_ENV.ts
のimport
の解決を試みてしまい、正しい値が得られません。
ここで Bundler のプラグインを導入し、Specifier に含まれる$NODE_ENV
という部分を、process.env.NODE_ENV
の値(development
やproduction
など)に応じて置き換え、対応するファイルパス(config/development.ts
やconfig/production.ts
)に解決するように設定します。
このアプローチの優れた点は、Bundler によるモジュール解決と、TypeScript による型チェックが独立して行われることにあります。開発者がコードを書いているとき、エディタ (tsserver) は(1)のダミーファイルconfig/$NODE_ENV.ts
を参照して型のチェックや補完を行ってくれます。そして、開発サーバーを起動したり、本番用にビルドしたりする際には、Bundler が(2)や(3)の実際の設定ファイルに差し替えてくれるのです。Bundler は開発サーバーでもビルド時でも同じ Resolver を利用するため、開発から本番まで一貫した方法で環境差分を吸収できるのが大きな利点です。
ちなみに、Specifier の見た目をシェルの環境変数に似せていることで、プラグインの挙動を直感的に理解しやすくなっています。
この方法の長所と短所
このアプローチは多くのメリットをもたらしますが、一方で考慮すべきデメリットも存在します。それぞれを詳しく見ていきましょう。
長所:Git による一元管理と HMR による快適な開発体験
最大の長所は、環境差分を Git で一元管理できる点です。設定ファイルがコードベースの一部となることで、変更履歴の追跡やコードレビューが容易になります。特に、SPA やモバイルアプリのように、クライアント側に配布されるコードに真の秘匿情報が含まれるケースが少ないフロントエンド開発では、このメリットは絶大です。もちろん、万が一コミットすべきでない値がある場合は、その部分だけ従来通り環境変数から読み込むようにしたり、特定の設定ファイル自体を.gitignore
に追加したりすることで、柔軟な対応が可能です。
設定ファイルの HMR (Hot Module Replacement) が機能するという嬉しい副産物もあります。開発中に設定値を変更した際、Bundler がその変更を検知し、ブラウザをリロードすることなく即座にアプリケーションに反映してくれます。これにより、設定変更の試行錯誤を快適に行えます。
長所:型安全性による品質向上と柔軟なファイル分割
設定ファイルに型をつけられることも大きな利点です。環境ごとの設定ファイルを TypeScript で記述できるため、設定値の型がコンパイル時に保証され、typo のような単純なミスに起因するバグを未然に防げます。もし JSON や YAML で設定を書きたい場合でも、JSON Schema などを用いれば同様の検証が可能です。
数値、真偽値、オブジェクトといった文字列以外の値も直接扱えるようになります。値をわざわざ文字列に変換する必要がなくなるため、ランタイムでデコードするような冗長なコードが不要になり、コードベース全体がよりクリーンになります。
設定ファイルを複数に分割できる柔軟性も魅力です。config/$NODE_ENV
のような Specifier を複数種類用意すれば、「実行環境」と「テナント」といった異なる軸の環境差分を、それぞれ独立したファイルとして管理し、動的に組み合わせることが可能になります。
短所:型定義の二重管理
TypeScript の型チェックと補完を正しく機能させるには、config/$NODE_ENV.ts
のような、型情報だけが書かれたダミーファイルを用意する必要があります。また、各環境の設定ファイル(例:development.ts
)が、その型定義を正しく実装しているかをTypeScriptは直接チェックしてくれないため、これは開発者の注意深さに依存する形になります。将来的には、特定のフォーマットを強制する ESLint プラグインなどを実装することで、この問題は緩和できるかもしれません。
短所:学習コストとドキュメント化
この方法は広く知られたプラクティスではないため、チームメンバー全員がその仕組みを理解し、正しく利用するための丁寧なドキュメント整備が不可欠です。例えば、全開発者向けに「NODE_ENV=test npm run dev
のように環境を切り替えて開発サーバーを立ち上げる方法」を説明したり、設定ファイルを編集する人向けに「どのファイルを編集すればどの環境に影響するのか」を明記したりする必要があります。
短所:Bundler 非依存ツールとの連携
コードジェネレーターや一部のテストランナーなど、Bundler を介さずにコードを直接評価するツールを使う場合、config/$NODE_ENV
のような特殊な Specifier を解決できず、問題が生じることがあります。この対策としては、型定義用のファイルに以下のような Node.js 環境向けのフォールバック処理を記述しておく、といった方法が考えられます。
config/$NODE_ENV.ts
if (typeof window === "undefined") {
module.exports = require(`./${process.env.NODE_ENV}`);
}
実装例
Vite (Rollup) で実装する
Viteの内部では、BundlerとしてRollupが使用されています。
export function switchImport({ replacements = process.env }: SwitchImportOptions = {}): Plugin {
return {
name: "switch-import",
resolveId: {
order: "pre",
handler(source, importer, options) {
if (!source.includes("$")) return null;
const result = source.replaceAll(
/(?\/)\$(?name>[A-Z_]+)/g,
(s, name: string) => replacements[name] ?? s,
);
return this.resolve(result, importer, options);
},
},
};
}
Webpack で実装する
Webpackでも同様のプラグインを作成することで実現可能です。
class SwitchImportPlugin {
constructor(private options: SwitchImportOptions) {}
apply(resolver: any) {
const target = resolver.ensureHook(this.options.target ?? 'parsed-resolve')
resolver
.getHook(this.options.source ?? 'resolve')
.tapAsync('SwitchImportPlugin', (request, resolveContext, callback) => {
if (!request.request.includes('$')) return callback()
const replacedRequest = request.request.replace(
/(?\/)\$(\w+)(?=$|\/)/g,
(original, name) => this.options.replacements[name] || original
)
if (replacedRequest === request.request) return callback()
const newRequest = { ...request, request: replacedRequest }
return resolver.doResolve(
target,
newRequest,
null,
resolveContext,
callback
)
})
}
}
Metro (React Native) で実装する
React Nativeで使われているMetro Bundlerでは、設定ファイルでresolver.resolveRequestをカスタマイズすることで対応できます。
function withSwitchImport(config: InputConfigT, options: SwitchImportOptions) {
config.resolver!.resolveRequest = (context, moduleName, platform) => {
if (!moduleName.includes('$')) return context.resolveRequest(context, moduleName, platform);
const replacedRequest = moduleName.replace(
/(?\/)\$(\w+)(?=$|\/)/g,
(original, name) => options.replacements[name] || original
);
return context.resolveRequest(context, replacedRequest, platform);
};
}
結論
.env
による環境変数の管理は手軽ですが、プロジェクトがスケールするにつれて多くの課題が顕在化します。今回ご紹介した、Bundler のモジュール解決能力を活用するアプローチは、フロントエンドにおける環境差分の管理をより堅牢でメンテナンス性の高いものへと変革するポテンシャルを秘めています。ぜひ、あなたのプロジェクトでも試してみてはいかがでしょうか。
Views: 0