Read OSS

デュアルパス・テーマシステム:クラシックオブジェクトと CSS Variables

上級

前提知識

  • 第1〜3回の記事
  • CSS カスタムプロパティ(var() 構文、カスケード)
  • React context と状態管理パターン

デュアルパス・テーマシステム:クラシックオブジェクトと CSS Variables

第1〜3回を通じて、スタイル定義の中に (theme.vars || theme).palette.primary.main が繰り返し登場するのを見てきました。この式は、より深いアーキテクチャ上の意思決定が表面に現れたものです。Material UI は、単一のコンポーネント API を通じて、根本的に異なる2つのテーマアプローチをサポートしています。クラシックモードはテーマの値をレンダリング時に解決される JavaScript オブジェクトとして保持します。CSS Variables モードはテーマをフラットな CSS カスタムプロパティに変換し、ドキュメントに一度だけ注入します。この2つのパスがどこで分岐し、どこで再合流するかを理解することは、高度なカスタマイズに不可欠です。

createTheme:ルーティング関数

エントリーポイントは createTheme で、その核心はルーティングの分岐です:

export default function createTheme(options = {}, ...args) {
  const { palette, cssVariables = false, colorSchemes, ... } = options;

  if (cssVariables === false) {
    // ...classic path
    return createThemeNoVars(options, ...args);
  }

  // CSS variables path
  return createThemeWithVars({ ...other, colorSchemes, ... }, ...args);
}
flowchart TD
    CT["createTheme(options)"]
    CT --> Check{"cssVariables?"}
    Check -->|"false (default)"| NoVars["createThemeNoVars()"]
    Check -->|"true or config"| WithVars["createThemeWithVars()"]
    NoVars --> Theme1["Theme object<br/>palette.primary.main = '#1976d2'"]
    WithVars --> Theme2["Theme object<br/>vars.palette.primary.main = 'var(--mui-palette-primary-main)'"]

同じルーティングは ThemeProvider でも行われます。該当箇所は packages/mui-material/src/styles/ThemeProvider.tsx です:

export default function ThemeProvider({ theme, ...props }) {
  const noVarsTheme = React.useMemo(() => {
    const muiTheme = (THEME_ID in theme ? theme[THEME_ID] : theme);
    if (!('colorSchemes' in muiTheme)) {
      return { ...theme, vars: null };
    }
    return null;
  }, [theme]);

  if (noVarsTheme) {
    return <ThemeProviderNoVars theme={noVarsTheme} {...props} />;
  }
  return <CssVarsProvider theme={theme} {...props} />;
}

この判定はシンプルかつ巧妙です。テーマに colorSchemes があれば CSS Variables テーマと判断し、カラースキーム管理を含む完全な CssVarsProvider を使用します。なければクラシックテーマとして、単純に context への注入のみを行います。

クラシックテーマの構築

クラシックパスの createThemeNoVars は、複数のサブシステムを合成してテーマを組み立てます:

function createThemeNoVars(options = {}, ...args) {
  const {
    breakpoints: breakpointsInput,
    palette: paletteInput = {},
    typography: typographyInput = {},
    shape: shapeInput,
    ...other
  } = options;

  const palette = createPalette({ ...paletteInput });
  const systemTheme = systemCreateTheme(options);

  let muiTheme = deepmerge(systemTheme, {
    mixins: createMixins(systemTheme.breakpoints, mixinsInput),
    palette,
    shadows: shadows.slice(),
    typography: createTypography(palette, typographyInput),
    transitions: createTransitions(transitionsInput),
    zIndex: { ...zIndex },
  });

  // Deep merge any additional arguments
  muiTheme = args.reduce((acc, argument) => deepmerge(acc, argument), muiTheme);

  attachColorManipulators(muiTheme);
  return muiTheme;
}
flowchart LR
    Input["options"] --> Palette["createPalette()"]
    Input --> System["systemCreateTheme()"]
    Input --> Typography["createTypography()"]
    Input --> Transitions["createTransitions()"]

    System --> Merge["deepmerge()"]
    Palette --> Merge
    Typography --> Merge
    Transitions --> Merge
    Merge --> Manipulators["attachColorManipulators()"]
    Manipulators --> Theme["Complete Theme Object"]

