Read OSS

ソースからバンドルへ: エラーマングリング、マルチフォーマットビルド、Dev/Prod の分岐

中級

前提知識

  • 第 1〜5 回の記事
  • JavaScript バンドラー(webpack、esbuild など)の基本的な知識
  • Babel プラグインの基礎知識

ソースからバンドルへ: エラーマングリング、マルチフォーマットビルド、Dev/Prod の分岐

これまでのシリーズでは、Redux のランタイムコードと型システムを詳しく見てきました。今回はすべてのライブラリ作者が直面する問いに向き合います。あらゆる JavaScript 環境で動作するコードを、バンドルサイズを抑えつつエラーメッセージの有用性も維持しながら、どのように配布するか。Redux の答えは洗練されたビルドパイプラインです。4 つの出力フォーマットを生成し、本番環境ではエラーメッセージを数値コードに置き換え、ビルド成果物に対してフルテストスイートを実行します。

この最終回では、src/index.ts から dist/ フォルダーに至るまでの道筋をたどります。

tsup のビルド設定

Redux は esbuild をベースにしたゼロ設定バンドラー tsup を使い、単一の設定ファイルから 4 つの出力フォーマットをすべて生成します。

tsup.config.ts#L47-L96

flowchart TD
    SRC["src/index.ts"] --> BABEL["Babel mangleErrors<br/>transform"]
    BABEL --> ESBUILD["esbuild"]

    ESBUILD --> ESM["redux.mjs<br/><i>ESM + sourcemap + .d.mts</i>"]
    ESBUILD --> LEGACY["redux.legacy-esm.js<br/><i>ESM, ES2017 target</i>"]
    ESBUILD --> BROWSER["redux.browser.mjs<br/><i>ESM, minified, NODE_ENV=production</i>"]
    ESBUILD --> CJS["cjs/redux.cjs<br/><i>CommonJS</i>"]

defineConfig の返り値となる配列から、4 つのビルドターゲットが生成されます。

ターゲット フォーマット 拡張子 特記事項
Standard ESM esm .mjs .d.mts 宣言ファイルを生成、clean: true
Legacy ESM esm .js Webpack 4 向けに target: ['es2017'] を指定
Browser ESM esm .mjs process.env.NODE_ENV'production' に固定、minify あり
CommonJS cjs .cjs dist/cjs/ サブディレクトリに出力

4 つのターゲットはすべて mangleErrorsTransform esbuild プラグインを含む共通のベース設定を共有しています。ここからがビルドの最も興味深い部分です。

エラーマングリング: Babel プラグイン

Redux は React のエラーコードシステムを参考にしています。本番環境では、"Actions must be plain objects" のようなエラーメッセージが数値コードに置き換えられ、URL をたどることで完全なメッセージを参照できます。これによってバンドルサイズを大幅に削減しながら、デバッグのしやすさも確保しています。

パイプラインのしくみは次のとおりです。

tsup.config.ts#L12-L45

esbuild プラグインは onTransform を使って TypeScript ファイルを横取りし、mangleErrorsPlugin を指定して Babel に渡したあと、変換済みのコードを返します。つまり Babel は esbuild の 前段 でコードを処理します。esbuild の代替ではなく、前処理ステップとして機能しているわけです。

Babel プラグインの本体は scripts/mangleErrors.mts にあります。

scripts/mangleErrors.mts#L75-L185

flowchart TD
    A["Visit ThrowStatement nodes"] --> B{"Is it 'throw new Error(...)'?"}
    B -- No --> C["Skip"]
    B -- Yes --> D["Extract error message string<br/>via evalToString"]
    D --> E{"Already in errors.json?"}
    E -- Yes --> F["Use existing index"]
    E -- No --> G["Append to array,<br/>get new index"]
    G --> H["Set changeInArray = true"]
    F --> I{"minify mode?"}
    H --> I
    I -- Yes --> J["throw new Error(formatProdErrorMessage(N))"]
    I -- No --> K["throw new Error(<br/>process.env.NODE_ENV === 'production'<br/>? formatProdErrorMessage(N)<br/>: 'original message'<br/>)"]
    K --> L["Write updated errors.json<br/>in post() hook"]
    J --> L

