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