Read OSS

ビルドパイプライン、エラーの最小化、そして開発者ツール

中級

前提知識

  • 第1〜2回の記事
  • Babel プラグインの基本概念
  • ビルドツールの基礎(webpack、npm scripts)

ビルドパイプライン、エラーの最小化、そして開発者ツール

Material UI は 60 を超えるコンポーネントを何百万もの本番アプリケーションに提供しています。このライブラリの品質はコンポーネントのコードだけで成り立っているわけではありません。開発専用コードを除去し、型情報を自動生成し、巨大なモノレポ全体でビルドをキャッシュし、lint でコーディング規約を徹底させる。そうした洗練されたビルドインフラが品質を支えています。本記事では、そのインフラを構成するツール群を詳しく見ていきます。

エラー最小化システム

本番環境に詳細なエラーメッセージを含めると、バンドルサイズが無駄に増えます。Material UI はこの問題を Babel プラグインで解決しています。エラーメッセージを数値コードと URL 参照に置き換える仕組みです。ソースコードの典型的なエラーを見てみましょう。

throw /* minify-error */ new Error(
  'MUI: `vars` is a private field used for CSS variables support.\n' +
    'Please use another name.'
);

/* minify-error */ コメントがトリガーになります。babel.config.mjs で設定された Babel プラグイン @mui/internal-babel-plugin-minify-errors が、このコードを次のように変換します。

throw new Error(formatMuiErrorMessage(17));

packages/mui-utils/src/formatMuiErrorMessage/formatMuiErrorMessage.ts にあるランタイムヘルパーは、デバッグ用の URL を生成します。

export default function formatMuiErrorMessage(code: number, ...args: string[]): string {
  const url = new URL(`https://mui.com/production-error/?code=${code}`);
  args.forEach((arg) => url.searchParams.append('args[]', arg));
  return `Minified MUI error #${code}; visit ${url} for the full message.`;
}
flowchart LR
    Source["throw /* minify-error */ new Error('long message')"]
    Babel["Babel Plugin"]
    Prod["throw new Error(formatMuiErrorMessage(17))"]
    URL["https://mui.com/production-error/?code=17"]

    Source -->|"build"| Babel
    Babel --> Prod
    Prod -->|"runtime"| URL

エラーコードは docs/public/static/error-codes.json に保存されます。プラグインはビルド時にこのファイルを読み込み、各エラーメッセージに数値コードを割り当て、新しいコードを書き戻します。コードファイルのパスは 14 行目 で設定されています。

const errorCodesPath = path.resolve(dirname, './docs/public/static/error-codes.json');

ヒント: 本番環境で Minified MUI error #N が表示されたら、エラーメッセージに含まれる URL にアクセスするだけです。ドキュメントページには、実行時の引数が埋め込まれた完全なエラーテキストが表示されます。このパターンは React 自身が先駆けたものです。

TypeScript からの PropTypes 自動生成

Material UI では、TypeScript の型定義をコンポーネント API の唯一の正式な情報源として扱っています。ランタイムの PropTypes はこの型定義から自動生成されるため、常に同期が保たれます。

scripts/generateProptypes.ts にある生成スクリプトは @mui/internal-scripts/typescript-to-proptypes を使用しています。

import {
  getPropTypesFromFile,
  injectPropTypesInFile,
} from '@mui/internal-scripts/typescript-to-proptypes';

生成された PropTypes には /* remove-proptypes */ アノテーションが付きます。Button コンポーネントの 607 行目 で確認できます。

Button.propTypes /* remove-proptypes */ = {
  // ┌────────────────────────────── Warning ──────────────────────────────┐
  // │ These PropTypes are generated from the TypeScript type definitions. │
  // │    To update them, edit the d.ts file and run `pnpm proptypes`.     │
  // └─────────────────────────────────────────────────────────────────────┘
  children: PropTypes.node,
  classes: PropTypes.object,
  // ...
};

/* remove-proptypes */ コメントは、別の Babel プラグインに対して本番ビルド時に PropTypes を除去するよう指示します。この二段階のアプローチにより、開発時は豊富な型チェックを活用しつつ、本番バンドルにはコストを一切かけずに済みます。

flowchart TD
    DTS["Button.d.ts (TypeScript source of truth)"]
    Script["pnpm proptypes (generateProptypes.ts)"]
    JS["Button.js — PropTypes injected"]
    DevBuild["Development Build: PropTypes included"]
    ProdBuild["Production Build: PropTypes stripped"]

    DTS --> Script
    Script --> JS
    JS --> DevBuild
    JS -->|"/* remove-proptypes */ stripped"| ProdBuild

Nx によるキャッシュとビルドオーケストレーション

nx.json では、ビルドキャッシュとタスクの依存関係を定義しています。

{
  "targetDefaults": {
    "copy-license": {
      "cache": true,
      "outputs": ["{projectRoot}/LICENSE"]
    },
    "build": {
      "cache": true,
      "dependsOn": ["copy-license", "^build"],
      "outputs": ["{projectRoot}/build", "{projectRoot}/dist", "{projectRoot}/.next"]
    }
  }
}

"^build" という依存指定は「先にすべての依存パッケージをビルドする」という意味です。"cache": true と組み合わせることで、Nx はコンテンツハッシュベースのビルドキャッシュを生成します。たとえば @mui/utils のファイルを変更すると、Nx はまず @mui/utils を再ビルドし、次に @mui/system(utils に依存)、さらに @mui/material(system に依存)の順に再ビルドします。影響を受けなかったパッケージはキャッシュから出力を取得します。

