Read OSS

组件定制的五个层次

高级

前置知识

  • 第 1-5 篇文章
  • CSS 优先级与级联规则
  • CSS @layer 概念

组件定制的五个层次

一切在这里汇聚。前五篇文章分别梳理了架构、styled 流水线、组件结构、主题系统以及构建工具链。现在,我们来完整地映射定制接口——五种不同机制按优先级排列——并观察它们如何组合成一个确定、无冲突的级联体系。

Material UI 有时被批评为"难以定制"。实际情况恰恰相反:它提供的定制选项太多了,真正的挑战在于理解何时该用哪种。本文通过展示每个层次与第 2 篇中 styled 流水线的具体交互方式,来彻底厘清这个问题。

第一层:通过 Theme 设置 Default Props

这是侵入性最低的定制方式。你无需编写任何 CSS,只需修改组件的默认 prop 值:

const theme = createTheme({
  components: {
    MuiButton: {
      defaultProps: {
        variant: 'contained',
        disableElevation: true,
      },
    },
  },
});

正如第 3 篇所追踪的,这些默认值通过 DefaultPropsProvider context 经由 useDefaultProps 传递,最终在 getThemeProps 中完成解析:

function getThemeProps(params) {
  const { theme, name, props } = params;
  const config = theme.components[name];
  if (config.defaultProps) {
    return resolveProps(config.defaultProps, props);
  }
  return props;
}

resolveProps 工具函数确保显式传入的 props 始终优先于默认值——只有值为 undefined 的 prop 才会回退到默认值。

flowchart LR
    ThemeDefaults["theme.components.MuiButton.defaultProps<br/>{variant: 'contained'}"]
    UserProps["<Button color='primary' />"]
    Resolved["Final: {variant: 'contained', color: 'primary'}"]

    ThemeDefaults --> Resolved
    UserProps --> Resolved

提示: 当你希望在整个应用范围内改变组件的行为时——例如默认 variant、默认 size、默认 color——Default props 是正确的选择。如果想针对某个特定 variant 或状态组合修改外观,则需要第二层或第三层。

第二层:Theme Variants——条件样式对象

Theme variants 允许你添加全新的 prop 样式组合,或覆盖现有的组合。它们以 {props, style} 对象数组的形式定义:

const theme = createTheme({
  components: {
    MuiButton: {
      variants: [
        {
          props: { variant: 'dashed' },
          style: { border: '2px dashed currentColor' },
        },
      ],
    },
  },
});

在第 2 篇介绍的 styled 流水线中,这些 variants 作为第二个尾部表达式注入,位于第 249 行

if (componentName && !skipVariantsResolver) {
  expressionsTail.push(function styleThemeVariants(props) {
    const themeVariants = theme?.components?.[componentName]?.variants;
    if (!themeVariants) {
      return null;
    }
    return processStyleVariants(
      props, themeVariants, [],
      props.theme.modularCssLayers ? 'theme' : undefined,
    );
  });
}

注意这里的层名 'theme'——当 CSS layers 启用时,theme variants 会落入 @layer theme 块,其优先级高于位于 @layer components 中的组件基础样式。

第三层:Theme styleOverrides——针对单个 Slot 的 CSS

Style overrides 允许你用任意 CSS 精确定位子组件的特定 slot:

const theme = createTheme({
  components: {
    MuiButton: {
      styleOverrides: {
        root: { textTransform: 'none' },
        startIcon: { marginRight: 4 },
      },
    },
  },
});

第 225 行的注入逻辑会对每个 slot 调用 processStyle 处理,然后将解析后的 overrides 传入组件的 overridesResolver

expressionsTail.push(function styleThemeOverrides(props) {
  const styleOverrides = theme.components?.[componentName]?.styleOverrides;
  if (!styleOverrides) return null;

  const resolvedStyleOverrides = {};
  for (const slotKey in styleOverrides) {
    resolvedStyleOverrides[slotKey] = processStyle(
      props, styleOverrides[slotKey],
      props.theme.modularCssLayers ? 'theme' : undefined,
    );
  }

  return overridesResolver(props, resolvedStyleOverrides);
});

Button 的 overridesResolver(见第 3 篇)随后会从中选取适用的 override 键——styles.rootstyles[ownerState.variant] 等——并以数组形式返回。

Style overrides 比 variants 更强大,因为它可以独立定位每一个子组件 slot。Variant 只能对匹配 props 的组件整体生效;而 override 可以分别对 root、startIcon、endIcon 和 loading 指示器进行独立样式控制。

