Read OSS

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つのカテゴリを中心に構成されています。

pnpm-workspace.yaml

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 はビルドキャッシュとタスクのオーケストレーションを担当します。設定は非常にシンプルで、buildcopy-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/utilsdeepmergecomposeClassesgenerateUtilityClassformatMuiErrorMessage などのランタイムヘルパーを提供します。@mui/private-theming は基本となる ThemeContextThemeProvider を提供します。

第二層 — Styled Engine: @mui/styled-engine は Emotion の styledcsskeyframes API を薄い抽象化レイヤーでラップします。スタイリングエンジンを差し替え可能にするためのアダプター層です。

第三層 — Styling System: @mui/system は MUI のスタイリング全体の中核となるファクトリ関数 createStyledsx 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.jscreateStyled に渡されます。

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,
});

コンポーネントのレンダリング時、createStyledattachTheme を通じてテーマを解決します。

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 コンポーネントへと変換するファクトリ関数です。その三段階の式パイプラインは、一見の印象よりもはるかに精巧な仕組みになっています。