REACT · Vol.IV · LESSON 27 · 组件设计与工程化
组件拆分与职责边界(对比三层架构)
一个 300 行的「上帝组件」
上一篇(第 26 篇)用 HOC 和 render props 解决了「横切逻辑怎么复用」,但还有一个更致命的坏味道没治:一个组件从请求、业务到渲染什么都干——前端的「上帝组件」。你几乎一定见过它:
import { useState, useEffect } from "react";
export default function UserPage() {
// ── 第 1 坨:数据请求 ──────────────────────
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [keyword, setKeyword] = useState("");
useEffect(() => {
setLoading(true);
fetch(`/api/users?keyword=${keyword}`)
.then(res => res.json())
.then(data => {
// ── 第 2 坨:业务规则 ──────────────────
const adults = data.filter(u => u.age >= 18);
adults.sort((a, b) => b.createdAt - a.createdAt);
setUsers(adults);
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [keyword]);
// ── 中间还有 100 多行:选中态、删除、批量操作、防抖……
// ── 最后 150 行:内联样式、布局、渲染 ────
return (
<div className="user-page">
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
{loading ? (
<p>加载中…</p>
) : (
users.map(u => (
<div key={u.id}>
<span>{u.name}</span>
<span>{u.status === "ACTIVE" ? "在职" : "离职"}</span>
</div>
))
)}
</div>
);
}
这个组件等于一个 800 行的 Controller:方法里开 JDBC 连接、写 SQL、做业务校验,最后手工拼 HTML 返回。code review 里看到你会直接打回——翻译成 React,很多人却觉得「前端都这样」。不是的,前端同样需要分层。
前端的三层架构:展示、容器、逻辑 Hook
治法你在后端天天用:按职责分层。React 生态里对应的分法是展示组件、容器组件、逻辑 Hook 三层:
| 层(前端形态) | 只负责什么 | Java 类比 |
|---|---|---|
| 展示组件 UserList、Avatar | 拿到 props 就画出界面,交互通过回调上报;不发请求、不写业务规则 | View / Thymeleaf 模板 / DTO 渲染 |
| 容器组件 UserListPage | 接线:调 Hook 拿数据、持有页面级状态、组装 UI;不写规则不写样式 | Controller |
| 逻辑 Hook useUsers | 封装业务规则与数据访问,返回「数据 + 操作」;不 import 组件、不碰 DOM | Service + Mapper |
判断一个函数属于哪层,看输入输出就够了:展示组件是「props 进、UI 出」的纯函数;逻辑 Hook 是「参数进、数据出」的函数;容器组件只做搬运和编排。展示组件不知道数据来自 REST 还是 mock,就像 Thymeleaf 模板不知道数据来自 Oracle 还是 MySQL。
动手重构:一拆三
拆法直接给结论:把 UserPage 拆成 useUsers(逻辑 Hook)+ UserList(展示组件)+ UserListPage(容器组件),各回各家。
第一块:useUsers —— 业务规则与数据访问(Service)
import { useEffect, useState } from "react";
import { fetchUsers } from "../api/user";
import type { User } from "./types";
export function useUsers(keyword: string) {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetchUsers(keyword, controller.signal)
.then(data => {
// 业务规则:只看已成年用户,按创建时间倒序(改规则只改这里)
const visible = data
.filter(u => u.age >= 18)
.sort((a, b) => b.createdAt - a.createdAt);
setUsers(visible);
setError(null);
})
.catch((e: Error) => {
if (e.name !== "AbortError") setError(e.message);
})
.finally(() => setLoading(false));
// 清理函数:卸载或 keyword 变化时取消上一次请求(第 9 篇讲过)
return () => controller.abort();
}, [keyword]);
return { users, loading, error };
}
这就是第 13 篇「自定义 Hook」的标准形态:use 开头的函数,内部用 useState/useEffect,对外返回数据与操作。它和 UI 零耦合——可以像测 Service 一样单测它,换三套 UI 都不用动一行。
第二块:UserList —— 纯展示(View)
import type { User } from "./types";
interface UserListProps {
users: User[];
loading: boolean;
selectedId: number | null;
onSelect: (id: number) => void;
}
export function UserList({ users, loading, selectedId, onSelect }: UserListProps) {
if (loading) return <p>加载中…</p>;
if (users.length === 0) return <p>没有符合条件的用户</p>;
return (
<ul className="user-list">
{users.map(u => (
<li
key={u.id}
className={u.id === selectedId ? "active" : ""}
onClick={() => onSelect(u.id)}
>
<span>{u.name}</span>
<span>{u.status === "ACTIVE" ? "在职" : "离职"}</span>
</li>
))}
</ul>
);
}
它的 import 里没有任何 fetch、api、业务字眼。换数据源(mock、GraphQL、另一个后端)它一字不改;单测只需「造 props → 断言渲染结果」,和用 DTO 测模板渲染一个套路。
第三块:UserListPage —— 接线(Controller)
import { useState } from "react";
import { useUsers } from "./useUsers";
import { UserList } from "./UserList";
import { SearchInput } from "../../components/ui";
export default function UserListPage() {
const [keyword, setKeyword] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
// 接线 1:页面状态 keyword 驱动 Hook 重新取数
const { users, loading, error } = useUsers(keyword);
if (error) return <p role="alert">加载失败:{error}</p>;
return (
<div className="user-page">
<SearchInput value={keyword} onChange={setKeyword} placeholder="搜索用户" />
// 接线 2:Hook 的数据 + 页面状态,通过 props 交给展示组件
<UserList
users={users}
loading={loading}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</div>
);
}
拆完每个文件都能一眼读完,三条变化曲线解耦:接口重构只动 useUsers,设计改版只动 UserList,页面交互只动 UserListPage。将来用上第 33 篇的 TanStack Query,useUsers 还能再瘦一圈——三层职责不变。
- 展示组件:props 进 UI 出,无副作用,像 View,最好测也最可复用。
- 容器组件:调 Hook、持有页面级状态、组装 UI,像 Controller,应该薄。
- 逻辑 Hook:业务规则 + 数据访问,像 Service,可脱离 UI 独立测试与复用。
判别清单:这段代码该放哪一层
分层最难的不是「拆」,是「判断」。给你两个问题,按顺序问,答案自然浮现:
- 换 UI 会变吗?设计稿改版、出移动端——要重写吗?要,它属于展示层(样式、布局、文案格式化、交互反馈)。
- 换数据源会变吗?REST 换 GraphQL、mock 换真接口、加缓存——要重写吗?要,它属于逻辑 Hook(请求、缓存、轮询、数据整形)。
- 两个都答「不会」——要么是容器组件的编排逻辑,要么沉成共享层的通用工具。
| 这段逻辑 | 该放哪 | 两个问题怎么答 |
|---|---|---|
| 金额分转元、保留两位小数 | src/lib 工具函数 | 换 UI 不变;换数据源也不变——通用规则 |
| 「只看已成年用户,按时间倒序」 | useUsers(Hook) | 换 UI 不变;换数据源要变——规则贴着数据走 |
| 列表隔行变色、空态插画 | UserList(展示) | 换 UI 必变;换数据源不变——纯展示 |
| 「先搜用户,再拿他的订单」 | 容器组件(编排) | 两头都不沾,纯粹是「接线的先后顺序」 |
再往上抽一层:UI 组件库思维
第 1 问里「换 UI 不变」的通用积木——Button、Input、Modal、Table——值得单独一层(src/components/ui)。它们比展示组件更纯:连业务词汇都没有,只认 variant、size 这类设计语言。类比公司的 common 包 / 公共 jar:一次打磨、处处复用,里面不该出现 if (userType === "VIP")。攒着攒着,你就长出了自己的「设计系统」——第 25 篇的 children 与复合组件,正是把这些积木拼好用的粘合剂。
import type { ReactNode } from "react";
interface ButtonProps {
variant?: "primary" | "ghost" | "danger"; // 只认设计语言,不认业务
onClick?: () => void;
children: ReactNode;
}
export function Button({ variant = "primary", children, onClick }: ButtonProps) {
return <button className={`btn btn--${variant}`} onClick={onClick}>{children}</button>;
}
// 这套 interface 写法,下一篇第 28 篇细讲
别教条:三层是拐杖,不是镣铐
最后泼一盆冷水:不是每个组件都值得拆三层。一个 40 行的标签选择器,硬拆成 useTags + TagList + TagPage,是自找麻烦——文件数翻三倍,读代码的人跳来跳去,困惑比清晰多。这和第 20 篇讲状态管理选型是同一个精神:架构是演进出来的,不是开局颁发的。
import { useState } from "react";
export function TagBadge({ initialTags }: { initialTags: string[] }) {
const [tags, setTags] = useState(initialTags);
function remove(tag: string) {
setTags(tags.filter(t => t !== tag));
}
return (
<span className="tag-badge">
{tags.map(t => (
<em key={t}>
{t}
<button onClick={() => remove(t)}>×</button>
</em>
))}
</span>
);
}
什么时候才值得动手拆?出现这些信号再说:
- 同一份 UI 要出现在第二个地方——复用需求真实出现了;
- 想给业务规则写测试,却发现它焊死在 JSX 里;
- 后端改一个字段名,你要在一堆样式代码里大海捞针;
- 「改样式的人」和「改逻辑的人」开始频繁冲突同一个文件。
另外,真实项目未必「一个页面恰好三层」:有的容器薄到只剩一行,直接在路由文件里调 Hook 渲染展示组件;有的 Hook 就是第 33 篇的一行 useQuery。分层是职责的划分,不是文件数的指标。
本章回顾
我们把一个 300 行的上帝组件拆成了三层:useUsers(逻辑 Hook,≈ Service)、UserList(展示组件,≈ View)、UserListPage(容器组件,≈ Controller)。归属判断只记两问:换 UI 会变吗?换数据源会变吗?但别教条——架构服务于变化,拆分跟着复用与测试的真实需求走。
三层拆完,新问题来了:层与层之间靠什么连接?靠 Props。而 Props 的形状、哪些可选、回调参数是什么类型,靠什么保证不出错?下一篇第 28 篇,我们把 TypeScript 请进来,让「契约」从口头约定变成编译期红线。
Comments · 评论