flowchart TD
    Overrides["theme.components.MuiButton.styleOverrides"]
    Root["root: { textTransform: 'none' }"]
    Start["startIcon: { marginRight: 4 }"]
    End["endIcon: { color: 'red' }"]

    Overrides --> Root
    Overrides --> Start
    Overrides --> End

    Root -->|"overridesResolver maps to"| ButtonRoot["ButtonRoot sub-component"]
    Start -->|"overridesResolver maps to"| ButtonStartIcon["ButtonStartIcon sub-component"]
    End -->|"overridesResolver maps to"| ButtonEndIcon["ButtonEndIcon sub-component"]

第四层:用 styled() API 创建派生组件

当 theme 级别的定制已经无法满足需求时,可以用 styled() 包裹 MUI 组件来创建一个新组件:

import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';

const GradientButton = styled(Button)({
  background: 'linear-gradient(45deg, #FE6B8B, #FF8E53)',
  color: 'white',
});

当你对一个已经经过 styled() 处理的组件再次调用 styled() 时,第 2 篇中介绍的系统会负责去重。muiStyledResolver 首先做的事情是从父组件中过滤掉 sx 处理器:

mutateStyles(tag, (styles) =>
  styles.filter((style) => style !== styleFunctionSx)
);

这样可以防止 sx 表达式被执行两次——一次来自父组件的表达式流水线,一次来自包裹层的流水线。包裹组件会拥有自己全新的 head/body/tail 表达式集合。

你编写的样式会落在新组件流水线的 body 区域,也就是说,它们在 theme 附着之后、theme overrides、theme variants 和 sx 之前处理。已有的三个 theme 定制层依然会在上面生效。

第五层:sx Prop——最高优先级的逃生通道

sx prop 是最终的覆盖手段,在表达式流水线中最后处理。它由 styleFunctionSx 实现:

function styleFunctionSx(props) {
  if (!props.sx) return null;
  const { sx, theme = EMPTY_THEME, nested } = props;
  const config = theme.unstable_sxConfig ?? defaultSxConfig;

  function process(sxInput) {
    let sxObject = typeof sxInput === 'function' ? sxInput(theme) : sxInput;
    // ...resolve breakpoints, theme-aware shorthands
  }

  return Array.isArray(sx) ? sx.map(process) : process(sx);
}

sx prop 支持三种输入形式:

  • 对象sx={{ mt: 2, color: 'primary.main' }} ——感知 theme 的简写属性
  • 函数sx={(theme) => ({ mt: theme.spacing(2) })} ——可完整访问 theme
  • 数组sx={[{ mt: 2 }, isActive && { color: 'red' }]} ——条件式组合

当 CSS layers 启用时,sx 样式会在第 71 行被包裹进 @layer sx

if (!nested && theme.modularCssLayers) {
  return { '@layer sx': sortContainerQueries(theme, removeUnusedBreakpoints(breakpoints, css)) };
}

这确保了 sx 在 MUI 所有定制层中始终拥有最高的 CSS 优先级。

利用 CSS 自定义属性提升 Variant 效率

Button 的样式揭示了一个巧妙的优化策略。与其为每种 variant × color 组合(text-primary、text-secondary、contained-primary、contained-secondary 等)分别定义样式,Button 使用 CSS 自定义属性作为间接层。

来自 Button.js 第 118-194 行

// Variant styles reference custom properties
{ props: { variant: 'contained' },
  style: {
    color: `var(--variant-containedColor)`,
    backgroundColor: `var(--variant-containedBg)`,
  },
},

// Color styles set those custom properties
...Object.entries(theme.palette)
  .filter(createSimplePaletteValueFilter())
  .map(([color]) => ({
    props: { color },
    style: {
      '--variant-textColor': (theme.vars || theme).palette[color].main,
      '--variant-containedColor': (theme.vars || theme).palette[color].contrastText,
      '--variant-containedBg': (theme.vars || theme).palette[color].main,
    },
  })),
flowchart TD
    Variant["variant: 'contained'<br/>color: var(--variant-containedColor)<br/>bg: var(--variant-containedBg)"]
    ColorPrimary["color: 'primary'<br/>--variant-containedColor: #fff<br/>--variant-containedBg: #1976d2"]
    ColorSecondary["color: 'secondary'<br/>--variant-containedColor: #fff<br/>--variant-containedBg: #9c27b0"]

    ColorPrimary -->|"CSS custom props"| Variant
    ColorSecondary -->|"CSS custom props"| Variant

