Vite を使ってアプリケーションに環境変数を参照させる方法を考える

前置き: ビルドごとにアプリの振る舞いを変えたいことがある

アプリケーションコード(以下、アプリコード)に直接ハードコーディングせず、ビルド時に任意の環境変数を値として参照させたいケースがあります。

例えば Firebase のような SaaS を利用する際は、開発用と本番用とで別々の環境を用意することが多いでしょう。ということは開発時と本番デプロイ時とで異なる API Key をアプリコードに読み取らせねばなりません。

SaaS に限らず自前で開発するサーバにおいても開発時と本番デプロイ時とで疎通先が異なる場合は、 API の URL をビルド時にスイッチできるような仕組みが求められます。

https://dev-api.example.com/  # 開発環境用
https://stg-api.example.com/  # ステージング環境用
https://api.example.com/      # 本番環境用

ビルド時にグローバル定数を生成する

アプリコード内にハードコーディングしないのであれば、何かしらの方法で外部にグローバル定数として定義し参照できるようにすることになりますね。Vite では define というオプションを使うことでビルド時にアプリコード内から参照可能なグローバル定数を生成できます。

import { defineConfig } from 'vite';
export default defineConfig({
  // ...
  define: {
    ENV_API_KEY: JSON.stringify('KEY:XXX-XXX'),
  },
});

ビルドすると ENV_API_KEY というグローバル定数が生成され、アプリコード内から参照できます。


これでアプリケーションの振る舞いをビルド時の設定次第で変えられるようになりました。

さて、ここまでは良いのですが、開発時と本番デプロイ時とでいちいち vite.config を書き直すのは非効率ですしケアレスミスの温床となります。やはりこういった値は環境変数として vite コマンド実行時にコマンドライン引数として任意の値を渡したいところです。

Vite は コマンドライン引数から任意の環境変数を渡せない

webpack の場合ですとコマンドライン引数の --env を使って任意の環境変数を webpack.config に渡せます。

yarn webpack --env apiKey=KEY:XXX-XXX

渡した値は webpack.configmodule.exports に渡す関数の引数 env から参照できます。

module.exports = (env) => {
  // Use env.<YOUR VARIABLE> here:
  console.log(env.apiKey); //=> 'KEY:XXX-XXX'
  return {
    entry: './src/index.js',
    // ...
  };
};

コマンドラインオプションから渡した値は env というオブジェクトに渡した変数が格納され、そこから参照できるという仕組みです。非常に柔軟性に富んだ機能ですが、残念ながら Vite にはこれに相当する機能がありません。よって Vite に適合した方法で環境変数を渡すことになります。

.env ファイルで環境変数を定義する方法

Vite は、ビルド時に vite.config ファイルと同じディレクトリ1)どうやら任意のディレクトリには置けないようです。にある .env というファイルに記載されたもののうち、名前が VITE_ で始まる環境変数だけを読み込む仕様となっています。読み込まれた値は import.meta.env というグローバルオブジェクトに格納され、アプリコード内から参照できます。

VITE_API_KEY=KEY:XXX-XXX # 読み込まれる
API_KEY=KEY:YYY-YYY      # 読み込まれない
console.log(import.meta.env.VITE_API_KEY); //=> KEY:XXX-XXX
console.log(import.meta.env.API_KEY);      //=> undefined

webpack と違いコマンドライン引数でなくファイルに記述することでメンテナンス性を担保してるというわけですね。

.env を開発用と本番用とで別管理にする

.env は用途別に複数作成できます。

.env         # 全てのケースで必ず読み込まれる
.env.[mode]  # --mode オプションで指定したものだけが読み込まれる
./app
├── src
├── .env           # 共通の環境変数 
├── .env.dev       # 開発環境用
├── .env.stg       # ステージング環境用
├── .env.prod      # 本番環境用
└── vite.config.ts

これを踏まえ上記のように4種類の .env ファイルを作成したとします。 vite コマンドには --mode というオプションがあり、これに指定した値と合致するファイル名が読み込まれます。つまり vite --mode dev とコマンドを実行すると .env.env.dev の2つが読み込まれ、 vite --mode stg なら .env.env.stg が読み込まれます。

