Material UI 组件 DOM 结构覆盖实战:component、slots 与 slotProps 三大 API 详解
本文基于 Material UI 官方定制指南「Overriding component structure」展开,系统讲解如何覆盖 Material UI 组件默认渲染的 DOM 结构:从 v6 版本 API 演进的背景,到 component、slots、slotProps 三个核心 props 的心智模型与用法,并结合开源仓库中 useSlot 工具函数 与 Autocomplete 源码 的实现,深入解析结构覆盖在底层的真实调用链与属性合并规则,帮助你既会用 API,也知其所以然。
背景:为什么要从 v6 开始讲结构覆盖
Material UI 组件在设计之初就面向尽可能广泛的用例,但有时你需要改变某个组件在 DOM 中的渲染方式。要理解这件事,需要先了解 API 设计的历史演进,并建立对组件结构的心智模型。
在 Material UI v6 之前,库中大多数组件的结构是无法覆盖的。部分组件提供过 *Props 形式的 props(例如把某个子节点的 props 透传出去),但这种模式在整个库中应用并不一致。v6 版本将这些 props 标记为弃用,转而统一为 slots 与 slotProps 两个 props:它们提供了对组件结构更细粒度的控制,并让 API 在整个库范围内保持一致。
心智模型:组件结构由「slot(插槽)」决定
一个组件的结构,由填充该组件 slot(插槽) 的元素决定。slot 最常见的填充物是 HTML 标签,但也可以是 React 组件。
- 所有组件都包含一个 root slot,它定义了组件在 DOM 树中的主节点;
- 更复杂的组件还会包含若干 interior slots(内部插槽),命名自它们所代表的元素。
要查看某个组件可用哪些 slot,请查阅该组件 API 文档中的 slots 小节。
所有**非工具类(non-utility)**的 Material UI 组件都接受两个用于覆盖其渲染 HTML 结构的 props:
| Prop | 作用 |
|---|---|
component |
覆盖 root slot |
slots |
替换任意内部插槽(如果存在),也可以替换 root |
此外,可以通过 slotProps 向内部插槽传递自定义 props。
根插槽(root slot)与 component prop
root slot 代表组件最外层的元素,默认由一个带有合适 HTML 标签的 styled component 填充。例如,Button 组件的 root slot 是一个 <button> 元素;Button 只有 root slot,而更复杂的组件还会有额外的内部插槽。
使用 component prop 可以覆盖组件的 root slot。官方示例演示了如何用 <a> 替换 Button 的 <button> 标签,创建一个链接按钮:
import Button from '@mui/material/Button';
export default function OverridingRootSlot() {
return (
<Button component="a" href="https://mui.com/about/" target="_blank">
About us
</Button>
);
}
完整示例可见 OverridingRootSlot.js(另有 TypeScript 版本)。
注意:
href、target、rel这些 props 是<a>标签特有的。使用componentprop 时,务必补上与目标元素对应的属性。
内部插槽(interior slots)与 slots prop
复杂组件由 root 之外的一组内部插槽组成,这些插槽通常(但不一定)嵌套在 root 之内。
以 Autocomplete 为例:它的结构是 root 层的一个 <div>,内部又包含若干以其元素命名的内部插槽:input、startDecorator、endDecorator、clearIndicator、popupIndicator,等等。
使用 slots prop 可以替换组件的内部插槽。官方示例演示了如何替换 Autocomplete 的 popper 插槽,从而移除弹出(popup)功能:
import Box from '@mui/material/Box';
import PropTypes from 'prop-types';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
function PopperComponent(props) {
const { disablePortal, anchorEl, open, ...other } = props;
return <div {...other} />;
}
PopperComponent.propTypes = {
anchorEl: PropTypes.any,
disablePortal: PropTypes.bool,
open: PropTypes.bool.isRequired,
};
export default function OverridingInternalSlot() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', width: 320, minHeight: 220 }}>
<Autocomplete
open
options={['🆘 Need help', '✨ Improvement', '🚀 New feature', '🐛 Bug fix']}
renderInput={(params) => <TextField {...params} />}
slots={{
popper: PopperComponent,
}}
/>
</Box>
);
}
完整示例可见 OverridingInternalSlot.js(含 TypeScript 版本)。
对照源码可以看到这套机制的落实方式:在 Autocomplete.js 中,组件通过 useSlot('root', {...})、useSlot('popper', {...})、useSlot('paper', {...}) 等调用逐个创建插槽,其中 PopperSlot 的默认 elementType 就是 Popper(第 635 行附近),additionalProps 中携带了 anchorEl、open、disablePortal 等内部依赖——这就是为什么替换 popper 插槽时,自定义的 PopperComponent 必须自行解构并忽略这些内部 props(open、anchorEl、disablePortal),只把剩下的 ...other(含 className)透传给原生元素。
slotProps:向插槽传递自定义 props
slotProps 是一个对象,包含组件内所有插槽的 props,用于定义传给内部插槽的额外自定义 props。例如,给 Autocomplete 的 popper 插槽加上一个自定义 data-testid:
<Autocomplete slotProps={{ popper: { 'data-testid': 'my-popper' } }} />
回调形式的 slotProps
每个 slot 的 props 也可以是一个回调函数,接收组件的 ownerState 并返回该插槽的 props。当你需要插槽 props 依赖组件的 props 或内部状态时使用这种形式:
<Popover
open={open}
slotProps={{
paper: (ownerState) => ({
elevation: ownerState.open ? 8 : 0,
}),
}}
/>
从源码看,这个回调形式是被统一支持的:useSlot.ts 中会先执行 resolveComponentProps(slotProps[name], ownerState),把「对象」或「接收 ownerState 的回调」统一解析为一个纯对象,再进入合并流程。类型定义(同文件第 42-48 行)也明确 slotProps 的每个键可以是 ExternalSlotProps | ((ownerState: OwnerState) => ExternalSlotProps)。
额外 props 会传播到 root slot
放在主组件上的所有额外 props,也会传播进 root slot(就像它们被写进了 slotProps.root 一样)。下面两个示例是等价的:
<Badge id="badge1">
<Badge slotProps={{ root: { id: 'badge1' } }}>
这一点在 useSlot.ts 的实现中有直接证据:mergeSlotProps 的入参中 externalForwardedProps: name === 'root' ? other : undefined——只有当插槽名为 root 时,组件上的其余属性(other)才会被合并进去,其他内部插槽则忽略这些额外 props。
优先级规则:如果
slotProps.root与额外 props 的键相同但值不同,slotProps.root中的 props 优先。这一规则不适用于classes与style——它们会被合并(merge),而不是覆盖。
TypeScript 类型安全
slotProps 的类型不会基于你自定义的 slots 动态变化。因此,当自定义插槽与默认插槽的类型不同时,你需要对类型做断言以避免 TypeScript 报错,并使用 satisfies(TypeScript 4.9 引入)来保证自定义插槽自身的类型安全。
例如,用 Next.js 的 Image 组件自定义 Avatar 的 img 插槽:
import Image, { ImageProps } from 'next/image';
import Avatar, { AvatarProps } from '@mui/material/Avatar';
<Avatar
slots={{
img: Image,
}}
slotProps={
{
img: {
src: 'https://example.com/image.jpg',
alt: 'Image',
width: 40,
height: 40,
blurDataURL: 'data:image/png;base64',
} satisfies ImageProps,
} as AvatarProps['slotProps']
}
/>;
要点拆解:satisfies ImageProps 保证 src、alt、width、height、blurDataURL 符合 Next.js Image 的 prop 约束;外层的 as AvatarProps['slotProps'] 则把整体断言回 Avatar 期望的 slotProps 类型,因为 AvatarProps['slotProps'] 并不知道 img 槽已被换成了 next/image 的 Image。
最佳实践
- 需要覆盖元素但保留插槽样式时,使用
component或slotProps.{slot}.componentprop。 - 需要用自定义组件整体替换插槽的样式与功能时,才使用
slotsprop。
用 component 覆盖,可以直接把目标元素的属性挂到根上。例如把 Button 的 root 覆盖为 <li> 标签时,你可以直接把 <li> 的属性 value 加到组件上;如果改用 slots.root,则必须把这个属性放进 slotProps.root 对象中,否则 TypeScript 会报错。
component 之所以能「换元素而保样式」,其机制在 useSlot.ts 中体现得很清楚:解析出最终要替换的 LeafComponent 后,代码并不直接把它作为渲染节点,而是转成 styled 组件的 as prop(第 143-146 行)。也就是说 <Button component="a"> 实际是把 styled button 重新指向 <a> 渲染,Button 的全部样式规则原样保留。
源码中还有一处值得注意的分支:shouldForwardComponentProp 标志(第 91-97 行注释与第 147-150 行实现)。当插槽的 elementType 本身是另一个 Material UI 组件的 styled component(例如 Autocomplete 的 paper 插槽默认元素是 Paper)时,若把 component 转成 as,as 会替换掉 Paper 这个组件本身、导致 Paper 的样式丢失;此时 useSlot 会把 component 通过 component 键继续向下转发给内层组件(Autocomplete.js 中 clearIndicator、popupIndicator 等插槽就显式设置了 shouldForwardComponentProp: true),从而保证「换元素不换皮」。
- 注意渲染出的 DOM 结构:覆盖复杂组件的插槽时,偏离默认结构过远容易破坏语义化、可访问的 HTML 规则——例如无意中把块级元素嵌套进行内元素。
小结
Material UI v6 之后,「结构即插槽」是理解组件渲染的核心模型:component 管 root slot 的换元素(保样式),slots 管任意插槽的换组件(连样式一起换),slotProps 管给插槽加 props(对象或回调,root 插槽还享受额外 props 自动传播与 slotProps.root 优先的合并规则)。这三个 API 的底层统一落在 useSlot 中:slots[name] || initialElementType 决定最终渲染的组件,resolveComponentProps 处理回调形式的 slotProps,mergeSlotProps 完成内部/外部 props 与 className、style 的合并,as 与 component 的转换则守护了样式不丢失的底线。掌握这套模型后,你既能在 Button 上做「链接按钮」这类简单替换,也能在 Autocomplete、Popover 这类复杂组件上做精细的结构定制,而不破坏组件原有的外观与可访问性。
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 StartedRust0624
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