Read OSS

styled() の内部構造:createStyled パイプラインを読み解く

上級

前提知識

  • 第1回:アーキテクチャとパッケージ階層
  • Emotion の styled() API
  • CSS の詳細度とカスケードの概念
  • JavaScript のクロージャとファクトリ関数

styled() の内部構造:createStyled パイプラインを読み解く

第1回では、4層の依存ピラミッドを俯瞰し、Material UI の styled エクスポートが設定オブジェクトを渡した createStyled() の呼び出しによって生成されることを確認しました。今回はそのファクトリ関数の中に踏み込み、すべての式、最適化、設計上の判断を順に追っていきます。ここで扱うコードは、ライブラリ内のすべての styled コンポーネントが通る処理です。このコードを理解することは、MUI のスタイリング全体を理解することに直結します。

中核となる実装は1つのファイルに収まっています: packages/mui-system/src/createStyled/createStyled.js。351行とコンパクトですが、構造を把握すれば十分に読み解けます。

createStyled:ファクトリ関数の概要

createStyledstyled 関数を返すファクトリです。渡された設定はクロージャに閉じ込められ、返された styled で生成されるすべてのコンポーネント間で共有されます。

export default function createStyled(input = {}) {
  const {
    themeId,
    defaultTheme = systemDefaultTheme,
    rootShouldForwardProp = shouldForwardProp,
    slotShouldForwardProp = shouldForwardProp,
  } = input;

  const styled = (tag, inputOptions = {}) => {
    // ...muiStyledResolver を返す
  };

  return styled;
}

第1回で見たように、Material UI はこのファクトリを { themeId: '$$material', defaultTheme, rootShouldForwardProp } という引数で1回だけ呼び出し、その結果をエクスポートしています。この1回の呼び出しで、ライブラリ全体のすべてのコンポーネントが使う styled 関数が生まれます。

flowchart TD
    Factory["createStyled({ themeId, defaultTheme, rootShouldForwardProp })"]
    Factory --> StyledFn["styled() — closed over config"]
    StyledFn --> |"styled(ButtonBase, { name: 'MuiButton', slot: 'Root' })"|Resolver["muiStyledResolver"]
    Resolver --> |"muiStyledResolver(styles...)"|Component["React Component"]

返された styled 関数は2つの引数を受け取ります。tag(HTML 要素の文字列または React コンポーネント)と、MUI 固有のメタデータを含むオプションオブジェクトです。

  • name: コンポーネント名(例: 'MuiButton')— テーマによるオーバーライドの検索に使用
  • slot: サブコンポーネントのスロット名(例: 'Root''StartIcon')— shouldForwardProp の挙動を制御
  • overridesResolver: テーマの styleOverrides キーとコンポーネントを対応付ける関数
  • skipVariantsResolverskipSx: 特定のパイプラインステージを無効にするオプション

3ゾーン式 expression パイプライン

createStyled の核心は、216行目で定義された muiStyledResolver 関数です。styled('div')({ color: 'red' }) と書いたとき、Emotion に渡されるのは自分が書いたスタイルオブジェクトだけではありません。MUI はそれらを3ゾーン構成のパイプラインでラップします。

const muiStyledResolver = (...expressionsInput) => {
  const expressionsHead = [];
  const expressionsBody = expressionsInput.map(transformStyle);
  const expressionsTail = [];

  // HEAD: テーマのアタッチ — 最初に実行される必要がある
  expressionsHead.push(styleAttachTheme);

  // TAIL: テーマオーバーライド
  if (componentName && overridesResolver) {
    expressionsTail.push(styleThemeOverrides);
  }

  // TAIL: テーマバリアント
  if (componentName && !skipVariantsResolver) {
    expressionsTail.push(styleThemeVariants);
  }

  // TAIL: sx prop — 常に最後
  if (!skipSx) {
    expressionsTail.push(styleFunctionSx);
  }

  const expressions = [...expressionsHead, ...expressionsBody, ...expressionsTail];
  const Component = defaultStyledResolver(...expressions);
  return Component;
};
sequenceDiagram
    participant Dev as Developer
    participant MSR as muiStyledResolver
    participant Emotion as Emotion styled

    Dev->>MSR: styled('div')(style1, style2)
    MSR->>MSR: expressionsHead = [attachTheme]
    MSR->>MSR: expressionsBody = [transform(style1), transform(style2)]
    MSR->>MSR: expressionsTail = [overrides, variants, sx]
    MSR->>Emotion: styled('div')(attachTheme, style1', style2', overrides, variants, sx)
    Emotion-->>Dev: React Component

