Read OSS

双路径主题系统:经典对象与 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.varsundefined(或由 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 缓存机制,以及文档站点的开发工作流。