首页
/ Material UI 构建可扩展主题:品牌主题与应用主题的官方设计模式

Material UI 构建可扩展主题:品牌主题与应用主题的官方设计模式

2026-09-06 13:43:05作者:房伟宁

本文基于 Material UI 官方指南 building-extensible-themes,讲解如何构建一个可跨应用复用的品牌主题(Branded Theme):如何拆分并导出 tokens 与 components、应用层如何在不覆盖品牌样式的前提下进行扩展,以及为何官方明确反对使用 deep merge 工具。读完后,你将掌握一套完整的「品牌主题 + 应用主题」双层架构落地方案,并能从 Material UI 源码层面理解 createThemestyleOverrides 数组语法的真实处理机制。

要解决的核心问题

当一个品牌(Brand)需要为多个应用提供统一的视觉身份时,会遇到一对矛盾:

  • 一致性:颜色、字体、圆角、组件默认样式等品牌元素必须在所有消费方应用中保持一致;
  • 可定制性:每个应用又需要根据自身业务场景覆盖部分样式(比如不同的按钮 hover 效果、不同的主色)。

官方指南给出的答案是:将品牌主题作为唯一可信来源(source of truth),应用层通过「展开 tokens + 组件样式数组语法」来扩展它,而不是把两个主题对象整体做深度合并。

品牌主题:品牌视觉身份的可信来源

品牌主题通过颜色(palette)、字体排印(typography)、间距与形状(shape)等 tokens 来表达品牌的视觉身份。官方建议从同一个文件中导出 tokens、components 和最终主题对象,这样消费方既可以直接使用完整主题,也可以只按需导入其中一部分:

import { createTheme } from '@mui/material/styles';
import type { ThemeOptions } from '@mui/material/styles';

export const brandedTokens: ThemeOptions = {
  palette: {
    primary: {
      main: '#000000',
    },
    secondary: {
      main: 'rgb(229, 229, 234)',
    },
  },
  shape: {
    borderRadius: 4,
  },
  typography: {
    fontFamily:
      'var(--font-primary, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif)',
  },
};

export const brandedComponents: ThemeOptions['components'] = {
  MuiButton: {
    defaultProps: {
      disableElevation: true,
    },
    styleOverrides: {
      root: {
        minWidth: 'unset',
        textTransform: 'capitalize',
        '&:hover': {
          textDecoration: 'underline',
        },
      },
    },
  },
};

const brandedTheme = createTheme({
  ...brandedTokens,
  components: brandedComponents,
});

export default brandedTheme;

几个值得注意的设计细节:

  1. brandedTokens 的类型是 ThemeOptions。它描述的是"不完整的主题选项",由 createTheme 补全缺失部分(默认调色板、阴影、字体排印体系等)后生成完整主题。createTheme 的入口实现在 createTheme.ts:当未显式开启 cssVariables 时(默认值为 false,见该文件第 72 行 cssVariables = false),它会走与 v5 行为一致的 createThemeNoVars 路径(第 91–94 行);开启后则走 createThemeWithVars 路径(第 169 行),把 tokens 编译为 CSS 变量,支持运行时切换 color scheme。
  2. brandedComponents 的类型是 ThemeOptions['components']。从源码结构看,components 下每个组件(如 MuiButton)统一支持三个键:defaultPropsstyleOverridesvariants,这一定义在 components.tsComponents 接口中(MuiButton 部分见第 88–94 行)。
  3. fontFamily 使用 var(--font-primary, ...) 的写法:优先读取宿主应用定义的品牌 CSS 变量,未定义时回退到系统字体栈。这让品牌字体可以在不改动主题代码的情况下由应用侧注入。

按组件拆分品牌组件:更优的工程组织方式

如果品牌组件配置很大,官方建议将组件按类别拆分到多个文件,使主题消费方在应用层面可以选择性地只导入自己需要的部分

import type { ThemeOptions } from "@mui/material/styles";

export const buttonTheme: ThemeOptions["components"] = {
  MuiButtonBase: {},
  MuiButton: {},
  MuiIconButton: {},
};
import { buttonTheme } from './brandedButtons';
// import other branded components as needed

export const brandedTokens: ThemeOptions = {}

export default createTheme({
  ...brandedTokens,
  components: {
    ...buttonTheme,
    // other branded components
  },
});

