组件拆分的质量直接决定了 React 项目的可维护性和可扩展性。拆分不足会导致巨型“上帝组件”,拆分过细又可能引入不必要的抽象成本。遵循以下三个核心原则,能帮助你在实际开发中找到合理的拆分粒度。
单一职责:一个组件只做一件事
单一职责原则(Single Responsibility Principle)要求每个组件只承担一个明确的功能。判断标准很简单:当你能用一句话说清楚这个组件是干什么的,且这句话里没有“和”字。
为什么重要
- 易于理解:接手代码的开发者能快速明白组件的作用,无需深入阅读全部实现。
- 改动隔离:当需求变化时,只需修改相关职责的组件,不影响其他功能。
- 降低耦合:每个组件依赖的数据和行为最小化,组件之间关联更弱。
如何实践
识别职责边界: 一个组件同时做了以下多件事时,就应考虑拆分:
- 数据获取(API 请求)
- 数据处理(格式转换、过滤、排序)
- UI 渲染(布局 + 样式)
- 用户交互处理
反面示例:
function UserProfilePage() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [editing, setEditing] = useState(false);
useEffect(() => {
fetch('/api/user').then(res => res.json()).then(setUser);
fetch('/api/posts').then(res => res.json()).then(setPosts);
}, []);
if (!user) return <Spinner />;
return (
<div>
<h1>{user.name}</h1>
<button onClick={() => setEditing(!editing)}>
{editing ? '取消' : '编辑'}
</button>
{editing && <EditUserForm user={user} onSave={() => setEditing(false)} />}
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
</div>
);
}
这个组件同时负责数据获取、用户信息展示、编辑状态控制、帖子列表渲染,职责混杂。
正面示例:
// 自定义 Hook:封装数据获取
function useUserData() {
const [user, setUser] = useState(null);
useEffect(() => { fetch('/api/user').then(r => r.json()).then(setUser); }, []);
return user;
}
// 独立组件:负责用户信息展示
function UserInfo({ user }) {
return <h1>{user.name}</h1>;
}
// 独立组件:负责帖子列表
function PostList({ posts }) {
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// 页面组件:只负责组合和协调
function UserProfilePage() {
const user = useUserData();
const [editing, setEditing] = useState(false);
if (!user) return <Spinner />;
return (
<div>
<UserInfo user={user} />
<button onClick={() => setEditing(!editing)}>
{editing ? '取消' : '编辑'}
</button>
{editing && <EditUserForm user={user} onSave={() => setEditing(false)} />}
<PostList posts={user.posts} />
</div>
);
}
拆分后,UserInfo、PostList、EditUserForm 各司其职,页面组件只负责组合和状态协调。
可复用性:一次定义,多处使用
可复用性是组件拆分的直接收益,但不应为了复用而过早抽象。真正有价值的复用组件是那些在不同场景下经过验证的通用模块。
判断可复用的信号
- 同一段 UI 结构在项目中出现 3 次以上。
- 该 UI 模式不仅在当前页面用,其他页面或模块也可能需要。
- 组件不依赖特定的业务上下文(使用通用 Props,不依赖全局状态或特定 API 格式)。
设计可复用组件
参数化差异,内聚共性: 将变化的部分通过 Props 暴露,固定的部分内聚在组件内部。
// 通用按钮:通过 variant 和 children 控制外观和内容
function Button({ variant = 'primary', children, onClick, disabled }) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
谨慎处理业务逻辑: 可复用组件不应包含特定业务逻辑,例如在 Button 内部执行“添加购物车”操作。业务操作应由父组件通过 onClick 回调传入。
不要过度抽象: 如果两个组件的相似度只有 30%,强行合并成一个通用组件会让 Props 变得臃肿,逻辑复杂。此时保留两个独立组件,反而更清晰。可复用是自然涌现的,不是刻意设计的。
可测试性:组件易于被单元测试覆盖
可测试的组件往往也是设计良好的组件。如果组件很难写测试,通常意味着它耦合了太多外部依赖或职责不单一。
可测试组件的特征
- 输入与输出明确:给定 Props 和初始状态,组件渲染确定的 UI 输出。事件触发后,有明确的回调调用或状态变化。
- 副作用隔离:数据请求、定时器等副作用集中在 Hooks 或容器组件中,表现层组件只专注渲染。
- 纯渲染组件优先:大多数业务组件可以拆分为“逻辑容器 + 纯渲染组件”的组合,纯渲染组件是纯函数,测试成本极低。
示例:分离逻辑与展示
// 纯展示组件:只依赖 Props,无副作用,测试简单
function TodoList({ items, onToggle }) {
if (items.length === 0) return <p>暂无待办</p>;
return (
<ul>
{items.map(item => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.done}
onChange={() => onToggle(item.id)}
/>
{item.text}
</label>
</li>
))}
</ul>
);
}
测试 TodoList 时,只需传入不同 Props 断言渲染结果,模拟点击验证 onToggle 调用即可,无需 Mock API 或全局 Store。
test('传入空数组时显示暂无待办', () => {
render(<TodoList items={[]} onToggle={jest.fn()} />);
expect(screen.getByText('暂无待办')).toBeInTheDocument();
});
逻辑容器组件可以单独测试 Hook 或集成测试,展示组件保持纯粹。
实践中的平衡
这三个原则并非孤立,常常需要权衡:
- 追求可复用性可能导致组件抽象过重,牺牲可读性。
- 单一职责过度会导致组件碎片化,增加组合复杂度。
- 可测试性要求拆分,但不应为测试创建无意义的包装组件。
实用建议:从页面开始,自上而下自然拆分。 先用一个粗粒度的页面组件实现完整功能,然后逐步将明显的独立区块(如头部、侧栏、表单、列表项)抽成独立组件。当同一个 UI 模式出现多次时,再抽象为复用组件。最后针对复杂逻辑的数据处理部分,提取自定义 Hook 或工具函数。这种“演进式拆分”比一开始就追求完美设计更符合现实开发节奏。