Material UI 创建主题化自定义组件:Slots、ownerState 与 useThemeProps 完整实践
本文基于 Material UI 官方文档「Creating themed components」,讲解如何把你的自定义组件接入 Material UI 的主题系统,使其像内置组件一样接受 components 配置(styleOverrides、variants、defaultProps)。通过本篇指南,你将掌握:用 styled API 的 name/slot 参数定义组件插槽(slots)、用 ownerState 将 prop 传入插槽参与样式计算、用 useThemeProps 支持主题默认 props,以及完整的 TypeScript 类型接入方案,最终产出一个可跨项目复用的 Stat 统计组件模板。
这套方案适用于在 Material UI 之上构建组件库的团队——组件一旦按此规范开发,就可以在任何消费方的 createTheme 中直接定制。文档同时给出了一条务实建议:如果你的组件只在单个项目中使用,其实并不需要将组件接入主题系统;只有需要跨项目可主题化(themeable)时才值得走完整流程。
1. 组件插槽(Slots):让每个元素可被主题定位
Material UI 的主题系统通过 theme 的 styleOverrides 和 variants 两个配置点来定制组件外观,而这两个配置都以「组件名 + 插槽名」为定位坐标。因此第一步是为自定义组件定义插槽。
以文档中的统计组件为例,它由三个插槽组成:
root:组件的容器元素;value:统计数值;unit:统计单位的说明文字。
官方建议:无论插槽叫什么名字,最外层容器元素统一命名为 root,以保持与库内其余组件的一致性。
使用 styled API 并传入 name(组件名)和 slot(插槽名)两个选项来创建插槽:
import * as React from 'react';
import { styled } from '@mui/material/styles';
const StatRoot = styled('div', {
name: 'MuiStat', // The component name
slot: 'root', // The slot name
})(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
}));
const StatValue = styled('div', {
name: 'MuiStat',
slot: 'value',
})(({ theme }) => ({
...theme.typography.h3,
}));
const StatUnit = styled('div', {
name: 'MuiStat',
slot: 'unit',
})(({ theme }) => ({
...theme.typography.body2,
color: theme.palette.text.secondary,
}));
这三个 styled 组件都声明 name: 'MuiStat',意味着它们共享同一个主题定制命名空间,仅靠 slot 区分具体元素。
从源码结构看,name/slot 参数的处理位于 createStyled 实现:styled 工厂函数接收 tag 与 inputOptions,从中解析出组件名与插槽名。此外,仓库中的 完整模板 还在插槽样式里直接使用了 variants 数组(variant: 'outlined' 时加边框、去掉阴影),这与文档分步指南中通过 ownerState 条件展开的方式是等价的两种写法——variants 写法可以让样式随主题被解析并支持 CSS 层叠,而条件展开写法对内部状态更灵活。
2. 组装组件并应用主题
有了插槽之后,用 React.forwardRef 把三个插槽组装成完整组件:
// /path/to/Stat.js
import * as React from 'react';
const StatRoot = styled('div', {
name: 'MuiStat',
slot: 'root',
})(…);
const StatValue = styled('div', {
name: 'MuiStat',
slot: 'value',
})(…);
const StatUnit = styled('div', {
name: 'MuiStat',
slot: 'unit',
})(…);
const Stat = React.forwardRef(function Stat(props, ref) {
const { value, unit, ...other } = props;
return (
<StatRoot ref={ref} {...other}>
<StatValue>{value}</StatValue>
<StatUnit>{unit}</StatUnit>
</StatRoot>
);
});
export default Stat;
此时消费方就可以像定制内置组件一样,在 createTheme 中按「组件名 → 插槽名」定位样式:
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
components: {
// the component name defined in the `name` parameter
// of the `styled` API
MuiStat: {
styleOverrides: {
// the slot name defined in the `slot` and `overridesResolver` parameters
// of the `styled` API
root: {
backgroundColor: '#121212',
},
value: {
color: '#fff',
},
unit: {
color: '#888',
},
},
},
},
});
关键在于 styleOverrides 的键必须与 styled API 中 slot 参数一一对应,MuiStat 则对应 name 参数——这就是第 1 步声明 name/slot 的意义所在:它建立了主题配置与 DOM 元素之间的映射。
3. 用 ownerState 让插槽感知组件 prop
当组件有 variant 这类需要影响内部插槽样式的 prop 时,直接解构传递会导致插槽无法读取到它(它不会被 spread 到需要它的地方)。官方方案是:把需要样式化的 prop 和内部状态封装进 ownerState 对象,作为 prop 传给每个插槽。
ownerState 是一个特殊名称,styled API 不会把它透传到 DOM,因此它只参与样式计算、不污染渲染结果。先给 Stat 增加 variant prop:
const Stat = React.forwardRef(function Stat(props, ref) {
+ const { value, unit, variant, ...other } = props;
+
+ const ownerState = { ...props, variant };
return (
- <StatRoot ref={ref} {...other}>
- <StatValue>{value}</StatValue>
- <StatUnit>{unit}</StatUnit>
- </StatRoot>
+ <StatRoot ref={ref} ownerState={ownerState} {...other}>
+ <StatValue ownerState={ownerState}>{value}</StatValue>
+ <StatUnit ownerState={ownerState}>{unit}</StatUnit>
+ </StatRoot>
);
});
然后在插槽内读取 ownerState,基于 variant 计算样式:
const StatRoot = styled('div', {
name: 'MuiStat',
slot: 'root',
- })(({ theme }) => ({
+ })(({ theme, ownerState }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
+ ...ownerState.variant === 'outlined' && {
+ border: `2px solid ${theme.palette.divider}`,
+ },
}));
「ownerState 不会 spread 到 DOM」这一点在源码中有明确依据:createStyled 中的属性过滤函数会显式排除 ownerState、theme、sx、as 这几个属性,保证它们只留在样式层。另外从源码结构看,processStyleVariants(同文件)在匹配 variants 配置时会合并 props 与 props.ownerState 后再比较 variant.props——这解释了为什么在模板文件里 variants 的 props: { variant: 'outlined' } 能直接命中 variant prop,也印证了 ownerState 是插槽样式访问组件状态的标准通道。
4. 支持主题默认 props(useThemeProps)
不同消费项目可能希望以主题为单位定制组件的默认 props(例如全局默认 variant: 'outlined')。这一步必须使用 useThemeProps API:
+ import { useThemeProps } from '@mui/material/styles';
- const Stat = React.forwardRef(function Stat(props, ref) {
+ const Stat = React.forwardRef(function Stat(inProps, ref) {
+ const props = useThemeProps({ props: inProps, name: 'MuiStat' });
const { value, unit, ...other } = props;
return (
<StatRoot ref={ref} {...other}>
<StatValue>{value}</StatValue>
<StatUnit>{unit}</StatUnit>
</StatRoot>
);
});
name: 'MuiStat' 必须与 styled 的 name 保持一致,这样主题中该组件条目下的 defaultProps 才会被合入。之后消费方即可在主题里覆盖默认值:
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
components: {
MuiStat: {
defaultProps: {
variant: 'outlined',
},
},
},
});
从实现看,@mui/material 的 useThemeProps 是对 mui-system 版本 的封装:它先通过 useTheme 取到当前主题(支持 themeId 命名空间),再调用 getThemeProps({ theme, name, props }) 完成「主题 defaultProps 打底 + 传入 props 覆盖」的合并。这也意味着 useThemeProps 必须在 forwardRef 回调内部调用,以保证始终读取当前主题上下文。
5. TypeScript 接入:Props、ownerState 与模块声明
使用 TypeScript 时,需要为组件 props 与 ownerState 分别定义接口:
interface StatProps {
value: number | string;
unit: string;
variant?: 'outlined';
}
interface StatOwnerState extends StatProps {
// …key value pairs for the internal state that you want to style the slot
// but don't want to expose to the users
}
StatOwnerState 在 StatProps 基础上扩展——既可以复用对外的 prop,也可以加入「只想用于插槽样式、不想暴露给用户」的内部状态键值对。然后把类型注入插槽与组件:
const StatRoot = styled('div', {
name: 'MuiStat',
slot: 'root',
})<{ ownerState: StatOwnerState }>(({ theme, ownerState }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
// typed-safe access to the `variant` prop
...(ownerState.variant === 'outlined' && {
border: `2px solid ${theme.palette.divider}`,
boxShadow: 'none',
}),
}));
// …do the same for other slots
const Stat = React.forwardRef<HTMLDivElement, StatProps>(function Stat(inProps, ref) {
const props = useThemeProps({ props: inProps, name: 'MuiStat' });
const { value, unit, variant, ...other } = props;
const ownerState = { ...props, variant };
return (
<StatRoot ref={ref} ownerState={ownerState} {...other}>
<StatValue ownerState={ownerState}>{value}</StatValue>
<StatUnit ownerState={ownerState}>{unit}</StatUnit>
</StatRoot>
);
});
注意 styled 调用后的泛型参数 <{ ownerState: StatOwnerState }> 让插槽内对 ownerState.variant 的访问获得类型安全;组件本体的 forwardRef<HTMLDivElement, StatProps> 则保证 ref 与 props 的类型正确。
最后一步是把 Stat 注册进主题类型系统,使消费方在 createTheme 中写 MuiStat 配置时能获得完整的类型提示与校验:
import {
ComponentsOverrides,
ComponentsVariants,
Theme as MuiTheme,
} from '@mui/material/styles';
import { StatProps } from 'path/to/Stat';
type Theme = Omit<MuiTheme, 'components'>;
declare module '@mui/material/styles' {
interface ComponentNameToClassKey {
MuiStat: 'root' | 'value' | 'unit';
}
interface ComponentsPropsList {
MuiStat: Partial<StatProps>;
}
interface Components {
MuiStat?: {
defaultProps?: ComponentsPropsList['MuiStat'];
styleOverrides?: ComponentsOverrides<Theme>['MuiStat'];
variants?: ComponentsVariants['MuiStat'];
};
}
}
三处声明各司其职:ComponentNameToClassKey 声明该组件拥有的插槽集合(对应生成的 class 键);ComponentsPropsList 声明 defaultProps 的合法取值;Components 接口把 MuiStat 正式加入 components 配置类型,使其 styleOverrides/variants 均受类型约束。
6. 完整模板与验证
上述四步的完整产物即仓库内的 Stat 组件模板(JS 版) 与 TypeScript 版,可直接作为自建主题化组件的起点。模板要点回顾:
- 三个插槽统一声明
name: 'MuiStat',分别使用slot: 'root' | 'value' | 'unit'; - 插槽样式中直接携带
variants数组,将variant: 'outlined'映射为边框样式; - 组件内先经
useThemeProps({ props: inProps, name: 'MuiStat' })合并主题默认 props,再构造ownerState并下发到每个插槽; - JS 版附带
propTypes声明(value: number | string、unit: string、variant: 'outlined'),便于运行时校验。
最终效果:消费方既可以在主题里写 styleOverrides.root/value/unit 覆盖外观,也可以写 defaultProps.variant 改变默认行为——自定义组件在 API 层面与内置组件完全同构。
7. 适用前提与小结
- 适用前提:方案基于
@mui/material/styles导出的styled、createTheme、useThemeProps,适用于 Material UI 当前的主题系统;单项目内使用的简单组件可跳过整条主题化流程。 - 四个关键约定:
name对应主题components键名、slot对应styleOverrides键名、ownerState承载插槽可见的组件状态且不透传 DOM、useThemeProps的name必须与styled的name一致。 - 可继续深入的路径:styled API 源码 中
name/slot/overridesResolver的解析逻辑、useThemeProps 实现 的主题合并流程,以及官方 Theme components 文档 中styleOverrides与variants的完整配置格式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00