evalToString ヘルパーは、エラーメッセージの AST 表現の違い——文字列リテラル、+ による連結、テンプレートリテラル——を吸収し、すべてを単純な文字列に変換してルックアップに備えます。

scripts/mangleErrors.mts#L27-L50

エラーコードのレジストリは errors.json です。数値インデックスと完全なメッセージを対応させたシンプルなオブジェクトです。

errors.json#L1-L20

18 個のエラーコードが登録されています。minify: false(Redux の現在の設定)でプラグインを実行すると、各 throw new Error("message") は次のように変換されます。

throw new Error(
  process.env.NODE_ENV === 'production'
    ? formatProdErrorMessage(7)
    : "Actions must be plain objects..."
)

また、formatProdErrorMessage は URL を生成します。

src/utils/formatProdErrorMessage.ts#L8-L13

本番環境ではユーザーに "Minified Redux error #7; visit https://redux.js.org/Errors?code=7 for the full message" というメッセージが表示されます。URL にアクセスすると、動的な値を含む完全なエラーテキストを確認できます。開発環境では、従来どおり詳細なメッセージがそのまま表示されます。

ヒント: errors.json のインデックスはビルドをまたいで安定しています。新しいエラーは末尾に追加されるだけで、インデックスの振り直しは行われません。これにより、エラーコード URL は Redux のバージョンが変わっても有効であり続けます。同様のシステムをライブラリに組み込む場合は、新しいコードを割り当てる前に既存ファイルを読み込む(83〜86 行目の処理)ことで、インデックスの変動を防ぎましょう。

Dev/Prod コード分岐のパターン

エラーマングリングは Dev/Prod 分岐の一形態にすぎません。Redux にはほかにもいくつかのパターンがあります。

kindOf ユーティリティ がその典型例です。

src/utils/kindOf.ts#L62-L70

開発環境では kindOfminiKindOf を呼び出します。これは 38 行の関数で、"date""error""WeakMap""Promise" といった詳細な型の説明を返します。本番環境では単純な typeof にフォールバックし、"object""function""string" といった基本的な型のみを返します。

flowchart TD
    A["kindOf(val)"] --> B{"NODE_ENV !== 'production'?"}
    B -- Dev --> C["miniKindOf(val)"]
    C --> D["'date', 'error', 'Map',<br/>'Promise', 'WeakSet', ..."]
    B -- Prod --> E["typeof val"]
    E --> F["'object', 'function',<br/>'string', ..."]

つまり、開発環境のエラーメッセージは "Expected the root reducer to be a function. Instead, received: 'Promise'" と表示されるのに対し、本番環境では "...received: 'object'" となります。トレードオフは明快です——開発時は豊富な診断情報を、本番では最小限のコードを。

warning ユーティリティ は、デバッガー連携のために throw-and-catch というトリックを使っています。

src/utils/warning.ts#L6-L18

console.error にログを出力した後、同じエラーを throw してすぐ catch します。一見無意味に思えますが、これはブラウザの DevTools で「すべての例外で一時停止」を有効にしたとき、警告の発生箇所でピンポイントに実行を止めるための工夫です。catch したエラーはそのまま破棄されるため、実行には影響しません。

4 つの出力フォーマットとツリーシェイキング

第 1 回で紹介したとおり、Redux は 4 つのフォーマットで配布されます。それぞれの存在理由と利用者を整理してみましょう。

フォーマット ファイル package.json フィールド 利用者
ESM dist/redux.mjs exports["."].import Vite、webpack 5+、Rollup、Node.js(ESM)
Legacy ESM dist/redux.legacy-esm.js module Webpack 4(module フィールドを参照、.js 拡張子が必要)
Browser ESM dist/redux.browser.mjs (exports に含まれない) <script type="module"> での直接読み込み
CJS dist/cjs/redux.cjs exports["."].defaultmain Node.js の require()、Jest、旧来のツール

