跳转到内容

组件设计规范

通用组件入参、事件、插槽


  • Standard:组件按职责拆分,单一组件只做一件事,避免”上帝组件”。
  • Standard:组件复用超过 2 处时,抽取为通用组件放入 components/。
  • Caution:过度拆分会增加维护成本,单处使用的逻辑不必抽取。
类型职责示例
展示组件(Presentational)纯展示,通过 props 接收数据Button、Tag、Avatar
容器组件(Container)处理数据和逻辑,组合展示组件UserTable、OrderList
布局组件(Layout)页面结构布局BasicLayout、Sidebar
高阶组件(HOC)/ 自定义 Hook复用逻辑,不渲染 UIwithAuth、useRequest
  • Standard:单个组件文件不超过 300 行,超过时考虑拆分为子组件。
  • Standard:组件 JSX 嵌套层级不超过 5 层,超过时提取子组件。
  • Suggestion:列表项(row/item)单独拆分为组件,避免列表组件过于臃肿。
// UserTable 容器组件
export function UserTable() {
  const { data, loading } = useUserList();
 
  return (
    <Table dataSource={data} loading={loading} rowKey="id">
      <Column title="用户名" dataIndex="name" render={(name) => <UserName name={name} />} />
      <Column title="操作" render={(_, record) => <UserActions user={record} />} />
    </Table>
  );
}
 
// UserName 展示组件
function UserName({ name }: { name: string }) {
  return <span style={{ fontWeight: 500 }}>{name}</span>;
}
 
// UserActions 展示组件
function UserActions({ user }: { user: User }) {
  const navigate = useNavigate();
  return (
    <Space>
      <Button size="small" onClick={() => navigate(`/user/edit/${user.id}`)}>编辑</Button>
      <Button size="small" danger onClick={() => handleDelete(user.id)}>删除</Button>
    </Space>
  );
}
反模式问题建议
单组件超过 500 行难以阅读和维护按职责拆分
列表组件内联所有列渲染逻辑混乱列渲染抽取为子组件
单处使用的逻辑也抽组件过度设计,增加跳转成本单处使用不抽取

  • Requirement:Props 名称使用 camelCase,布尔值以 is/has/can/should 开头。
  • Standard:事件回调以 on 开头,如 onClick、onChange、onVisibleChange。
  • Standard:数据类 props 使用名词,如 data、options、value。
  • Requirement:必须为组件 Props 定义 TypeScript 接口,禁止使用 any。
  • Standard:Props 接口命名为 组件名 + Props,如 ButtonProps、UserTableProps。
  • Caution:Props 数量超过 7 个时,考虑使用配置对象(config object)模式。
  • Standard:非必填 props 必须提供默认值,通过 defaultProps 或参数默认值。
  • Requirement:必填 props 在 TypeScript 接口中不标记 ?,使用 JSDoc 注释说明用途。
  • Standard:表单类组件同时支持受控(value + onChange)和非受控(defaultValue)模式。
  • Caution:禁止在同一组件中混用受控和非受控,运行时必须保持一致。
export interface ButtonProps {
  /** 按钮类型 */
  type?: 'primary' | 'default' | 'danger';
  /** 是否禁用 */
  disabled?: boolean;
  /** 是否加载中 */
  loading?: boolean;
  /** 点击事件 */
  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
  /** 按钮内容 */
  children?: React.ReactNode;
}
 