この順序は意図的なもので、CSS の詳細度を決定します。

  1. HeadattachTheme が最初に実行され、後続のすべての式が正しいスコープのテーマを参照できるようにします
  2. Body — バリアントサポートとレイヤーラッピングのために transformStyle を通じて変換された、自分で書いたスタイル関数やオブジェクト
  3. Tail — テーマオーバーライド、テーマバリアント、そして sx の順に並び、後になるほど詳細度が高くなります

この構造により、sx は常にテーマオーバーライドより優先され、テーマオーバーライドはコンポーネントのデフォルトより優先され、コンポーネントのデフォルトはベーススタイルより優先されます。CSS カスケードをプログラムで管理しているわけです。

processStyle とバリアントマッチング

48行目processStyle 関数はスタイル解決エンジンです。5種類の入力を処理します。

  1. 関数props を引数に呼び出し、結果を再帰的に処理
  2. 配列processStyle を通じて再帰的にフラットマップ
  3. variants を持つオブジェクト — ルートスタイルを取り出した後、バリアントマッチングを適用
  4. 処理済みオブジェクトisProcessed: true)— すでにシリアライズ済みのスタイルをそのまま返す
  5. プレーンオブジェクト・文字列 — そのまま返す(CSS レイヤーにラップされる場合あり)

バリアントマッチングは、85行目processStyleVariants で行われます。

function processStyleVariants(props, variants, results = [], layerName = undefined) {
  let mergedState;

  variantLoop: for (let i = 0; i < variants.length; i += 1) {
    const variant = variants[i];

    if (typeof variant.props === 'function') {
      mergedState ??= { ...props, ...props.ownerState, ownerState: props.ownerState };
      if (!variant.props(mergedState)) {
        continue;
      }
    } else {
      for (const key in variant.props) {
        if (props[key] !== variant.props[key] &&
            props.ownerState?.[key] !== variant.props[key]) {
          continue variantLoop;
        }
      }
    }

    // バリアントがマッチ — スタイルを追加
    results.push(/* resolved style */);
  }

  return results;
}

パフォーマンス上の工夫が2点あります。まず、variantLoop: というラベルと continue variantLoop の組み合わせ。これはラベル付きループと呼ばれる、あまり使われない JavaScript の機能です。内側の for...in ループが、いずれかの prop がマッチしなかった瞬間に次のバリアントへジャンプできます。Object.entries() の中間配列を生成したり Object.keys().every() を呼び出したりするコストを避けられます。すべての styled コンポーネントのレンダリングごとに実行される関数である以上、この差は無視できません。

もう1点は、mergedState の遅延初期化に使われているヌル合体代入演算子(??=)です。関数 props を使うバリアントが存在するときだけ、マージされた state オブジェクトが確保されます。大半のバリアントはオブジェクト形式なので、この割り当ては発生しません。

ヒント: テーマでバリアントを定義するときは、関数形式よりもオブジェクト形式({ props: { variant: 'contained', color: 'primary' } })を優先するとパフォーマンスが向上します。オブジェクト形式はプロパティを1つずつ直接比較しますが、関数形式ではマージされた state オブジェクトの生成が必要になるためです。

パフォーマンス最適化:memoTheme と preprocessStyles

Material UI のすべての styled コンポーネントは、スタイル関数を memoTheme でラップしています。Button のルートスタイルを見てみましょう。

const ButtonRoot = styled(ButtonBase, { name: 'MuiButton', slot: 'Root' })(
  memoTheme(({ theme }) => {
    return { /* 数百行のスタイル */ };
  })
);

packages/mui-system/src/memoTheme.ts の実装は、クロージャを使った参照同一性チェックです。

export default function unstable_memoTheme<T>(styleFn: ThemeStyleFunction<T>) {
  let lastValue: CSSInterpolation;
  let lastTheme: T;

  return function styleMemoized(props: { theme: T }) {
    let value = lastValue;
    if (value === undefined || props.theme !== lastTheme) {
      arg.theme = props.theme;
      value = preprocessStyles(styleFn(arg));
      lastValue = value;
      lastTheme = props.theme;
    }
    return value;
  };
}

テーマオブジェクトはレンダリングをまたいでも通常は同一の参照を持ちます。そのため、このメモ化によって、数十のバリアント定義やテーマ参照を含む可能性があるスタイル関数本体は、テーマが変わったときだけ実行され、レンダリングのたびに実行されることはありません。