如果不采用这种模式,你需要 3 种 variant × 7 种颜色 = 21 个独立样式块。借助 CSS 自定义属性,只需 3 个 variant 块 + 7 个颜色块 = 10 个块。规模增长从乘法变成了加法。当你通过 palette 添加自定义颜色时,只有颜色块会增加——variant 块保持不变。

提示: 在创建自定义组件时,对任何与其他 prop 存在乘法组合关系的 prop,都应采用 CSS 自定义属性间接模式。用 var(--your-prop) 定义 variant 布局,然后按颜色或尺寸分别设置这些属性值。这样可以大幅减少最终的 CSS 输出量。

CSS @layer 集成与优先级管理

通过 theme.modularCssLayers 启用 CSS layers 后,每个定制层都会映射到一个 CSS 级联层:

定制层 CSS Layer 优先级顺序
组件基础样式(body 表达式) @layer components 最低
Theme styleOverrides + variants(tail 表达式) @layer theme 居中
sx prop @layer sx 最高

层的分配发生在流水线的多个位置。在第 195 行transformStyle 中,body 表达式会检查 props.theme.modularCssLayers

return function styleFunctionProcessor(props) {
  return processStyle(
    props, style,
    props.theme.modularCssLayers ? layerName : undefined
  );
};

组件的层名由第 149 行name 选项决定:

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

MUI 自身的组件使用 @layer components;用户创建的 styled 组件(名称不以 Mui* 开头)则使用 @layer custom

flowchart BT
    Components["@layer components<br/>MUI base styles"]
    Custom["@layer custom<br/>User styled() components"]
    ThemeLayer["@layer theme<br/>styleOverrides + variants"]
    SxLayer["@layer sx<br/>sx prop styles"]

    Components --> Custom
    Custom --> ThemeLayer
    ThemeLayer --> SxLayer

    style SxLayer fill:#b71c1c,color:#fff
    style ThemeLayer fill:#e65100,color:#fff
    style Custom fill:#1565c0,color:#fff
    style Components fill:#283593,color:#fff

这解决了 CSS-in-JS 长期以来的一个痛点:样式注入顺序。在没有 layers 的情况下,哪个 styled 组件先渲染,其样式就先被注入;由于优先级相同,后注入的样式可能无意间覆盖前者。有了 @layer,级联顺序由 layer 声明决定,而非注入顺序。SSR 水合不匹配、代码分割的重排、动态 import 的时序问题,都不再影响优先级。

五层综合实战

让我们追踪一个经过全部五层定制的 Button:

// Layer 1: Default props
const theme = createTheme({
  components: {
    MuiButton: {
      defaultProps: { variant: 'contained' },           // Layer 1
      variants: [{ props: { size: 'xl' }, style: {} }], // Layer 2
      styleOverrides: { root: { borderRadius: 8 } },    // Layer 3
    },
  },
});

// Layer 4: Derivative component
const MyButton = styled(Button)({ fontWeight: 700 });

// Layer 5: Instance override
<MyButton sx={{ mt: 2 }}>Click me</MyButton>

这个 Button 最终生成的 CSS 包含来自全部五层的样式,每一层在优先级上依次包裹下一层:

  1. variant: 'contained' 的 default prop 从组件基础定义中选取了 contained variant 的样式
  2. theme.components.MuiButton.variants 中匹配的 theme variants 被追加进来
  3. styleOverrides.root 中的 borderRadius: 8 被应用
  4. styled() 包裹层中的 fontWeight: 700 被应用
  5. sx={{ mt: 2 }} 中的 marginTop: 16px 最后应用,拥有最高优先级

这一五层模型让你在每个抽象层级都能进行定制——从全局默认值到单个实例的覆盖——同时保持可预期、确定性的级联行为。

系列总结

在这六篇文章中,我们从 pnpm workspace 的项目根结构出发,依次深入:styled-engine 抽象层、createStyled 表达式流水线、以 Button 为范例的组件结构、双路径主题系统与 CSS 变量、构建基础设施,最终来到完整的定制接口全貌。

整体设计哲学清晰可见:每一层都是适配器,每一个适配器都可替换。 styled engine 可以替换(Emotion ↔ styled-components)。主题模式可以替换(经典模式 ↔ CSS variables 模式)。组件主题可以限定作用域(Material ↔ Joy,通过 THEME_ID)。优先级模型可以替换(注入顺序 ↔ CSS layers)。

正是这种分层、基于适配器的架构,让 Material UI 能够服务于千万个需求迥异的应用——从使用默认配置的简单 dashboard,到拥有自定义主题、variant 扩展和实例级覆盖的复杂设计系统——而这一切都源自同一套代码库。