package.jsonsideEffects: false フィールド(84 行目)がツリーシェイキングを可能にする鍵です。これはバンドラーに対し、すべての エクスポートが未使用であれば削除してよいと伝えます。このフィールドがなければ、バンドラーはモジュールのインポートがグローバルの登録などの副作用を持つ可能性があると見なし、デッドコードの除去を行えません。

flowchart TD
    A["import { compose } from 'redux'"] --> B["Bundler reads package.json"]
    B --> C["Selects redux.mjs via exports.import"]
    C --> D["Sees sideEffects: false"]
    D --> E["Tree-shakes everything except compose"]
    E --> F["Final bundle includes only<br/>compose (~16 lines)"]

Legacy ESM フォーマットは明示的に ES2017 をターゲットにしています(tsup.config.ts の 71 行目)。これにより、モダンな構文の処理が苦手な Webpack 4 でも、追加のトランスパイルなしに出力を処理できます。

ビルド成果物のテスト

Redux の最後の品質ゲートは、ソースコードだけでなく ビルド成果物 に対してフルテストスイートを実行することです。

vitest.config.mts#L1-L17

TEST_DIST 環境変数によって、redux のインポートエイリアスが切り替わります。

  • TEST_DIST なし: src/index.ts からインポート——高速で直接的、開発中に適した設定
  • TEST_DIST あり: node_modules/redux からインポート——ビルド済みパッケージを利用者と同じ目線で検証

これにより、ソースレベルのテストでは見落としがちなバグのクラスを捕捉できます。

  • ビルド変換による動作の変化(エラーマングリングなど)
  • エクスポートマップの問題(ソースではエクスポートされているのに、ビルドのエントリーポイントに含まれていないシンボル)
  • フォーマット固有の問題(CJS 相互運用の癖、.mjs 拡張子の欠落など)
  • ツリーシェイキングの破損(esbuild が削除すべきでないものを削除してしまうケース)

ヒント: ライブラリをメンテナンスしているなら、npm pack を実行し、一時ディレクトリにパッケージをインストールしてテストを走らせる CI ステップを追加しましょう。Redux の TEST_DIST アプローチは同じ目的をより少ない手順で達成しています——ぜひ参考にしてみてください。

ビルドパイプラインの全体像

一歩引いて、ソースからユーザーに届くまでの全体の流れを確認してみましょう。

flowchart TD
    A["17 source files in src/"] --> B["tsup reads tsup.config.ts"]
    B --> C["For each of 4 build targets..."]
    C --> D["esbuild-extra intercepts .ts files"]
    D --> E["Babel mangleErrors plugin<br/>replaces throw messages<br/>with conditional expressions"]
    E --> F["esbuild compiles TS → JS"]
    F --> G{"Browser target?"}
    G -- Yes --> H["NODE_ENV hardcoded,<br/>minified"]
    G -- No --> I["NODE_ENV checks preserved"]
    H --> J["dist/redux.browser.mjs"]
    I --> K["dist/redux.mjs<br/>dist/redux.legacy-esm.js<br/>dist/cjs/redux.cjs"]
    K --> L["vitest with TEST_DIST<br/>validates built output"]
    J --> L

シリーズのまとめ

6 回にわたって、Redux のコードベース全体を追ってきました。

  1. アーキテクチャ: 17 ファイル、9 つのエクスポート、意図的に最小化された API サーフェス
  2. createStore: 6 つのクロージャ変数、dispatch サイクル、デュアル Map によるリスナースナップショット
  3. combineReducers: 生成時のバリデーション、参照等価性の最適化、compose
  4. applyMiddleware: 25 行のコード、mutable dispatch クロージャトリック、ミドルウェアパイプライン
  5. 型システム: 条件型の推論、UnknownIfNonSpecific、合成可能な StoreEnhancer ジェネリクス、型レベルテスト
  6. ビルドシステム: 4 つの出力フォーマット、エラーマングリング、Dev/Prod コード分割、成果物テスト

Redux が長く影響力を持ち続ける理由は、1,575 行というサイズの小ささではなく、抽象化の精度にあります。クラスではなくクロージャ。設定ではなく関数。継承ではなくコンポジション。これらの原則は、どんなコードベースにも持ち込む価値のある考え方です。