flowchart TD
    Render1["Render 1: theme = T1"] --> Check1{"lastTheme === T1?"}
    Check1 -->|"No (first render)"| Compute["Call styleFn → preprocessStyles → cache"]
    Check1 -->|"Yes"| Return["Return cached value"]
    Compute --> Return
    Render2["Render 2: theme = T1"] --> Check2{"lastTheme === T1?"}
    Check2 -->|"Yes"| Return2["Return cached value ⚡"]

その結果は packages/mui-system/src/preprocessStyles.tspreprocessStyles に渡されます。

export default function preprocessStyles(input: any) {
  const { variants, ...style } = input;
  const result = {
    variants,
    style: internal_serializeStyles(style) as any,
    isProcessed: true,
  };

  if (variants) {
    variants.forEach((variant: any) => {
      if (typeof variant.style !== 'function') {
        variant.style = internal_serializeStyles(variant.style);
      }
    });
  }
  return result;
}

ここで Emotion の serializeStyles が呼び出され、スタイルオブジェクトが決定論的なクラス名を持つ事前ハッシュ済みの CSS 文字列に変換されます。レンダリング時ではなくメモ化のタイミングでこの処理を行うことで、CSS シリアライゼーションのコストをテーマ変更時だけに限定しています。

CSS レイヤーによる詳細度の制御

184行目transformStyle 関数は props.theme.modularCssLayers をチェックし、有効であればスタイルを @layer ディレクティブでラップします。レイヤー名はコンテキストによって決まります。

const layerName =
  (componentName && componentName.startsWith('Mui')) || !!componentSlot
    ? 'components'
    : 'custom';

パイプライン全体を通じて3つのレイヤーが使用されます。

レイヤー 使用箇所 役割
components Body の式(MUI コンポーネントのスタイル) ベースのコンポーネントスタイリング
theme Tail の式(styleOverridesvariants テーマによるカスタマイズ
sx styleFunctionSx(tail の最後) インラインのエスケープハッチ
flowchart BT
    L1["@layer components — Base component styles"]
    L2["@layer theme — styleOverrides + variants"]
    L3["@layer sx — sx prop styles"]

    L1 --> L2 --> L3

    style L3 fill:#c62828,color:#fff
    style L2 fill:#e65100,color:#fff
    style L1 fill:#1565c0,color:#fff

shallowLayer ヘルパーは、すでにシリアライズ済みの CSS をラップします。

function shallowLayer(serialized, layerName) {
  if (layerName && serialized?.styles && !serialized.styles.startsWith('@layer')) {
    serialized.styles = `@layer ${layerName}{${String(serialized.styles)}}`;
  }
  return serialized;
}

これは、注入順序に頼った詳細度管理からの大きなアーキテクチャ的な進化です。CSS レイヤーを使うことで、どのスタイルが先に読み込まれるかに関係なくカスケードが決定論的になり、SSR のハイドレーションやコード分割の挙動がはるかに予測しやすくなります。

全体の流れをたどる

<Button variant="contained" color="primary" sx={{ mt: 2 }}> がレンダリングされるときの処理を最初から追ってみましょう。

  1. Emotion が { theme, ownerState, ... } を引数にすべての式を順番に呼び出す
  2. attachTheme(head)がコンテキストから theme['$$material'] を解決する
  3. memoTheme でラップされた body の式が、事前シリアライズ済みのベーススタイルと、contained + primary にマッチするバリアントを返す
  4. styleThemeOverrides(tail)が theme.components.MuiButton.styleOverrides を確認し、存在すれば processStyleroot スロットのオーバーライドを解決する
  5. styleThemeVariants(tail)が theme.components.MuiButton.variants を確認し、マッチした {props, style} のペアを追加する
  6. styleFunctionSx(tail)が { mt: 2 }{ marginTop: '16px' } に変換し、レイヤーが有効であれば @layer sx でラップする

詳細度の順序は保証されています:ベース → オーバーライド → バリアント → sx。

ヒント: スタイルが適用されない原因を調べるときは、そのスタイルがどのゾーンにあるかを確認しましょう。sx prop がテーマオーバーライドを上書きしない場合、sx の処理がスキップされている(skipSx: true)可能性があります。バリアントがマッチしない場合は、ownerState の prop の値がバリアントの props と厳密に一致しているかを確認してください(厳密等価比較で、型の強制変換はありません)。

次回に向けて

すべての MUI コンポーネントを動かすスタイリングパイプラインを理解できました。第3回では、具体的なコンポーネントとして Button の実装を掘り下げます。5ファイル構成のディレクトリ規約、JSX から最終レンダリングまでの props 解決チェーン、ownerState パターン、そして CSS クラス生成の仕組みを見ていきます。抽象的なパイプラインから、具体的なコンポーネントの解剖へと進みましょう。