双路径主题系统:经典对象与 CSS 变量
前置知识
- ›第 1–3 篇文章
- ›CSS 自定义属性(var() 语法、级联机制)
- ›React context 与状态管理模式
双路径主题系统:经典对象与 CSS 变量
在前三篇文章中,你一定注意到样式定义里反复出现 (theme.vars || theme).palette.primary.main 这样的写法。这个表达式背后,隐藏着一个深层的架构决策:Material UI 通过统一的组件 API,同时支持两种截然不同的主题方案。经典模式将主题值存储为 JavaScript 对象,在渲染时解析;CSS 变量模式则将主题展平为 CSS 自定义属性,一次性注入文档。理解这两条路径如何分叉、又如何汇合,是掌握高级定制能力的关键。
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 变量主题,需要完整的 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 变量路径
当 cssVariables 为真值时,由 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 遍历嵌套对象,输出三个结果:
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 变量模式下,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()编写自定义组件时,引用主题 token 请始终使用(theme.vars || theme)。这样无论使用者是否开启cssVariables,你的组件都能正常运行。
配色方案管理与颜色操作
启用 CSS 变量后,createCssVarsProvider 中的 CssVarsProvider 负责管理配色方案切换。它创建 ColorSchemeContext,通过 useCurrentColorScheme hook 追踪当前方案,并将用户选择持久化到 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);
},
});
}
同一套 API,三条执行路径:
| 模式 | theme.alpha('#1976d2', 0.5) 的输出 |
|---|---|
| 经典模式 | 'rgba(25, 118, 210, 0.5)'(JavaScript 计算值) |
| CSS 变量模式 | 'rgba(var(--mui-palette-primary-mainChannel) / 0.5)'(CSS 表达式) |
原生颜色(oklch) |
'oklch(from #1976d2 l c h / 0.5)'(CSS 函数) |
开启 nativeColor 选项后,颜色空间切换为 oklch,颜色操作直接在 CSS 层完成,无需 JavaScript 计算,且感知上更加均匀一致。
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
下一步
至此,我们已经完整追踪了主题系统的两条路径——经典对象与 CSS 变量——并理解了每个组件如何通过 (theme.vars || theme) 保持对两种模式的兼容。第 5 篇将把目光从运行时代码转向构建基础设施:错误信息压缩系统、TypeScript 到 PropTypes 的代码生成、Nx 缓存机制,以及文档站点的开发工作流。