这种「每个按钮类组件一个模块」的组织方式,把品牌主题的发布粒度从"整个包"细化到"某一类组件",消费方按模块引入,避免把用不到的组件配置打进 bundle。

应用主题:在品牌主题之上做增量定制

品牌主题的消费方可以直接使用它,也可以针对具体使用场景进行扩展。官方以定制品牌按钮的 hover 样式为例,展示了标准扩展写法:

import { createTheme } from '@mui/material/styles';
import { brandedTokens, brandedComponents } from './brandedTheme'; // or from an npm package.

const appTheme = createTheme({
  ...brandedTokens,
  palette: {
    ...brandedTokens.palette,
    primary: {
      main: '#1976d2',
    },
  },
  components: {
    ...brandedComponents,
    MuiButton: {
      styleOverrides: {
        root: [
          // Use array syntax to preserve the branded theme styles.
          brandedComponents?.MuiButton?.styleOverrides?.root,
          {
            '&:hover': {
              transform: 'translateY(-2px)',
            },
          },
        ],
      },
    },
  },
});

这段代码里有两个关键决策,下面分别解释。

tokens 合并:使用对象展开语法

palettetypographyshape 这类纯数据 tokens,官方推荐使用对象展开(spread)语法:先展开品牌 tokens,再覆盖具体字段。上面的例子里,应用层保留了品牌主题中 secondary 等其他配色,只替换了 primary.main#1976d2。展开语法是浅层、静态、可被静态分析工具识别的合并方式,不产生运行时开销。

components 合并:使用数组语法保留品牌样式

components,官方明确推荐使用数组语法,以确保品牌主题中的 variants、状态类样式和伪类样式(pseudo-class styles)得以保留:

root: [
  brandedComponents?.MuiButton?.styleOverrides?.root, // 品牌样式,排前面
  { '&:hover': { transform: 'translateY(-2px)' } },    // 应用样式,排后面
],

这个推荐有明确的源码依据。Material UI 在每个组件的 styled 层通过 overridesResolver 读取 theme.components[componentName].styleOverrides,再交给 processStyle 处理,见 createStyled.js。而 processStyle 对数组的处理是递归 flatMapcreateStyled.js 第 60–62 行):

if (Array.isArray(resolvedStyle)) {
  return resolvedStyle.flatMap((subStyle) => processStyle(props, subStyle, layerName));
}

其含义是:数组中每一项都会依次被解析并全部进入最终样式列表,顺序即数组顺序。因此:

  • 把品牌样式放在数组第一位、应用样式放后面,品牌的 textTransform: 'capitalize'minWidth: 'unset' 与 hover 下划线都会保留;
  • 同一 CSS 属性若两边都设置(例如都写 &:hover 下的不同属性),后面的样式因在 CSS 输出中位置更靠后而生效,符合直觉的覆盖语义;
  • 相反,如果直接写 root: { ... } 单一对象并整体替换品牌配置,品牌样式就会丢失;如果试图在 JS 里手动 deep merge 两个对象,又会落入下面要警告的陷阱。

此外,styleOverrides.root 的值还支持函数形式 ({ theme }) => ({ ... }),可以在覆盖中访问主题对象(例如 theme.breakpoints.up('md') 做响应式覆盖)。官方 demo 中品牌按钮就用了这种形式:

styleOverrides: {
  root: ({ theme }) => ({
    minWidth: 'unset',
    textTransform: 'capitalize',
    fontSize: '1rem',
    '&:hover': { textDecoration: 'underline' },
    [theme.breakpoints.up('md')]: { fontSize: '0.875rem' },
  }),
},

完整代码见配套 demo ExtensibleThemes.tsx

官方警告:不要使用 deep merge 工具合并主题

指南中有一条明确的警告,值得单独强调:

我们不推荐使用 JavaScript 函数或任何工具对品牌主题和应用主题做 deep merge。这样做会在应用首次渲染时引入性能开销,影响大小取决于主题的体量。

原因从实现角度可以理解:createTheme 本身只执行一次且成本可控;而 deep merge 意味着在主题构造路径上递归遍历两棵可能很大的主题对象树(components 覆盖全量组件、typographyshadows 等),这部分同步 CPU 开销发生在首屏渲染关键路径上,且合并出的新对象无法复用 createTheme 内部针对各 token 类型做的专门解析逻辑(如 palette 的对比色计算、fontFamily 字符串解析等)。官方的替代方案就是上面展示的「spread + 数组语法」——两者都是静态、增量、零额外遍历的合并方式。

