project-structure
リポジトリ: pmndrs/zustand 分析日: 2026-02-20
概要
zustand のプロジェクト構造を、ソースとビルド成果物の分離、サブパスエクスポート設計、CJS/ESM デュアルパッケージ戦略の観点から分析する。小規模(ソース 16 ファイル)でありながら、フレームワーク非依存コアと React バインディングの分離、ミドルウェアの独立エントリポイント化、CJS/ESM デュアルビルド + React Native 対応という多層的なパッケージング戦略を実現している。特に「ソースでは相対パスで参照し、ビルド時にパッケージ名エイリアスへ書き換える」手法と、dist/ ディレクトリをそのまま npm パッケージルートとして publish するアプローチが注目に値する。
背景にある原則
関心の層ごとにエントリポイントを分離すべき。消費者が不要な依存を引き込まないために: zustand は
vanilla(コア)、react(React バインディング)、middleware(拡張)、shallow(ユーティリティ)をそれぞれ独立したサブパスエクスポートとして公開している。React を使わないプロジェクトはzustand/vanillaだけを import でき、reactへの依存が発生しない。peerDependencies がすべて optional であることがこの設計を裏付ける(package.json:166-179)。ビルド成果物をパッケージルートとして publish すべき。消費者の import パスを簡潔に保つために: ソースの
src/からdist/へビルドし、dist/内にpackage.jsonをコピーしてnpm publishをdist/から実行する。これにより消費者はzustand/dist/vanillaではなくzustand/vanillaと書ける。publish.yml:25でworking-directory: distが指定されている。ソース内の相対パスをビルド時にパッケージ名へ書き換えるべき。サブパスエクスポート間の独立性を保つために:
src/react.tsは./vanilla.tsを相対 import しているが、ビルド後のdist/react.jsはzustand/vanillaを import する。各サブパスが独立したバンドルエントリとして機能するため、バンドラーの tree-shaking が正しく動作する(rollup.config.mjs:11-16)。環境変数のアクセス方法をビルドフォーマットに応じて切り替えるべき。CJS と ESM の実行環境差異を吸収するために: CJS ビルドでは
import.meta.env?.MODEをprocess.env.NODE_ENVに置換し、ESM ビルドではimport.meta.envを条件付きで参照する。同一ソースから両フォーマットを生成しつつ、各環境で正しく動作させている(rollup.config.mjs:55-63, 83-84)。
実例と分析
ソースツリーの階層設計
ソースは 3 層構造になっている。
src/
├── vanilla.ts # Layer 0: フレームワーク非依存コア
├── react.ts # Layer 1: React バインディング(vanilla に依存)
├── traditional.ts # Layer 1: 旧来 React バインディング
├── index.ts # Facade: vanilla + react を re-export
├── middleware/ # Extension: コアを拡張するミドルウェア群
├── middleware.ts # Barrel: ミドルウェアの re-export
├── shallow.ts # Utility Barrel: vanilla/shallow + react/shallow
├── vanilla/shallow.ts # Layer 0 Utility
└── react/shallow.ts # Layer 1 Utility依存の方向は常に上位層から下位層への一方通行。vanilla.ts は他のソースファイルを一切 import せず、react.ts は vanilla.ts のみを import する。ミドルウェアは ../vanilla.ts のみに依存する。この制約が、各サブパスを独立してビルド可能にしている。
サブパスエクスポートとビルドの 1:1 対応
package.json の exports フィールドで定義された各サブパスが、build:* スクリプトのそれぞれに対応する。
| サブパス | ビルドスクリプト | ソース | CJS 出力 | ESM 出力 |
|---|---|---|---|---|
. | build:base | src/index.ts | dist/index.js | dist/esm/index.mjs |
./vanilla | build:vanilla | src/vanilla.ts | dist/vanilla.js | dist/esm/vanilla.mjs |
./react | build:react | src/react.ts | dist/react.js | dist/esm/react.mjs |
./middleware | build:middleware | src/middleware.ts | dist/middleware.js | dist/esm/middleware.mjs |
./shallow | build:shallow | src/shallow.ts | dist/shallow.js | dist/esm/shallow.mjs |
各ビルドは Rollup の --config-* フラグで制御される。rollup.config.mjs:93-104 で args からフラグ名を抽出し、対応する src/*.ts をエントリとする。
dist ディレクトリを publish ルートにする仕組み
postbuild の copy スクリプト(package.json:85)で以下を実行する:
dist/src/*をdist/esmとdist/にコピー(型定義の配置)dist/srcとdist/testsを削除(不要ディレクトリの除去)package.json,README.md,LICENSEをdist/にコピーdist/package.jsonからprivate,devDependencies,optionalDependencies,scripts,prettierフィールドを削除
結果として dist/ がそのまま npm パッケージとして publish 可能になる。publish.yml:25 で working-directory: dist が指定されているため、npm publish は dist/package.json を参照する。
CJS/ESM デュアルビルドの詳細
Rollup の設定で、同一ソースから CJS(.js)と ESM(.mjs)の両方を生成する。
CJS 生成 (rollup.config.mjs:75-91): format: 'cjs'、import.meta.env?.MODE を process.env.NODE_ENV に一律置換。
ESM 生成 (rollup.config.mjs:47-73): format: 'esm'、出力が .js か .mjs かで import.meta.env の置換方法を切り替え。.mjs の場合は import.meta.env をそのまま活かす条件式に変換する。
型定義の二重生成: postbuild の patch-esm-ts スクリプト(package.json:87)で dist/esm/*.d.ts を .d.mts にリネームし、import パスの拡張子を .mjs に書き換える。これにより ESM の exports 条件で .d.mts が正しく解決される。
Rollup エイリアスによるパッケージ内相対パスの書き換え
rollup.config.mjs:11-16 で定義された entries 配列が、ビルド時にソース内の相対パスをパッケージ名に書き換える。
./vanilla.ts → zustand/vanilla
./react.ts → zustand/react
./vanilla/shallow.ts → zustand/vanilla/shallow
./react/shallow.ts → zustand/react/shallowただし、自分自身のエントリと一致するエイリアスは除外される(entries.filter((entry) => !entry.find.test(input)))。例えば src/react.ts をビルドする際、./vanilla.ts → zustand/vanilla は適用されるが、./react.ts → zustand/react は除外される。
TypeScript バージョン互換の防御策
typesVersions フィールド(package.json:9-26)で TS 4.5 未満を明示的にブロックしている。>=4.5 条件に一致しない場合、ts_version_4.5_and_above_is_required.d.ts という空ファイル(patch-old-ts スクリプトで生成)が参照され、型エラーとなる。
CI では TS 4.5 から 5.9 まで 15 バージョンのマトリクスで型チェックを実行し、古い TS では isolatedDeclarations、verbatimModuleSyntax、moduleResolution: "bundler" などのフラグを sed で除去して互換性を確保している(.github/workflows/test-old-typescript.yml)。
React Native 対応
exports の各サブパスで react-native 条件を最上位に配置している(package.json:30-33, 44-47)。React Native のバンドラー(Metro)は ESM の import 条件よりも react-native 条件を優先するため、CJS 形式の .js ファイルが使用される。
ビルド成果物の品質ゲート
CI で以下の多段階検証を実施している:
- ソーステスト:
test:specでソースに対して Vitest を実行(.github/workflows/test.yml) - ビルド成果物テスト: CJS/ESM それぞれのビルド出力に対してテストを実行(
.github/workflows/test-multiple-builds.yml)。vitest.config.mts のエイリアスをsedで書き換えてdist/*.jsやdist/esm/*.mjsを直接テストする - バンドルサイズ監視:
compressed-size-actionでdist/**/*.{js,mjs}の圧縮サイズを PR ごとに比較(.github/workflows/compressed-size.yml) - React バージョンマトリクス: React 18.0 から 19.x、canary、experimental まで 9 バージョンでテスト(
.github/workflows/test-multiple-versions.yml)
コード例
// rollup.config.mjs:11-16 — ソース相対パスからパッケージ名エイリアスへの変換テーブル
export const entries = [
{ find: /.*\/vanilla\/shallow\.ts$/, replacement: "zustand/vanilla/shallow" },
{ find: /.*\/react\/shallow\.ts$/, replacement: "zustand/react/shallow" },
{ find: /.*\/vanilla\.ts$/, replacement: "zustand/vanilla" },
{ find: /.*\/react\.ts$/, replacement: "zustand/react" },
];// rollup.config.mjs:47-73 — ESM ビルドでの環境変数置換(フォーマット依存の分岐)
function createESMConfig(input, output) {
return {
input,
output: { file: output, format: "esm" },
external,
plugins: [
alias({ entries: entries.filter((entry) => !entry.find.test(input)) }),
resolve({ extensions }),
replace({
...(output.endsWith(".js")
? {
"import.meta.env?.MODE": "process.env.NODE_ENV",
}
: {
"import.meta.env?.MODE": "(import.meta.env ? import.meta.env.MODE : undefined)",
}),
// ...
}),
getEsbuild(),
],
};
}// src/index.ts:1-2 — Facade パターン: vanilla + react を統合 re-export
export * from "./react.ts";
export * from "./vanilla.ts";// src/middleware.ts:1-17 — Barrel エクスポート: 個別ミドルウェアの集約
export { combine } from "./middleware/combine.ts";
export { devtools, type DevtoolsOptions, type NamedSet } from "./middleware/devtools.ts";
export {
createJSONStorage,
persist,
type PersistOptions,
type PersistStorage,
type StateStorage,
type StorageValue,
} from "./middleware/persist.ts";
export { redux } from "./middleware/redux.ts";
export { ssrSafe as unstable_ssrSafe } from "./middleware/ssrSafe.ts";
export { subscribeWithSelector } from "./middleware/subscribeWithSelector.ts";// package.json:29-56 — 3 条件対応のサブパスエクスポート
"exports": {
".": {
"react-native": { "types": "./index.d.ts", "default": "./index.js" },
"import": { "types": "./esm/index.d.mts", "default": "./esm/index.mjs" },
"default": { "types": "./index.d.ts", "default": "./index.js" }
},
"./*": {
"react-native": { "types": "./*.d.ts", "default": "./*.js" },
"import": { "types": "./esm/*.d.mts", "default": "./esm/*.mjs" },
"default": { "types": "./*.d.ts", "default": "./*.js" }
}
}パターンカタログ
Facade パターン (分類: 構造)
- 解決する問題: 消費者が内部モジュール構造を意識せずに主要 API にアクセスできるようにする
- 適用条件: 複数の内部モジュールを統合して単一のインターフェースを提供したい場合
- コード例:
src/index.ts:1-2—vanillaとreactを re-export - 注意点: Facade 経由の import では tree-shaking が効きにくい場合がある。zustand では
sideEffects: falseで緩和
Layered Architecture (分類: アーキテクチャ)
- 解決する問題: フレームワーク依存部とフレームワーク非依存部の混在を防ぐ
- 適用条件: ライブラリが複数のランタイム環境(Node.js、ブラウザ、React Native)で動作する必要がある場合
- コード例:
src/vanilla.ts(Layer 0)→src/react.ts(Layer 1)。依存は常に上位から下位への一方通行 - 注意点: 層をまたぐ型の共有は
declare moduleによるモジュール拡張で実現(src/middleware/immer.ts:14-19)
Good Patterns
- ワイルドカードサブパスエクスポート
"./*": 個別のサブパスを列挙する代わりに"./*"パターンで一括定義している(package.json:43-55)。新しいサブパスを追加してもエクスポートマップの変更が不要で、ビルドスクリプトを追加するだけで済む。
// package.json:43-55
"./*": {
"react-native": { "types": "./*.d.ts", "default": "./*.js" },
"import": { "types": "./esm/*.d.mts", "default": "./esm/*.mjs" },
"default": { "types": "./*.d.ts", "default": "./*.js" }
}- ビルド成果物に対するテスト実行: ソースだけでなく、CJS/ESM のビルド成果物に対してもテストを実行している(
.github/workflows/test-multiple-builds.yml)。vitest.config.mts のエイリアスをsedで差し替えることで、同一テストスイートを異なるビルド出力に適用する。
# .github/workflows/test-multiple-builds.yml:37-43
- name: Patch for CJS
if: ${{ matrix.build == 'cjs' }}
run: |
sed -i~ "s/resolve('\.\/src\(.*\)\.ts')/resolve('\.\/dist\1.js')/" vitest.config.mts
- name: Patch for ESM
if: ${{ matrix.build == 'esm' }}
run: |
sed -i~ "s/resolve('\.\/src\(.*\)\.ts')/resolve('\.\/dist\/esm\1.mjs')/" vitest.config.mts全 peerDependencies を optional 化:
react、immer、use-sync-external-storeをすべて optional peerDependencies として宣言(package.json:166-179)。コアのvanilla.tsは外部依存ゼロで動作し、React バインディングはreactがある場合のみ機能する。sideEffects: false宣言: パッケージ全体に副作用がないことを宣言し(package.json:61)、バンドラーが未使用のサブパスを安全に除去できるようにしている。
Anti-Patterns / 注意点
- postbuild シェルスクリプトの複雑性:
patch-d-ts、copy、patch-esm-tsが package.json のscriptsフィールド内にインライン Node.js コードとして記述されている。sed相当の処理を shelljs で実行しており、可読性が低く、デバッグが困難。
// package.json:84 — Bad: インラインの postbuild スクリプト
"patch-d-ts": "node --input-type=module -e \"import { entries } from './rollup.config.mjs'; ...\""// Better: 独立したスクリプトファイル scripts/patch-d-ts.mjs
import { find, sed } from "shelljs";
import { entries } from "../rollup.config.mjs";
find("dist/**/*.d.ts").forEach(f => {
entries.forEach(({ find: pattern, replacement }) => {
sed("-i", new RegExp(/* ... */), ` from '${replacement}';`, f);
});
});- CI での
sedによる設定書き換え:test-old-typescript.ymlとtest-multiple-builds.ymlで tsconfig.json や vitest.config.mts をsedで動的に書き換えている。正規表現が壊れやすく、設定変更時に CI が意図せず壊れるリスクがある。
# Bad: sed による tsconfig.json の動的パッチ
sed -i~ 's/"moduleResolution": "bundler",/"moduleResolution": "node",/' tsconfig.json# Better: 環境ごとの tsconfig を extends で分離
# tsconfig.node-resolution.json
{ "extends": "./tsconfig.json", "compilerOptions": { "moduleResolution": "node" } }導出ルール
[MUST]CJS/ESM デュアルパッケージでは、各サブパスエクスポートのimport条件にtypesフィールドをdefaultより前に配置する- 根拠: TypeScript は
exports内の条件を上から順に評価する。typesがdefaultの後にあると.mjsに対して.d.ts(CJS 用)が解決される場合がある(package.json:35-36でtypes→defaultの順序を遵守)
- 根拠: TypeScript は
[MUST]サブパスエクスポートを持つパッケージでは、ビルド成果物のディレクトリをパッケージルートとして publish する(ソースルートから publish しない)- 根拠:
exportsのパスは publish されたパッケージルートからの相対パスで解決される。ソースルートから publish するとdist/プレフィックスがエクスポートパスに露出し、消費者の import パスが冗長になる(publish.yml:25でworking-directory: distを指定)
- 根拠:
[MUST]独立してビルドされるサブパスエクスポート間の import は、ビルド時にパッケージ名(自己参照)に書き換える- 根拠: 相対パスのままビルドすると、バンドラーがサブパス間の依存をインライン化し、同一コードが複数チャンクに重複する。パッケージ名に書き換えることで external 扱いとなり、tree-shaking が正しく動作する(
rollup.config.mjs:11-16)
- 根拠: 相対パスのままビルドすると、バンドラーがサブパス間の依存をインライン化し、同一コードが複数チャンクに重複する。パッケージ名に書き換えることで external 扱いとなり、tree-shaking が正しく動作する(
[SHOULD]ESM 用の型定義ファイルは.d.mts拡張子で提供し、内部 import パスの拡張子も.mjsに統一する- 根拠: TypeScript の
moduleResolution: "bundler"や"node16"では、.mjsファイルに対して.d.mtsを探索する。.d.tsのみだと ESM 条件で型解決に失敗する場合がある(package.json:87のpatch-esm-tsで.d.ts→.d.mtsリネーム + import パス書き換え)
- 根拠: TypeScript の
[SHOULD]フレームワーク非依存のコアロジックとフレームワーク固有のバインディングは別のサブパスエクスポートに分離し、フレームワークを optional peerDependency にする- 根拠: zustand では
vanilla(コア)とreact(バインディング)を分離し、React を optional peerDependency にしている。これにより Node.js の CLI ツールやVue/Svelte からコアを直接使用でき、不要なreact依存を強制しない(src/vanilla.tsは外部依存ゼロ)
- 根拠: zustand では
[SHOULD]ソースに対するテストだけでなく、CJS/ESM の各ビルド成果物に対しても同一テストスイートを実行する- 根拠: ビルド時のエイリアス置換、
import.meta.envの変換、.mjs拡張子処理などでビルド成果物がソースと異なる挙動を示す可能性がある。zustand は CI でビルド出力を差し替えてテストを再実行している(.github/workflows/test-multiple-builds.yml)
- 根拠: ビルド時のエイリアス置換、
[AVOID]package.json のscriptsフィールドにインラインの複雑なシェルコマンドや Node.js ワンライナーを記述する- 根拠: zustand の
patch-d-ts、patch-esm-tsは 1 行に圧縮されたスクリプトで、可読性・保守性が低い。独立したスクリプトファイルに分離すべき(package.json:84,87)
- 根拠: zustand の
適用チェックリスト
- [ ]
package.jsonのexportsフィールドで、各条件のtypesがdefaultより前に配置されているか - [ ] サブパスエクスポートごとに CJS(
.js+.d.ts)と ESM(.mjs+.d.mts)の両方のファイルが生成されているか - [ ] ビルド成果物のディレクトリを publish ルートにしているか(
npm publishをdist/から実行) - [ ] サブパスエクスポート間の import がビルド後にパッケージ名(自己参照)に解決されているか確認したか
- [ ]
sideEffects: falseが設定されているか(すべてのエクスポートが副作用なしの場合) - [ ] フレームワーク固有の依存が optional peerDependency として宣言されているか
- [ ] CI でビルド成果物(CJS/ESM 両方)に対するテストを実行しているか
- [ ] React Native を対象とする場合、
exportsの条件順序でreact-nativeがimportより前にあるか - [ ] 複数の TypeScript バージョンで型チェックが通ることを CI で検証しているか