- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存
前回、import が何を結び付けるかを見ました。今回はその「書いた単位」が配られる前にどう組み替えられるかを見ます。書いた単位と配る単位が違うのは、C のリンカと同じ形です。
まず結論
ブラウザは TypeScript も JSX も読めません。だから配る前に**変換 → 束ねる → 縮める → 内容で決まる名前を付ける**工程が挟まり、受け取るのは書いたファイルではなくその出力です。
この工程で `import.meta.env.VITE_*` のような環境変数は**文字列リテラルに置き換えられます**。置き換えはビルド時なので、後から環境変数を変えても配られた JavaScript は変わりません。
`vite build` で作って配った後に、サーバ側の環境変数 `VITE_API_URL` を別の値に書き換えて再起動しました。ブラウザが読む JavaScript の中の値はどうなりますか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
配る前に、書いたものは別の形になる
前回、import が何を結び付けているかを見ました。扱ったのは「書いた単位」です。今回はそれがそのままブラウザに届くわけではないという話をします。
理由は2つです。
- ブラウザが読めない形式で書いている。 TypeScript の型注釈も JSX も、ブラウザの JavaScript エンジンには構文エラーです。
- 書いた単位のまま配ると数が多い。 100 個に分ければ、素朴には 100 本の取得が要ります。
だから配る前に工程が挟まります。変換(TS/JSX → JS)・束ねる(依存を取り込む)・縮める(空白や識別子を詰める)・名前を付ける(出力の内容から決まる文字列を名前に入れる)です。
もう1つ結果が伴います。ビルド時に確定できるものは値に置き換えられるのです。環境変数がそうです。「環境変数を読む式」は出力に残らず、そのとき決まっていた値の文字列だけが残ります。
実物: dist の中を見る
import.meta.env.VITE_API_URL を1回使うだけの小さなプロジェクトをビルドした出力です。
vite v8.3.0 building client environment for production...transforming...✓ 4 modules transformed.rendering chunks...computing gzip size...dist/index.html 0.20 kB │ gzip: 0.16 kBdist/assets/index-W_jZTbjh.js 0.82 kB │ gzip: 0.47 kB✓ built in 38ms
書いたのは index.html と main.ts なのに、出てきたのは assets/index-W_jZTbjh.js。名前が変わっています。 中身はどうなったか。
$ grep -o 'http://localhost:8080' dist/assets/*.jshttp://localhost:8080$ grep -c 'import.meta.env' dist/assets/*.js0$ grep -o 'process.env[.A-Z_]*' dist/assets/*.js(exit=1) ← 一致なし
import.meta.env は1件も残っていません。代わりに、そのとき環境変数に入っていた文字列が出力の中にあります。Vite の文書もそう書きます。
開発時はグローバル変数として定義、ビルド時は静的に置換 — 扱いが違うと文書自身が言っています。開発サーバで動いたことは、ビルド後の証明になりません。
`vite build` の出力に `import.meta.env` という文字列が1つも残らないのはなぜですか。
工程はそれぞれ独立している
「ビルド」と一語で呼ばれているものは、別々の道具が別々の目的でやっている複数の工程の総称です。片方が成功しても片方が何もしていないことがあります。
例が型検査と JS 出力で、同じ tsc の中にいても独立しています。
既定が false ということは、既定では型エラーがあっても JS が出るということです。実機(TypeScript 7.0.2)で const x: number = "hello"; を含むファイルをコンパイルすると、error TS2322 を報告しながら out-tsc/main.js が生成され、--noEmitOnError のときだけ生成されませんでした。
接頭辞にも同じ「道具ごと」の性質があります。VITE_ は Vite の規則であって JavaScript の規則ではありません。Next.js(16.2.9)は別の接頭辞を定めています。
対比: C のコンパイルとリンク
この構造は Web の道具に特有ではありません。C で main.c と util.c から実行ファイルを作るとき、まずコンパイルが各 .c をオブジェクトファイルに変え、次にリンクがそれらとライブラリを1つにまとめます。
書いた単位は .c が2つ、配る単位は実行ファイルが1つ。数が合っていません。 そして片方の工程だけが失敗します。型の誤りはコンパイル時、シンボル不足はリンク時。どちらの段で止まったかで直す場所が変わります。
JavaScript 側の「束ねる」も esbuild の文書が定義しています。
束ねる(Bundle)と縮める(Minify)が別項目として定義されていることに注目してください。実機(esbuild 0.28.2)でも別々に効きます。
段階 サイズ 備考入力 main.ts + math.ts 216 2ファイルの合計--bundle 832 補助コードが入り増えた--bundle --minify 322 識別子と空白を詰めた
束ねると増えています。 小さな例では挿入される補助のコードのほうが大きいためです。「バンドルすると軽くなる」は縮める工程と tree shaking の効果です。同じ実機で、どこからも呼ばれない関数 unused() は両方の出力から消えていました。
型エラーのある TypeScript を `tsc` にかけました。既定の設定で JavaScript は出力されますか。
Vite のビルド出力
書いたのは index.html と main.ts の2つ。出てきたのは dist/index.html と、内容由来の文字列が入った .js です。環境変数を変えて再ビルドすると名前も変わり、値を戻すと名前も戻りました。
VITE_API_URL=http://localhost:8080 → dist/assets/index-W_jZTbjh.jsVITE_API_URL=https://api.example.com → dist/assets/index-0j6iANU8.jsVITE_API_URL=http://localhost:8080 → dist/assets/index-W_jZTbjh.js
C のコンパイルとリンク
書いたのは main.c と util.c の2つ。コンパイルがそれぞれをオブジェクトファイルに変え、リンクがライブラリと合わせて1つにまとめます。実行されるのは、書いたどのファイルでもありません。
段 入力 出力コンパイル main.c / util.c main.o / util.oリンク main.o / util.o / lib 実行ファイル 1 つ
フロントエンドのビルドと、C のコンパイル・リンク。共通する構造は何でしょうか。そして片方にだけある「名前が内容で決まる」という性質は、何のためにあるのでしょうか。
「書いたファイル名で本番を grep しても見つからない」のはなぜか、両方で考えてみてください。
図解: 書いたものが dist に着くまで
- ・環境変数の置き換えは「変換」の段。以後は値そのものであって式ではない
- ・型検査はこの流れの外で別に走る(tsc は型エラーがあっても JS を出す)
- ・段の呼び名と担当する道具は環境で違う。ここは総称として書いている
この章のまとめ
- ブラウザは TS も JSX も読めず、書いた単位のまま配ると数が多い。だから変換 → 束ねる → 縮める → 内容で決まる名前が挟まる。
import.meta.env.VITE_*はビルド時に文字列へ静的置換される。出力に式が残らないので、後から環境変数を変えても届く値は変わらない。- 接頭辞の規則はバンドラごと(Vite は
VITE_、Next.js はNEXT_PUBLIC_)。道具の文書で確かめる。 - 「ビルド」は独立した複数工程の総称。型検査と JS 出力は別で、
noEmitOnErrorの既定は false。 - 束ねることと小さくすることは別工程。小さな例では束ねるとむしろ増えた(216 → 832 → 322 バイト)。
- 名前が内容で決まると名前と内容が一対一になり、
Cache-Control: max-age=31536000, immutableという長い鮮度を与えられる。 - 次回はこの出力を動かす側 — Node.js が1スレッドで「待つ」をどう扱うかを見ます。各段を掘るなら既存クイズの
プログラムが動くまで(ビルド)群(コンパイラI(フロントエンド:字句・構文・意味解析)、静的リンク(シンボル解決と再配置))とフロントエンド VI(レンダリング戦略とパフォーマンス)が対応します。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
「本番で `process.env.API_URL` が undefined になる。開発では動いていた」という報告に対し、最初に確かめるべきことはどれですか。
用語集(5語)
- バンドル
- import された依存をファイル自身の中に取り込むこと。esbuild の文書は「To bundle a file means to inline any imported dependencies into the file itself」と定義する。
- minify(縮小)
- 生成コードを整形せずに小さく出力すること。バンドルとは別の工程。
- tree shaking
- 到達しないコードを取り除く最適化。esbuild の文書は「the term the JavaScript community uses for dead code elimination」と説明する。
- ビルド時の静的置換
- ソース中の式を、ビルド時点で確定した値のリテラルに書き換えること。`import.meta.env.VITE_*` や `process.env.NEXT_PUBLIC_*` がこれ。
- noEmitOnError
- TypeScript のオプション。既定は false で、型エラーがあっても JS は出力される。
出典(一次ソース)
- Vite — Env Variables and Modes ↗
- Vite — Building for Production ↗
- Next.js — Environment Variables(Next.js 16.2.9 同梱文書 01-app/02-guides/environment-variables.md) ↗
- esbuild — API (Bundle / Minify / Tree shaking) ↗
- TypeScript — TSConfig Reference: noEmitOnError ↗
- RFC 8246 — HTTP Immutable Responses ↗
- RFC 9111 — HTTP Caching ↗