開発中は問題なく動いていた画面が、ビルドを作って実機に入れた途端に、データを取りにいかなくなることがあります。通信エラーの表示も出ません。ただ空のままです。
こういうとき、私が最初に疑うのは API の URL が空になっていないか、という点です。個人開発のアプリでも、サーバーの向き先を環境変数で切り替える構成はよく使います。向き先が undefined のまま本番に出ると、症状だけが静かに消えたように見えてしまいます。
先にお伝えしたいのは、開発サーバーで読めたことは、本番で読めることの証拠にならないということです。理由を、変換の結果を並べて確かめました。
何をどこまで確かめたか
確かめたのは Expo が使っている Babel の変換と、開発サーバーが差し込む 1 行です。バージョンは babel-preset-expo 57.0.15、@expo/metro-config 57.0.13、Node 22 です。
Metro で実際にバンドルし、Hermes で動かしたわけではありません。ソースを変換し、その結果を process.env に NODE_ENV だけが入った状態で実行する、という手元の再現です。実機で同じになるかは、最後の手順で各自の環境で確かめてください。
ここで見えたのは、開発と本番とで 環境変数の渡り方がまったく別物 だということです。
- 本番ビルド:
process.env.EXPO_PUBLIC_名前と書かれた場所が、ビルド時に文字列そのものへ置き換わります。置き換わらなかった場所には何も残りません。 - 開発サーバー: 置き換えとは別に、
EXPO_PUBLIC_で始まる変数をすべてprocess.envに差し込む 1 行が、バンドルの先頭に入ります。
つまり開発中は、置き換えが起きなくても process.env の中身を直接読めてしまうのです。
書き方ごとの結果
同じ値 https://api.example.com を渡して、書き方を変えながら開発と本番で実行しました。
| 書き方 | 開発 | 本番 |
|---|---|---|
process.env.EXPO_PUBLIC_API_URL | 読める | 読める |
const { EXPO_PUBLIC_API_URL } = process.env | 読める | 読める |
process.env["EXPO_PUBLIC_API_URL"] | 読める | 読める |
process.env[key](key は変数) | 読める | undefined |
const env = process.env; env.EXPO_PUBLIC_API_URL | 読める | undefined |
read(process.env)(関数へ丸ごと渡す) | 読める | undefined |
process.env.API_URL(接頭辞なし) | undefined | undefined |
表の太字の 3 行が、開発では動くのに本番だけ壊れる書き方です。共通点は、名前が 1 つの式の中で完結していないことでした。置き換えは process.env と名前が同じ式に並んでいるときだけ働きます。変数を経由すると、どの名前を読むのかが書いた時点で決まらず、置き換えの対象から外れます。開発で動いたことを根拠に安心してしまうのは、無理もないことだと感じています。
もう一つ、手元で意外だったことがあります。公式のドキュメントには、分割代入や角括弧の書き方は置き換えの対象にならないと読めます。ところが今回の版では、分割代入と文字列リテラルの角括弧は置き換わりました。ドキュメントの記述が古いのか、設定や版によって変わるのかまでは、私には切り分けられていません。
「通る書き方」は版で動きうるので、頼らずに直接の形へ寄せておきます。 この一文を、いまの私の線引きにしています。
自分のコードから危ない行を探す
探す対象は、process.env のあとに .名前 が続いていない行です。
# process.env の直後が「.」でない行だけを出します
grep -rnE "process\.env(\[|[^.A-Za-z_\[]|$)" src \
--include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx"試しに 7 行ほどの検証用ファイルに当てたところ、変数キー・別名・関数への受け渡しの 3 行だけが出ました。文字列リテラルの角括弧は安全なのに引っかかりますので、出てきた行を一つずつ見る前提の道具です。
直接の形に集めて、空なら起動時に止める
直す方針は、読む場所を 1 か所に集め、そこだけ直接の形で書くことです。空のときは、画面が空になる前に止めます。
// src/env.ts
// 値は「process.env.名前」と直接書いて拾います。ここがビルド時に文字列へ置き換わります。
const raw = {
EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
EXPO_PUBLIC_SENTRY_DSN: process.env.EXPO_PUBLIC_SENTRY_DSN,
} as const;
const missing = Object.entries(raw)
.filter(([, value]) => !value)
.map(([name]) => name);
if (missing.length > 0) {
throw new Error(`公開環境変数が空です: ${missing.join(', ')}`);
}
export const env = raw as Record<keyof typeof raw, string>;他のファイルは import { env } from "./env" として、env.EXPO_PUBLIC_API_URL を使います。
この形を本番設定で変換して実行すると、2 つとも値があれば通り、片方を消すと 公開環境変数が空です: EXPO_PUBLIC_SENTRY_DSN で止まりました。止まる場所が起動直後になるので、空の画面を前に原因を探す時間が減ります。ここで止めるのは、本番ビルドに値が入らなかったことを、利用者より先に自分が知るためです。
実機で確かめる最後の一手
手元の再現は変換までですので、実際のバンドルでも見ておくと安心です。ビルドの成果物は、値が文字列として入っているかを検索できます。
# 本番設定でバンドルを書き出し、API の URL が含まれているかを数えます
npx expo export --platform ios
grep -rl "https://api.example.com" dist | headここでは api.example.com を、ご自身の向き先に置き換えてください。1 件も出なければ、置き換えが起きていません。なお、この方法は値がバンドルに入る前提の確認です。入ってはいけない秘密の値は、そもそも EXPO_PUBLIC_ に載せない、という整理は EAS の secret は「アプリに入れない」設定ではありません に書き残しております。
まずは今日、手元のプロジェクトで上の grep を一度だけ実行して、出てきた行の数を確かめてみてください。0 行であれば、このまま進めて大丈夫です。