通用组件入参、事件、插槽
- Standard:组件按职责拆分,单一组件只做一件事,避免”上帝组件”。
- Standard:组件复用超过 2 处时,抽取为通用组件放入
components/。
- Caution:过度拆分会增加维护成本,单处使用的逻辑不必抽取。
| 类型 | 职责 | 示例 |
|---|
| 展示组件(Presentational) | 纯展示,通过 props 接收数据 | Button、Tag、Avatar |
| 容器组件(Container) | 处理数据和逻辑,组合展示组件 | UserTable、OrderList |
| 布局组件(Layout) | 页面结构布局 | BasicLayout、Sidebar |
| 高阶组件(HOC)/ 自定义 Hook | 复用逻辑,不渲染 UI | withAuth、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' | 否 | 按钮类型 |
| disabled | boolean | false | 否 | 是否禁用 |
| onClick | (e: MouseEvent) => void | - | 否 | 点击回调 |
# Button 按钮
按钮组件,用于触发操作。
## 基础用法
```tsx
<Button type="primary">主要按钮</Button>
| 属性名 | 类型 | 默认值 | 必填 | 说明 |
|---|
| type | 'primary' | 'default' | 'danger' | 'default' | 否 | 按钮类型 |
| disabled | boolean | false | 否 | 是否禁用 |
| loading | boolean | false | 否 | 是否加载中 |
| onClick | (e) => void | - | 否 | 点击事件 |
| 事件名 | 参数 | 说明 |
|---|
| onClick | e: MouseEvent | 点击时触发 |
- **Requirement**:修改组件 Props 或行为时,必须同步更新 `README.md`。
- **Standard**:使用 Storybook 或同类工具维护组件示例,确保示例可运行。
- **Caution**:文档示例必须真实可运行,禁止提供无法执行的示例代码。
| 无组件文档 | 使用者需读源码理解 API | 提供 README.md |
| 文档与代码不一致 | 误导使用者 | 修改代码同步更新文档 |
| 示例无法运行 | 降低信任度 | 示例必须可复制运行 |