Material-UI 自定义组件 #
概述 #
创建自定义组件是构建可维护 UI 库的关键。本章节介绍如何基于 MUI 创建高质量的自定义组件。
组件设计原则 #
单一职责 #
jsx
function UserAvatar({ name, src }) {
return (
<Avatar src={src}>
{name.charAt(0).toUpperCase()}
</Avatar>
);
}
function UserCard({ user }) {
return (
<Card>
<CardContent>
<UserAvatar name={user.name} src={user.avatar} />
<Typography>{user.name}</Typography>
</CardContent>
</Card>
);
}
可配置性 #
jsx
function CustomButton({
variant = 'contained',
color = 'primary',
size = 'medium',
children,
...props
}) {
return (
<Button variant={variant} color={color} size={size} {...props}>
{children}
</Button>
);
}
可扩展性 #
jsx
function CustomCard({ title, subtitle, actions, children, ...props }) {
return (
<Card {...props}>
<CardHeader title={title} subheader={subtitle} action={actions} />
<CardContent>{children}</CardContent>
</Card>
);
}
创建基础组件 #
样式组件 #
jsx
import { styled } from '@mui/material/styles';
import { Button } from '@mui/material';
const PrimaryButton = styled(Button)(({ theme }) => ({
borderRadius: 24,
textTransform: 'none',
fontWeight: 600,
padding: theme.spacing(1.5, 3),
boxShadow: 'none',
'&:hover': {
boxShadow: 'none',
},
}));
const SecondaryButton = styled(Button)(({ theme }) => ({
borderRadius: 24,
textTransform: 'none',
fontWeight: 600,
border: `2px solid ${theme.palette.primary.main}`,
'&:hover': {
backgroundColor: 'transparent',
borderColor: theme.palette.primary.dark,
},
}));
组合组件 #
jsx
import { Card, CardContent, CardActions, Typography, Button, Stack } from '@mui/material';
function InfoCard({ title, description, actionText, onAction }) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{title}
</Typography>
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
</CardContent>
{actionText && (
<CardActions>
<Button size="small" onClick={onAction}>
{actionText}
</Button>
</CardActions>
)}
</Card>
);
}
布局组件 #
jsx
import { Box, Container } from '@mui/material';
function PageLayout({ children, maxWidth = 'lg' }) {
return (
<Container maxWidth={maxWidth} sx={{ py: 4 }}>
{children}
</Container>
);
}
function SectionLayout({ title, children }) {
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom>
{title}
</Typography>
{children}
</Box>
);
}
高级组件模式 #
Render Props #
jsx
function DataFetcher({ url, render }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return render({ data, loading, error });
}
<DataFetcher
url="/api/users"
render={({ data, loading, error }) => {
if (loading) return <CircularProgress />;
if (error) return <Alert severity="error">{error.message}</Alert>;
return <UserList users={data} />;
}}
/>;
高阶组件 #
jsx
function withLoading(WrappedComponent) {
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
);
}
return <WrappedComponent {...props} />;
};
}
const UserListWithLoading = withLoading(UserList);
Compound Components #
jsx
import { createContext, useContext } from 'react';
const TabsContext = createContext();
function Tabs({ value, onChange, children }) {
return (
<TabsContext.Provider value={{ value, onChange }}>
<Box>{children}</Box>
</TabsContext.Provider>
);
}
function TabsList({ children }) {
return (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Stack direction="row" spacing={2}>
{children}
</Stack>
</Box>
);
}
function Tab({ value, children }) {
const { value: selectedValue, onChange } = useContext(TabsContext);
const isSelected = value === selectedValue;
return (
<Button
onClick={() => onChange(value)}
sx={{
borderBottom: isSelected ? 2 : 0,
borderColor: 'primary.main',
color: isSelected ? 'primary.main' : 'text.secondary',
}}
>
{children}
</Button>
);
}
function TabPanel({ value, children }) {
const { value: selectedValue } = useContext(TabsContext);
if (value !== selectedValue) return null;
return <Box>{children}</Box>;
}
Tabs.List = TabsList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
function App() {
const [value, setValue] = useState(0);
return (
<Tabs value={value} onChange={setValue}>
<Tabs.List>
<Tabs.Tab value={0}>标签一</Tabs.Tab>
<Tabs.Tab value={1}>标签二</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value={0}>内容一</Tabs.Panel>
<Tabs.Panel value={1}>内容二</Tabs.Panel>
</Tabs>
);
}
表单组件 #
表单字段组件 #
jsx
import { TextField, FormControl, FormLabel, FormHelperText } from '@mui/material';
function FormField({ label, error, helperText, required, ...props }) {
return (
<FormControl fullWidth error={error} required={required}>
{label && <FormLabel sx={{ mb: 1 }}>{label}</FormLabel>}
<TextField error={error} {...props} />
{helperText && <FormHelperText>{helperText}</FormHelperText>}
</FormControl>
);
}
表单组件 #
jsx
import { Box, Button, Stack } from '@mui/material';
function Form({ onSubmit, children, submitText = '提交' }) {
return (
<Box component="form" onSubmit={onSubmit}>
<Stack spacing={2}>
{children}
<Button type="submit" variant="contained" fullWidth>
{submitText}
</Button>
</Stack>
</Box>
);
}
function LoginForm() {
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
console.log(Object.fromEntries(formData));
};
return (
<Form onSubmit={handleSubmit} submitText="登录">
<FormField label="邮箱" name="email" type="email" required />
<FormField label="密码" name="password" type="password" required />
</Form>
);
}
数据展示组件 #
数据表格组件 #
jsx
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, TablePagination } from '@mui/material';
function DataTable({ columns, data, pagination, onPageChange, onRowsPerPageChange }) {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const handleChangePage = (event, newPage) => {
setPage(newPage);
onPageChange?.(newPage);
};
const handleChangeRowsPerPage = (event) => {
const newRowsPerPage = parseInt(event.target.value, 10);
setRowsPerPage(newRowsPerPage);
setPage(0);
onRowsPerPageChange?.(newRowsPerPage);
};
return (
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell key={column.field} align={column.align}>
{column.headerName}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{data
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row, index) => (
<TableRow key={index}>
{columns.map((column) => (
<TableCell key={column.field} align={column.align}>
{column.renderCell
? column.renderCell(row)
: row[column.field]}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{pagination && (
<TablePagination
component="div"
count={data.length}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
)}
</Paper>
);
}
空状态组件 #
jsx
import { Box, Typography, Button } from '@mui/material';
import { Inbox as InboxIcon } from '@mui/icons-material';
function EmptyState({ title, description, action, actionText }) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
textAlign: 'center',
}}
>
<InboxIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description}
</Typography>
)}
{action && actionText && (
<Button variant="contained" onClick={action}>
{actionText}
</Button>
)}
</Box>
);
}
组件库组织 #
目录结构 #
text
src/
├── components/
│ ├── ui/
│ │ ├── Button/
│ │ │ ├── Button.jsx
│ │ │ ├── Button.styles.js
│ │ │ ├── Button.test.jsx
│ │ │ └── index.js
│ │ ├── Card/
│ │ ├── Input/
│ │ └── index.js
│ ├── layout/
│ │ ├── PageLayout/
│ │ ├── SectionLayout/
│ │ └── index.js
│ ├── forms/
│ │ ├── FormField/
│ │ ├── Form/
│ │ └── index.js
│ └── data/
│ ├── DataTable/
│ ├── EmptyState/
│ └── index.js
└── theme/
├── index.js
├── palette.js
├── typography.js
└── components.js
组件导出 #
jsx
export { default as Button } from './Button';
export { default as Card } from './Card';
export { default as Input } from './Input';
TypeScript 支持 #
组件类型定义 #
tsx
import { ButtonProps as MuiButtonProps } from '@mui/material/Button';
interface CustomButtonProps extends MuiButtonProps {
variant?: 'primary' | 'secondary' | 'gradient';
size?: 'small' | 'medium' | 'large';
}
export function CustomButton({
variant = 'primary',
size = 'medium',
children,
...props
}: CustomButtonProps) {
return (
<Button variant={variant} size={size} {...props}>
{children}
</Button>
);
}
下一步 #
继续学习 性能优化,了解 MUI 应用的性能优化技巧!
最后更新:2026-03-28