export function Button({
  type = 'default',
  disabled = false,
  loading = false,
  onClick,
  children,
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${type}`}
      disabled={disabled || loading}
      onClick={onClick}
    >
      {loading && <Spinner />}
      {children}
    </button>
  );
}
反模式问题建议
props 用 any 类型失去类型保护定义具体接口
布尔值 props 不以 is/has 开头语义不清使用 isDisabled 而非 disabled(样式属性除外)
回调不以 on 开头不符合约定统一 onXxx 命名
非必填 props 无默认值可能出现 undefined 错误提供默认值

  • Requirement:自定义事件名称使用动词短语,以 on 开头,camelCase 命名。
  • Standard:事件名称应描述”发生了什么”,而非”要做什么”,如 onItemSelect 而非 onSelectItem。
  • Standard:状态变化事件使用 onXxxChange,如 onVisibleChange、onValueChange。
  • Standard:事件回调参数遵循”数据在前,事件在后”的顺序:onChange(value, event)。
  • Standard:复杂场景传递包含所有相关信息的对象,而非多个参数。
  • Standard:组件应透传原生事件给调用方,如 onClick、onBlur,方便扩展。
  • Caution:透传原生事件时不要阻止冒泡(stopPropagation),除非有明确理由。
// Select 组件事件
export interface SelectProps {
  value?: string;
  onChange?: (value: string, option: Option) => void;
  onSearch?: (keyword: string) => void;
  onVisibleChange?: (visible: boolean) => void;
}
 
// 使用
<Select
  value={selected}
  onChange={(value, option) => setSelected(value)}
  onSearch={(keyword) => fetchOptions(keyword)}
  onVisibleChange={(visible) => console.log('下拉展开:', visible)}
/>
反模式问题建议
事件名用命令式 handleClick应描述发生了什么使用 onClick
回调参数过多(超过 3 个)调用方难以记忆封装为对象
阻止原生事件冒泡影响父组件事件除非必要不阻止

  • Standard:React 使用 children 传递默认插槽内容,使用 render props 传递具名插槽。
  • Standard:当插槽内容需要访问组件内部状态时,使用 render props(函数子节点)。
// 默认插槽
<Card title="用户信息">
  <p>这是卡片内容</p>
</Card>
 
// 具名插槽(通过 props)
interface CardProps {
  title?: React.ReactNode;
  extra?: React.ReactNode;
  children?: React.ReactNode;
}
 
<Card title="用户信息" extra={<Button>编辑</Button>}>
  <p>卡片内容</p>
</Card>
 
// render props
<Table
  dataSource={data}
  columns={[
    {
      title: '状态',
      render: (status) => <Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag>,
    },
  ]}
/>
  • Standard:Vue 中默认插槽使用 <slot />,具名插槽使用 <slot name="header" />。
  • Standard:作用域插槽(scoped slot)通过 v-slot 接收组件内部数据。
  • Standard:插槽内容为空时,提供默认内容作为 fallback。
<!-- Modal 组件 -->
<template>
  <div class="modal">
    <header class="modal-header">
      <slot name="title">
        <h3>{{ title }}</h3>
      </slot>
    </header>
    <div class="modal-body">
      <slot />
    </div>
    <footer class="modal-footer">
      <slot name="footer">
        <Button @click="$emit('cancel')">取消</Button>
        <Button type="primary" @click="$emit('confirm')">确定</Button>
      </slot>
    </footer>
  </div>
</template>
 
<!-- 使用 -->
<Modal title="确认删除" @confirm="handleDelete">
  <p>确定要删除吗?</p>
  <template #footer>
    <Button @click="close">关闭</Button>
    <Button danger @click="handleDelete">确认删除</Button>
  </template>
</Modal>
  • Caution:插槽数量不宜过多(建议不超过 3 个),过多时考虑通过 props 配置而非插槽。
  • Standard:插槽必须提供合理的默认内容,避免空状态。
反模式问题建议
插槽数量超过 5 个API 过于复杂改用 props 配置
插槽无默认内容不传入时显示空白提供 fallback
React 用字符串区分插槽类型不安全使用 props 传递 ReactNode

  • Requirement:每个通用组件必须在组件目录下提供 README.md,包含:
    • 组件说明与用途
    • Props 表格(名称、类型、默认值、说明、是否必填)
    • 事件列表
    • 插槽说明(如有)
    • 基础用法示例
    • 进阶用法示例
属性名类型默认值必填说明
type'primary' | 'default' | 'danger''default'否按钮类型
disabledbooleanfalse否是否禁用
onClick(e: MouseEvent) => void-否点击回调
# Button 按钮
 
按钮组件,用于触发操作。
 
## 基础用法
 
```tsx
<Button type="primary">主要按钮</Button>
属性名类型默认值必填说明
type'primary' | 'default' | 'danger''default'否按钮类型
disabledbooleanfalse否是否禁用
loadingbooleanfalse否是否加载中
onClick(e) => void-否点击事件
事件名参数说明
onClicke: MouseEvent点击时触发
### 文档维护
- **Requirement**:修改组件 Props 或行为时,必须同步更新 `README.md`。
- **Standard**:使用 Storybook 或同类工具维护组件示例,确保示例可运行。
- **Caution**:文档示例必须真实可运行,禁止提供无法执行的示例代码。
### 反模式
| 反模式 | 问题 | 建议 |
|--------|------|------|
| 无组件文档 | 使用者需读源码理解 API | 提供 README.md |
| 文档与代码不一致 | 误导使用者 | 修改代码同步更新文档 |
| 示例无法运行 | 降低信任度 | 示例必须可复制运行 |