各サブシステムのファクトリ関数は部分的な入力を受け取り、完全なセクションを生成します。createPalette はライト・ダークのバリアントやコントラストテキストの色、アクションのパレットエントリを生成します。createTypography はバリアント名を CSS のフォント宣言にマッピングします。最終的な結果はフラットな JavaScript オブジェクトで、theme.palette.primary.main'#1976d2' のような文字列になります。

CSS Variables パス

cssVariables が truthy の場合、127行目createThemeWithVars が処理を引き継ぎます。同じテーマ構造を構築したうえで、prepareCssVars を使ってフラットな CSS カスタムプロパティの宣言へと変換します。

@mui/system にある prepareCssVars がその中核です:

function prepareCssVars(theme, parserConfig = {}) {
  const { colorSchemes = {}, defaultColorScheme = 'light', ...otherTheme } = theme;

  const { vars: rootVars, css: rootCss, varsWithDefaults: rootVarsWithDefaults } =
    cssVarsParser(otherTheme, parserConfig);

  let themeVars = rootVarsWithDefaults;

  const colorSchemesMap = {};
  Object.entries(otherColorSchemes).forEach(([key, scheme]) => {
    const { vars, css, varsWithDefaults } = cssVarsParser(scheme, parserConfig);
    themeVars = deepmerge(themeVars, varsWithDefaults);
    colorSchemesMap[key] = { css, vars };
  });

  // ...
}

