Dev Study
← アプリケーションの土台
レッスン 9 / 11·約 9 分·第7期 アプリケーションの土台

ビルドとバンドル — 書いたコードと届くコードが違う理由

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

前回、import が何を結び付けるかを見ました。今回はその「書いた単位」が配られる前にどう組み替えられるかを見ます。書いた単位と配る単位が違うのは、C のリンカと同じ形です。

まず結論

ブラウザは TypeScript も JSX も読めません。だから配る前に**変換 → 束ねる → 縮める → 内容で決まる名前を付ける**工程が挟まり、受け取るのは書いたファイルではなくその出力です。

この工程で `import.meta.env.VITE_*` のような環境変数は**文字列リテラルに置き換えられます**。置き換えはビルド時なので、後から環境変数を変えても配られた JavaScript は変わりません。

? 読む前に予測

`vite build` で作って配った後に、サーバ側の環境変数 `VITE_API_URL` を別の値に書き換えて再起動しました。ブラウザが読む JavaScript の中の値はどうなりますか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

配る前に、書いたものは別の形になる

前回、import が何を結び付けているかを見ました。扱ったのは「書いた単位」です。今回はそれがそのままブラウザに届くわけではないという話をします。

理由は2つです。

  1. ブラウザが読めない形式で書いている。 TypeScript の型注釈も JSX も、ブラウザの JavaScript エンジンには構文エラーです。
  2. 書いた単位のまま配ると数が多い。 100 個に分ければ、素朴には 100 本の取得が要ります。

だから配る前に工程が挟まります。変換(TS/JSX → JS)・束ねる(依存を取り込む)・縮める(空白や識別子を詰める)・名前を付ける(出力の内容から決まる文字列を名前に入れる)です。

もう1つ結果が伴います。ビルド時に確定できるものは値に置き換えられるのです。環境変数がそうです。「環境変数を読む式」は出力に残らず、そのとき決まっていた値の文字列だけが残ります。

具体実物

実物: dist の中を見る

import.meta.env.VITE_API_URL を1回使うだけの小さなプロジェクトをビルドした出力です。

$ VITE_API_URL=http://localhost:8080 npx vite build(vite 8.3.0 / Node v24.21.0 / macOS 実測)コマンド出力
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 kB
dist/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 で出力を探る(同じ実機)コマンド出力
$ grep -o 'http://localhost:8080' dist/assets/*.js
http://localhost:8080
$ grep -c 'import.meta.env' dist/assets/*.js
0
$ 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)でも別々に効きます。

入力2ファイルを束ねる/縮める(esbuild 0.28.2 実測、バイト)表
段階 サイズ 備考
入力 main.ts + math.ts 216 2ファイルの合計
--bundle 832 補助コードが入り増えた
--bundle --minify 322 識別子と空白を詰めた

束ねると増えています。 小さな例では挿入される補助のコードのほうが大きいためです。「バンドルすると軽くなる」は縮める工程と tree shaking の効果です。同じ実機で、どこからも呼ばれない関数 unused() は両方の出力から消えていました。

✓ ここまでの確認

型エラーのある TypeScript を `tsc` にかけました。既定の設定で JavaScript は出力されますか。

⇄ 2つを見比べる
具体例 A

Vite のビルド出力

書いたのは index.html と main.ts の2つ。出てきたのは dist/index.html と、内容由来の文字列が入った .js です。環境変数を変えて再ビルドすると名前も変わり、値を戻すと名前も戻りました。

環境変数の値を変えて3回ビルドした(vite 8.3.0 の実測)コマンド出力
VITE_API_URL=http://localhost:8080 → dist/assets/index-W_jZTbjh.js
VITE_API_URL=https://api.example.com → dist/assets/index-0j6iANU8.js
VITE_API_URL=http://localhost:8080 → dist/assets/index-W_jZTbjh.js
具体例 B

C のコンパイルとリンク

書いたのは main.c と util.c の2つ。コンパイルがそれぞれをオブジェクトファイルに変え、リンクがライブラリと合わせて1つにまとめます。実行されるのは、書いたどのファイルでもありません。

書いた単位と配る単位表
段 入力 出力
コンパイル main.c / util.c main.o / util.o
リンク main.o / util.o / lib 実行ファイル 1 つ

フロントエンドのビルドと、C のコンパイル・リンク。共通する構造は何でしょうか。そして片方にだけある「名前が内容で決まる」という性質は、何のためにあるのでしょうか。

「書いたファイル名で本番を grep しても見つからない」のはなぜか、両方で考えてみてください。

抽象原理

図解: 書いたものが dist に着くまで

ソースから配布物まで(工程名は道具で呼び方が違う)
ソースTS / JSX が多数のファイルに分かれている○ ブラウザは読めない
変換TS/JSX を JS へ。型注釈は落ちる○ 読める構文になる
束ねるimport された依存を取り込む○ ファイル数が減る
縮める空白・識別子を詰め、到達しないコードを落とす○ 転送量が減る
名前を付ける出力の内容から決まる文字列を名前に含める○ assets/index-W_jZTbjh.js
↓
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(レンダリング戦略とパフォーマンス) が対応します。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 4正解: 0

「本番で `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 は出力される。