Material UI の内部構造:アーキテクチャとパッケージ依存ピラミッド
前提知識
- ›React の基礎(コンポーネント、hooks、context)
- ›CSS-in-JS の基本的な概念(styled-components または Emotion のパターン)
- ›npm/pnpm およびモノレポの概念に関する基本的な知識
Material UI の内部構造:アーキテクチャとパッケージ依存ピラミッド
Material UI は世界で最も広く使われている React コンポーネントライブラリのひとつですが、import Button from '@mui/material/Button' の裏側まで立ち入る開発者はほとんどいません。このシリーズはその「裏側」を丁寧に読み解くことを目的としています。全6回にわたり、JSX がコンポーネントを呼び出す瞬間から CSS が DOM に適用される瞬間まで、重要なコードを一行ずつ追っていきます。まず第1回では、システム全体を支えるアーキテクチャから始めましょう。
Material UI は単一のパッケージではありません。各パッケージが下位層との依存関係を厳密に定義した、精緻な層構造のモノレポです。このピラミッドを理解することが、コードベース全体を理解するための鍵になります。
モノレポの構造:pnpm Workspaces、Lerna、Nx
リポジトリはルートのワークスペースファイルで定義された3つのカテゴリを中心に構成されています。
packages:
- packages/*
- packages/mui-envinfo/test
- packages-internal/*
- docs
- test
- test/*
| ディレクトリ | 役割 |
|---|---|
packages/* |
npm に公開されるパッケージ(@mui/material、@mui/system、@mui/utils など) |
packages-internal/* |
npm に公開されない内部ツール(Markdown 処理、ドキュメントユーティリティなど) |
docs/ |
mui.com で公開されている Next.js ドキュメントサイト |
test/ |
パッケージをまたいだ統合テスト |
このモノレポは三つのツールが役割を分担して管理されています。
- pnpm はパッケージのインストールとワークスペースのリンクを担当します。
package.jsonの依存フィールドに記述されたworkspace:^プロトコルにより、pnpm はレジストリではなくローカルのワークスペースからパッケージを解決します。 - Nx はビルドキャッシュとタスクのオーケストレーションを担当します。設定は非常にシンプルで、
buildとcopy-licenseタスクのデフォルト設定とキャッシュの出力先を指定するだけです。 - Lerna は(歴史的な経緯から)バージョン管理と npm への公開を担当しています。
nx.json の設定は非常に最小限です。
{
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["copy-license", "^build"],
"outputs": ["{projectRoot}/build", "{projectRoot}/dist", "{projectRoot}/.next"]
}
}
}
ここで重要なのが "dependsOn": ["^build"] の記述です。これにより、あるパッケージをビルドする前に、そのパッケージが依存するすべてのパッケージが先にビルドされることが保証されます。つまり、四層のピラミッドが必ず下から順にビルドされる仕組みになっています。
四層の依存ピラミッド
@mui/material からレンダリングするすべてのコンポーネントは、厳密な依存階層の上に成り立っています。この層構造を理解することが、このシリーズ全体の土台になります。
graph BT
L1_TYPES["@mui/types"]
L1_UTILS["@mui/utils"]
L1_THEMING["@mui/private-theming"]
L2["@mui/styled-engine"]
L3["@mui/system"]
L4["@mui/material"]
L1_TYPES --> L1_UTILS
L1_UTILS --> L3
L1_THEMING --> L3
L2 --> L3
L3 --> L4
L1_UTILS --> L4
style L4 fill:#1976d2,color:#fff
style L3 fill:#42a5f5,color:#fff
style L2 fill:#90caf9,color:#000
style L1_TYPES fill:#bbdefb,color:#000
style L1_UTILS fill:#bbdefb,color:#000
style L1_THEMING fill:#bbdefb,color:#000
第一層 — 基盤: @mui/types は共有 TypeScript ユーティリティを提供します。@mui/utils は deepmerge、composeClasses、generateUtilityClass、formatMuiErrorMessage などのランタイムヘルパーを提供します。@mui/private-theming は基本となる ThemeContext と ThemeProvider を提供します。
第二層 — Styled Engine: @mui/styled-engine は Emotion の styled、css、keyframes API を薄い抽象化レイヤーでラップします。スタイリングエンジンを差し替え可能にするためのアダプター層です。
第三層 — Styling System: @mui/system は MUI のスタイリング全体の中核となるファクトリ関数 createStyled、sx prop の処理、テーマの構築、CSS 変数のサポート、Box などのレイアウトプリミティブを提供します。
第四層 — コンポーネント: @mui/material はコンポーネントライブラリ本体です。Button、TextField、Dialog をはじめ 60 以上のコンポーネントが含まれています。すべてのコンポーネントは第三層の styled を使用し、その styled は第二層のエンジンを使用します。
この階層構造はパッケージの依存関係を直接確認することで検証できます。packages/mui-material/package.json では次のようになっています。
"dependencies": {
"@mui/system": "workspace:^",
"@mui/types": "workspace:^",
"@mui/utils": "workspace:^",
...
}
また packages/mui-system/package.json では次のとおりです。
"dependencies": {
"@mui/private-theming": "workspace:^",
"@mui/styled-engine": "workspace:^",
"@mui/types": "workspace:^",
"@mui/utils": "workspace:^",
...
}
ヒント: Material UI の問題をデバッグするときは、まずどの層が関係しているかを特定しましょう。スタイリングの問題なら
@mui/system(第三層)、テーマトークンの問題なら@mui/material/styles(第四層)が起点になります。ピラミッドを見れば、どこを調べればいいかが自ずとわかります。
Styled Engine の抽象化
styled-engine 層はそのシンプルさが際立っています。Emotion アダプター全体は packages/mui-styled-engine/src/index.js という一つのファイルに収まっています。
import emStyled from '@emotion/styled';
import { serializeStyles as emSerializeStyles } from '@emotion/serialize';
export default function styled(tag, options) {
const stylesFactory = emStyled(tag, options);
// dev-mode validation omitted for clarity
return stylesFactory;
}
export function internal_mutateStyles(tag, processor) {
if (Array.isArray(tag.__emotion_styles)) {
tag.__emotion_styles = processor(tag.__emotion_styles);
}
}
export function internal_serializeStyles(styles) {
wrapper[0] = styles;
return emSerializeStyles(wrapper);
}
export { ThemeContext, keyframes, css } from '@emotion/react';
このファイルは三つのことを行っています。Emotion の styled を開発モード向けのバリデーションでラップすること、事後的なスタイル操作のために internal_mutateStyles を公開すること、定義時に CSS を事前ハッシュ化するために internal_serializeStyles を公開することです。
styled-components 向けの代替実装は packages/mui-styled-engine-sc/src/index.js にあり、同じ API サーフェスを持ちながら、内部では styled-components にマッピングされています。
flowchart LR
SC["@mui/styled-engine-sc"]
EM["@mui/styled-engine"]
SYS["@mui/system"]
SC -- "styled, keyframes, css" --> SYS
EM -- "styled, keyframes, css" --> SYS
SC2["styled-components"] --> SC
EM2["@emotion/styled"] --> EM
二つの実装の重要な違いは internal_serializeStyles にあります。Emotion アダプターはスタイルをハッシュ化された CSS 文字列として事前にシリアライズします。一方、styled-components アダプターは return styles とするだけのノーオペレーションです。これは styled-components が同等のシリアライズ API を公開していないためです。このことがパフォーマンス面にどう影響するかは、第二回で詳しく取り上げます。
バレルエクスポートとワンレベルインポートルール
Material UI のパブリック API はバレルファイル(子モジュールから再エクスポートする index ファイル)を通じて公開されています。packages/mui-material/src/index.js のメインバレルは一貫したパターンに従っています。
export { default as Button } from './Button';
export * from './Button';
export { default as TextField } from './TextField';
export * from './TextField';
// ...60+ more components
このパターンにより、import { Button } from '@mui/material'(バレルインポート)と import Button from '@mui/material/Button'(直接インポート)の二つのスタイルが使えます。モノレポ内では eslint.config.mjs でワンレベルインポートルールが強制されています。
const OneLevelImportMessage = [
'Prefer one level nested imports to avoid bundling everything in dev mode',
'or breaking CJS/ESM split.',
].join('\n');
const NO_RESTRICTED_IMPORTS_PATTERNS_DEEPLY_NESTED = [
{
group: ['@mui/*/*/*'],
message: OneLevelImportMessage,
},
];
import Button from '@mui/material/Button' は許可されますが、import ButtonRoot from '@mui/material/Button/Button' は許可されません。このルールには二つの理由があります。深いインポートがプライベートな実装の詳細を引き込むのを防ぐことと、予測可能なモジュールグラフを維持することでバンドラーの tree-shaking が正しく機能するよう保証することです。
flowchart TD
A["import Button from '@mui/material/Button'"] -->|"✅ One level"| B["packages/mui-material/src/Button/index.js"]
B --> C["./Button.js (implementation)"]
D["import Button from '@mui/material/Button/Button'"] -->|"❌ Two levels"| C
E["import { Button } from '@mui/material'"] -->|"✅ Barrel"| F["packages/mui-material/src/index.js"]
F --> B
THEME_ID によるスコープ付きテーマ
Material UI のアーキテクチャで最も見落とされがちな設計決定のひとつが THEME_ID システムです。すべての Material UI コンポーネントは生のテーマコンテキストではなく、その中のスコープキーからテーマを取り出します。
この識別子は packages/mui-material/src/styles/identifier.ts で定義されています。
export default '$$material';
この ID は packages/mui-material/src/styles/styled.js で createStyled に渡されます。
import createStyled from '@mui/system/createStyled';
import defaultTheme from './defaultTheme';
import THEME_ID from './identifier';
import rootShouldForwardProp from './rootShouldForwardProp';
const styled = createStyled({
themeId: THEME_ID,
defaultTheme,
rootShouldForwardProp,
});
コンポーネントのレンダリング時、createStyled は attachTheme を通じてテーマを解決します。
function attachTheme(props, themeId, defaultTheme) {
props.theme = isObjectEmpty(props.theme)
? defaultTheme
: props.theme[themeId] || props.theme;
}
sequenceDiagram
participant Component as Button
participant Styled as createStyled
participant Theme as ThemeContext
Component->>Styled: render with props
Styled->>Theme: read theme from context
Theme-->>Styled: { $$material: {...}, $$joy: {...} }
Styled->>Styled: attachTheme(props, '$$material', default)
Styled-->>Component: props.theme = theme['$$material']
この設計により、Material UI と Joy UI を同じページで完全に独立したテーマのもとで共存させることができます。各ライブラリは共有テーマコンテキスト内の固有キーにスコープされるため、ThemeProvider が両方のライブラリをラップしても競合が発生しません。
ヒント:
@mui/systemをベースにコンポーネントライブラリを構築する場合は、独自のTHEME_ID(例:'$$mylib')を定義しましょう。これにより、利用者が Material UI と自作ライブラリを併用しても、テーマが衝突しなくなります。
次回に向けて
アーキテクチャの全体像が把握できたところで、コードがどこに存在し、パッケージ同士がどのように関係しているかが明確になりました。第二回では、コードベース全体で最も重要な関数である createStyled を深く掘り下げます。styled(Button)({...}) というシンプルな呼び出しを、テーマ適用済み・バリアント対応・sx prop 対応の React コンポーネントへと変換するファクトリ関数です。その三段階の式パイプラインは、一見の印象よりもはるかに精巧な仕組みになっています。