--mode オプションのデフォルト値は、開発時なら development 、本番デプロイ時なら production の二種類だけなのですが、 stg ( staging ) のような独自のモードも作れるため、利用する現場の運用に則した柔軟な設計が可能です。

デメリット: .env の数が増えるにつれ管理が煩雑化する

環境変数のパターンが dev | stg | prod と少なければ問題ないのですが、これに収まりきらなくなると運用に辛さが出てきます。

例えばスタディサプリ ENGLISH の開発現場では常に複数の機能開発案件が同時並行で行われているため、 開発用環境だけでも10個あります2)開発用以外にも QA 用、コンテンツ入稿確認用、プレリリース用など非常に多くの環境が運用されています。。これをそのまま .env ファイル運用に乗せると実に 11以上もの .env ファイルが作成されることとなり、いくらなんでも冗長が過ぎます。

とはいえ一度に疎通するのは 10ある環境のうちせいぜい1つなので、 .env.devdev1 | dev2 | ... dev10 のうちどこと疎通するのかを環境変数として記述すれば済む話でもあります。

VITE_VARIANT=dev1  # dev1 環境と疎通する。dev10 と疎通したければ `dev10` と書き換える

ひとますこれで .env.dev ファイルの大量発生は防げそうですが、疎通先を変えるたびにファイル編集せねばならないのはやはり非効率であり冗長と言わざるを得ません.env.dev が Git 管理下にあると疎通先を変えただけで差分と見なされてしまい、これまた面倒です。

そもそも扱う環境変数が .env ファイルだけで全て管理できるのであれば何も問題ありませんが、値のバリエーションが多くなるにつれコマンドライン引数から渡したくなってきます。

どうにかして webpack のようにコマンドライン引数を受け渡す方法

繰り返しになりますが、 Vite は webpack の --env のように任意の引数を受け取れません。仮に無理やり vite --apiKey KEY:XXX-XXX とコマンド実行したところで未定義の引数と見なされエラーとして処理されてしまいます。

yarn vite --apiKey KEY:XXX-XXX
/path/to/sample-project/node_modules/vite/dist/node/cli.js:431
          throw new CACError(`Unknown option \`${name.length > 1 ? `--${name}` : `-${name}`}\``);
          ^