完整示例:一个品牌主题被两个应用差异化消费

官方指南末尾的完整 demo(ExtensibleThemes.tsx)把上述模式串成了可运行的整体,展示了三个 ThemeProvider 层级下的按钮效果:

  1. brandedTheme:直接渲染品牌按钮,验证品牌主题开箱即用。它的 tokens 中还完整定义了 25 级 shadows 阴影体系,替代 Material Design 默认阴影;
  2. appTheme(App 1):把 primary.main 改为 #1976d2,并用数组语法为按钮追加 transition 与 hover 上浮效果——品牌样式(无 elevation、首字母大写、hover 下划线)全部保留;
  3. appTheme2(App 2):把 primary.main 改为 #ffa726,用 defaultProps 的 spread 语法在保留品牌 disableElevation: true 的基础上追加 variant: 'outlined',并通过数组中的主题函数引用 theme.palette.primary.dark 设置文字颜色。

App 2 的 defaultProps 合并写法同样值得注意——它用的是 spread 而非对象替换:

defaultProps: {
  ...brandedComponents?.MuiButton?.defaultProps, // 保留品牌 defaultProps
  variant: 'outlined',
},

这说明一个普适规律:对纯数据字段(tokens、defaultProps)用 spread,对样式覆盖(styleOverrides)用数组,这是贯穿整个指南的合并原则。

demo 的完整结构如下(节选):

import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import { ThemeProvider, createTheme, type ThemeOptions } from '@mui/material/styles';

const brandedTokens: ThemeOptions = { /* palette、shape、typography、shadows … */ };
const brandedComponents: ThemeOptions['components'] = {
  MuiButton: {
    defaultProps: { disableElevation: true },
    styleOverrides: { root: ({ theme }) => ({ /* 品牌按钮样式 */ }) },
  },
};
const brandedTheme = createTheme({ ...brandedTokens, components: brandedComponents });

const appTheme = createTheme({
  ...brandedTokens,
  palette: { ...brandedTokens.palette, primary: { main: '#1976d2' } },
  components: {
    ...brandedComponents,
    MuiButton: {
      styleOverrides: {
        root: [
          brandedComponents?.MuiButton?.styleOverrides?.root,
          { transition: 'transform 0.2s ease-in-out', '&:hover': { transform: 'translateY(-2px)' } },
        ],
      },
    },
  },
});

export default function ExtensibleThemes() {
  return (
    <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
      <ThemeProvider theme={brandedTheme}><Button>Branded Button</Button></ThemeProvider>
      <ThemeProvider theme={appTheme}><Button>App Button</Button></ThemeProvider>
      <ThemeProvider theme={appTheme2}><Button>App 2 Button</Button></ThemeProvider>
    </Box>
  );
}

要点总结与落地检查清单

场景 推荐做法 依据
定义品牌 tokens 从品牌主题文件导出 brandedTokens: ThemeOptions 官方指南 "Branded theme"
定义品牌组件 导出 brandedComponents: ThemeOptions['components'];组件多时按类拆分到独立文件 官方指南 "Branded theme";组件三键结构见 components.ts
应用层覆盖 tokens(palette/typography/shape) 对象展开语法:先展开品牌 tokens,再覆盖字段 官方指南 "Merging branded theme"
应用层追加组件样式 styleOverrides 值写成数组,品牌样式放第一位 官方指南;数组处理见 createStyled.js
应用层追加 defaultProps spread 品牌的 defaultProps 后再追加 官方 demo ExtensibleThemes.tsx
品牌与应用主题合并 禁止使用 JS deep merge 工具 官方指南 warning:首渲染性能开销
需要 CSS 变量/多 color scheme createTheme 中开启 cssVariables createTheme.ts 第 91 行(关闭时走 v5 行为)、第 169 行(开启时走 WithVars 路径)

最后补充适用前提:本指南面向的是以 @mui/materialcreateTheme / ThemeProvider 为核心的应用(仓库中 packages/mui-material/src/styles/ 即对应实现);若项目采用 CSS Variables 方案(cssVariables: true)或暗色模式体系,品牌主题同样可以通过导出 brandedTokens 复用,只是 createTheme 内部会改走 createThemeWithVars 分支处理。

登录后查看全文
热门项目推荐
相关项目推荐