flowchart BT
    Utils["@mui/utils (rebuild: file changed)"]
    Engine["@mui/styled-engine (cache hit ✓)"]
    System["@mui/system (rebuild: depends on utils)"]
    Material["@mui/material (rebuild: depends on system)"]

    Utils --> System
    Engine --> System
    System --> Material

このモノレポでは、それぞれ明確な役割を持つ 3 つのツールが連携して動いています。

ツール 役割 設定ファイル
pnpm パッケージのインストール、ワークスペースのリンク pnpm-workspace.yaml
Nx ビルドキャッシュ、タスクオーケストレーション nx.json
Lerna バージョン管理、変更履歴の生成、npm への公開 lerna.json

このように関心を明確に分離することで、各ツールが最も得意な領域に集中できます。pnpm が依存関係グラフを、Nx がビルドグラフを、Lerna がリリースグラフをそれぞれ担当します。

インポートの規約とツリーシェイキング

第 1 回の記事でも触れたように、ESLint の設定によってインポートの規約が強制されています。ここではその仕組みをより詳しく見ていきましょう。

eslint.config.mjs では、2 段階の制限が定義されています。

// Block top-level barrel imports within the monorepo
const NO_RESTRICTED_IMPORTS_PATHS_TOP_LEVEL_PACKAGES = [
  { name: '@mui/material', message: OneLevelImportMessage },
  { name: '@mui/lab', message: OneLevelImportMessage },
];

// Block deeply nested imports (3+ levels)
const NO_RESTRICTED_IMPORTS_PATTERNS_DEEPLY_NESTED = [
  {
    group: ['@mui/*/*/*', '!@mui/internal-*/**'],
    message: OneLevelImportMessage,
  },
];

モノレポ内では @mui/material(バレルインポート)が禁止されています。開発中に 60 以上のコンポーネントすべてが読み込まれてしまい、HMR のパフォーマンスが著しく低下するためです。また @mui/material/Button/Button(2 階層以上の深いパス)も禁止されています。これは内部実装の詳細を外部に露出させてしまうからです。

モノレポ内で有効なインポート形式は @mui/material/Button のみです。1 階層だけ掘り下げ、コンポーネントの index.js を参照します。外部の利用者に対しては、import { Button } from '@mui/material'(バレル)と import Button from '@mui/material/Button'(1 階層)の両方がサポートされています。バンドラーは開発サーバーとは異なる方法でツリーシェイキングを処理するためです。

flowchart TD
    A["import { Button } from '@mui/material'"] -->|"✅ External consumers"| OK1["Barrel — bundler tree-shakes"]
    B["import Button from '@mui/material/Button'"] -->|"✅ Everywhere"| OK2["One-level — optimal"]
    C["import '@mui/material'"] -->|"❌ Monorepo"| ERR1["Pulls everything in dev"]
    D["import '@mui/material/Button/Button'"] -->|"❌ Everywhere"| ERR2["Exposes internals"]

ヒント: Next.js や Vite プロジェクトで MUI をインポートする際は、バレルインポートよりも import Button from '@mui/material/Button' の形式を優先しましょう。モダンなバンドラーはバレルインポートを最適化しますが、1 階層形式であれば確実なツリーシェイキングと高速な開発サーバーの起動が保証されます。

ドキュメントサイトと開発ワークフロー

ドキュメントサイトは Next.js アプリケーションとして構築されており、ライブ開発時は @mui/* パッケージをソースディレクトリに直接解決します。これを実現しているのが、docs/next.config.ts の webpack エイリアスシステムです。

alias: {
  '@mui/material$': path.resolve(workspaceRoot, 'packages/mui-material/src/index.js'),
  '@mui/material': path.resolve(workspaceRoot, 'packages/mui-material/src'),
  '@mui/system': path.resolve(workspaceRoot, 'packages/mui-system/src'),
  '@mui/styled-engine': path.resolve(workspaceRoot, 'packages/mui-styled-engine/src'),
  '@mui/utils': path.resolve(workspaceRoot, 'packages/mui-utils/src'),
  // ...
},

webpack を使わないコンテキスト向けには、babel.config.mjs でも同様のエイリアスが設定されています。

const defaultAlias = {
  '@mui/material': resolveAliasPath('./packages/mui-material/src'),
  '@mui/system': resolveAliasPath('./packages/mui-system/src'),
  '@mui/styled-engine': resolveAliasPath('./packages/mui-styled-engine/src'),
  '@mui/utils': resolveAliasPath('./packages/mui-utils/src'),
  // ...
};

このおかげで、コントリビューターが packages/mui-material/src/Button/Button.js を編集すると、ビルドステップなしに HMR を通じてドキュメントサイトへ即座に反映されます。ソースファイルは Next.js の webpack パイプラインによってオンザフライでトランスパイルされ、直接消費されます。

Babel の設定には、テスト以外のファイルに適用されるパフォーマンス最適化も含まれています(68 行目)。

overrides: [
  {
    exclude: /\.test\.(m?js|ts|tsx)$/,
    plugins: ['@babel/plugin-transform-react-constant-elements'],
  },
],

@babel/plugin-transform-react-constant-elements は、動的な値に依存しない React 要素の生成をレンダー関数の外に巻き上げます。これにより、本番環境でのガベージコレクションの負荷を軽減できます。

次回予告

ここまでで、Material UI が本番環境での最小限のオーバーヘッドと最大限の開発速度を両立させるためのビルドインフラ全体を把握できました。第 6 回(最終回)では、カスタマイズの全体像を整理します。テーマのデフォルト値から sx というエスケープハッチまで、5 つの異なるカスタマイズレイヤーと、CSS カスタムプロパティおよび @layer ディレクティブが他のコンポーネントライブラリで問題となるスタイルの組み合わせ爆発をどのように解消するかを解説します。