cssVarsParser はネストされたオブジェクトを走査し、3つの出力を生成します:

  • css:CSS 変数宣言のフラットなマップ({ '--mui-palette-primary-main': '#1976d2' }
  • vars:CSS 変数参照のフラットなマップ({ palette: { primary: { main: 'var(--mui-palette-primary-main)' } } }
  • varsWithDefaultsvars と同じ構造だが、フォールバック値が埋め込まれたもの

この結果として、theme.vars.palette.primary.main には文字列 'var(--mui-palette-primary-main)' が入り、theme.palette.primary.main には引き続き '#1976d2' が入ります。どちらも同じテーマオブジェクト上で参照可能です。

(theme.vars || theme) 互換パターン

ここで、コードベース全体で最も頻出するパターンの登場です。テーマの値を参照するすべてのスタイルは次のように書かれています:

color: (theme.vars || theme).palette.primary.main,

クラシックモードでは theme.varsundefined(または ThemeProvider によって明示的に null に設定)なので、この式は theme.palette.primary.main'#1976d2' と評価されます。

CSS Variables モードでは theme.vars が存在するため、theme.vars.palette.primary.main'var(--mui-palette-primary-main)' と評価されます。

flowchart TD
    Style["borderRadius: (theme.vars || theme).shape.borderRadius"]

    Style --> Classic{"theme.vars exists?"}
    Classic -->|"No (classic)"| Raw["theme.shape.borderRadius → '4px'"]
    Classic -->|"Yes (CSS vars)"| Var["theme.vars.shape.borderRadius → 'var(--mui-shape-borderRadius)'"]

    Raw --> CSS1["border-radius: 4px"]
    Var --> CSS2["border-radius: var(--mui-shape-borderRadius)"]

このパターンは Button コンポーネントだけでも数百箇所に登場します。105行目 を見てみましょう:

borderRadius: (theme.vars || theme).shape.borderRadius,

また、171〜178行目 のバリアントカラー割り当てでも同様です:

'--variant-textColor': (theme.vars || theme).palette[color].main,
'--variant-containedColor': (theme.vars || theme).palette[color].contrastText,
'--variant-containedBg': (theme.vars || theme).palette[color].main,

ヒント: MUI の styled() を使ってカスタムコンポーネントを書くときは、テーマトークンを参照する際に必ず (theme.vars || theme) を使いましょう。そうすることで、利用者が cssVariables を有効にしているかどうかに関わらず、コンポーネントが正しく動作するようになります。

カラースキーム管理とカラー操作

CSS Variables が有効な場合、createCssVarsProvider から生成される CssVarsProvider がカラースキームの切り替えを管理します。内部では ColorSchemeContext を作成し、useCurrentColorScheme フックでアクティブなスキームを追跡し、選択内容を localStorage に永続化します:

export default function createCssVarsProvider(options) {
  const {
    themeId,
    theme: defaultTheme = {},
    modeStorageKey: defaultModeStorageKey = DEFAULT_MODE_STORAGE_KEY,
    colorSchemeStorageKey: defaultColorSchemeStorageKey = DEFAULT_COLOR_SCHEME_STORAGE_KEY,
    defaultColorScheme,
    resolveTheme,
  } = options;

  const ColorSchemeContext = React.createContext(undefined);
  const useColorScheme = () => React.useContext(ColorSchemeContext) || defaultContext;

  function CssVarsProvider(props) {
    // ...manages mode, colorScheme, injects CSS variables as GlobalStyles
  }

  return { CssVarsProvider, useColorScheme, ... };
}

カラー操作システムも同様によく設計されています。createThemeNoVars.js にある attachColorManipulators 関数は、alpha()lighten()darken() メソッドをテーマオブジェクトに直接追加します。これらのメソッドは実行コンテキストを認識して動作します:

function attachColorManipulators(theme) {
  Object.assign(theme, {
    alpha(color, coefficient) {
      const obj = this || theme;
      if (obj.colorSpace) {
        return `oklch(from ${color} l c h / ${coefficient})`;
      }
      if (obj.vars) {
        return `rgba(${color.replace(/var\(--([^,\s)]+).../, 'var(--$1Channel)')} / ${coefficient})`;
      }
      return systemAlpha(color, parseAddition(coefficient));
    },
    lighten(color, coefficient) {
      const obj = this || theme;
      if (obj.colorSpace) {
        return `color-mix(in ${obj.colorSpace}, ${color}, #fff ${...})`;
      }
      return systemLighten(color, coefficient);
    },
  });
}

1つの API に対して、3つの実行パスが存在します:

モード theme.alpha('#1976d2', 0.5) の出力
クラシック 'rgba(25, 118, 210, 0.5)'(JavaScript で計算された値)
CSS Variables 'rgba(var(--mui-palette-primary-mainChannel) / 0.5)'(CSS 式)
ネイティブカラー(oklch 'oklch(from #1976d2 l c h / 0.5)'(CSS 関数)

nativeColor オプションを使うと oklch をカラースペースとして有効化でき、JavaScript による計算を介さず CSS 上で知覚的に均一なカラー操作を実現できます。

sequenceDiagram
    participant Component as Styled Component
    participant Theme as theme.alpha()
    participant Output as CSS Output

    Component->>Theme: theme.alpha(palette.primary.main, 0.5)
    alt Classic mode
        Theme->>Output: rgba(25, 118, 210, 0.5)
    else CSS Variables mode
        Theme->>Output: rgba(var(--mui-palette-primary-mainChannel) / 0.5)
    else oklch mode
        Theme->>Output: oklch(from var(--mui-palette-primary-main) l c h / 0.5)
    end

次回予告

これでテーマシステムの2つのパス——クラシックオブジェクトと CSS Variables——を両方追いかけ、(theme.vars || theme) によってすべてのコンポーネントがどちらにも対応できる仕組みを理解できました。第5回ではランタイムのコードから離れ、ビルドインフラストラクチャを掘り下げます。エラーの最小化システム、TypeScript から PropTypes への生成、Nx キャッシング、そしてドキュメントサイトの開発ワークフローを取り上げる予定です。