CACError: Unknown option `--apiKey`
    at Command.checkUnknownOptions (//path/to/sample-project/node_modules/node_modules/vite/dist/node/cli.js:431:17)
    at CAC.runMatchedCommand (/path/to/sample-project/node_modules/node_modules/vite/dist/node/cli.js:629:13)
    at CAC.parse (/path/to/sample-project/node_modules/node_modules/vite/dist/node/cli.js:568:12)
    at Object.<anonymous> (/path/to/sample-project/node_modules/node_modules/vite/dist/node/cli.js:13989:5)
    at Module._compile (internal/modules/cjs/loader.js:1068:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1097:10)
    at Module.load (internal/modules/cjs/loader.js:933:32)
    at Function.Module._load (internal/modules/cjs/loader.js:774:14)
    at Module.require (internal/modules/cjs/loader.js:957:19)
    at require (internal/modules/cjs/helpers.js:88:18)
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
error Command failed.
Exit code: 1

つまり任意の引数を受け取るには、 vite コマンド以外の方法でビルドを実行させる必要があるというわけです。

コマンドライン引数を受け取り Vite を起動する Node.js スクリプトを自作する

Vite の機能の殆どは JavaScript ( or TypeScript )API として提供されており、これを駆使することで vite コマンドを使わずビルド処理を実現できます。

下記は Vite でビルドして Dev サーバを起動する処理の例です。

const { createServer } = require('vite');
(async () => {
  const server = await createServer({
    // any valid user config options, plus `mode` and `configFile`
    configFile: false,
    // 以下、 vite.config と同じ設定項目を指定
    // ※ 不要なのは省略可能
    root: __dirname,
    server: {
      port: 3000,
    },
  });
  await server.listen();
})();

createServer という API を使って Dev サーバのインスタンスを生成し、 listen() を実行すると Dev サーバが watch 状態で立ち上がります。つまり vite コマンドを実行したのと同じ結果が得られます。

configFile プロパティに vite.config ファイルへのパスを指定するとその設定内容を読み込んだ上で起動しますが、 false と指定することで読み込まなくなります。その他のプロパティは全て vite.config にて記述してたのと同じものが使えます。

スクリプトファイルを作ったら Node.js コマンドを実行します。

node ./builder

成功すると http://localhost:3000 で Dev サーバが立ち上がります。

同様に vite build コマンドを再現したければ build という API を使えば OK です(引数の型は createServer のそれと全く同じ)。

任意のコマンドライン引数を受け取る

実行するコマンドが vite から単なる Node.js コマンドになったので、任意のコマンドライン引数を受け取れるようになりました。

node ./builder apiKey=KEY:XXX-XXX

これで builder.js 内で process.argv から apiKey を参照できます。

const { createServer } = require('vite');
console.log(process.argv);
// ...
[
  '/usr/local/bin/node', // Node.jsの実行プロセスのパス
  '/path/to/builder.js', // 実行したスクリプトファイルのパス
  'apiKey=KEY:XXX-XXX',  // 渡したコマンドライン引数
];

念願のコマンドライン引数を受け取れました。が、流石にこの構造のままでは扱いにくいので、 yargs という npm パッケージを使って扱いやすい構造にパースします。

yargs - npm

yarn add -D yargs

builder.js にコマンドライン引数をパースする処理を追記します。

const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const { apiKey } = yargs(hideBin(process.argv))
  .option('apiKey', {
    type: 'string',
    describe: '疎通する API サーバのシリアルキーを指定します。',
  }).argv;
console.log(apiKey);
// ...

これで apiKey を string 型として取り出せます。この他にも number 型や Boolean 型として取り出せるだけでなく、Array 型として取り出すことも可能です。

--params foo bar baz #=> ['foo', 'bar', 'baz'] として取り出せる

これでコマンドライン引数を受け取る準備が整いました。 下記のコマンドを実行します。

node ./builder.js --apiKey KEY:XXX-XXX
KEY:XXX-XXX

無事に値を受け取れています。あとはこの値を define を使ってグローバル定数化すれば完成です。

const { createServer } = require('vite');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const { apiKey } = yargs(hideBin(process.argv))
  .option('apiKey', {
    type: 'string',
    describe: '疎通する API サーバのシリアルキーを指定します。',
  }).argv;
console.log({ apiKey });
if (!apiKey) {
  console.error('apiKey が指定されていません。');
  process.exit(1);
}
(async () => {
  const server = await createServer({
    // any valid user config options, plus `mode` and `configFile`
    configFile: false,
    // 以下、 vite.config と同じ設定項目を指定可能
    root: __dirname,
    server: {
      port: 3000,
    },
    define: {
      ENV_API_KEY: JSON.stringify(apiKey),
    },
  });
  await server.listen();
})();

これで webpack の --env と同等の機能と使い勝手を再現できました。

デメリット: Vite のシンプルさが損なわれる

せっかく vite.config でシンプルかつイージーにビルド環境を構築できていたのが、最終的に webpack.config と大差ない規模にまで膨れ上がってしまいました。これはデメリットと言わざるを得ないでしょう。また、Node.js スクリプト化したことでこれまで TypeScript でコーディングできていたのが VanillaJS になってしまったのもマイナス点といえます。 ts-node 等を導入するか tsc コマンドでのトランスパイルを挟んでから Node.js で実行することで TypeScript のまま設定を記述することも可能ではありますが、果たしてそこまでして採算に見合うかは悩ましいところです。

願わくば vite コマンド も webpack の --env のように任意の引数を受け取れるようになってもらいたいところですが、当面はこういった「運用でカバー」して凌ぐことも考慮しておいた方が良いかもしれません。

当エントリが皆様のお役に立てれば幸いです。

脚注

脚注
1 どうやら任意のディレクトリには置けないようです。
2 開発用以外にも QA 用、コンテンツ入稿確認用、プレリリース用など非常に多くの環境が運用されています。