デュアルパス・テーマシステム:クラシックオブジェクトと 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)' } } })varsWithDefaults:varsと同じ構造だが、フォールバック値が埋め込まれたもの
この結果として、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.vars は undefined(または 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 キャッシング、そしてドキュメントサイトの開発ワークフローを取り上げる予定です。