模块四 · 性能、调度与架构
架构与组件模式:从单页到可协作的工程
架构解决的是"改动的影响范围"。这一节讲清目录分层、四种组件模式、Monorepo 与微前端的适用边界,并给出一套可落地的约束清单。
读完这一节,你能做到
- 按领域而不是按文件类型组织目录,并给出边界规则。
- 在 HOC、render props、复合组件、headless 之间选对一种。
- 判断什么时候需要 Monorepo,什么时候需要微前端。
- 用可执行的约束(lint 规则、依赖方向)保护架构,而不是靠口头约定。
一、目录:按领域切,不按类型切
按类型组织的典型结构:
src/
├─ components/ (80 个文件)
├─ hooks/
├─ utils/
└─ pages/
问题不在于"看起来乱",而在于改一个需求要在三个顶层目录之间跳,且无法判断某个文件属于哪个业务。
按领域组织:
src/
├─ app/ # 路由入口与全局配置
├─ features/
│ ├─ order/
│ │ ├─ api.ts # 该领域的数据访问
│ │ ├─ model.ts # 类型与状态
│ │ ├─ components/ # 领域内组件
│ │ └─ index.ts # 对外只导出必要内容
│ └─ user/
├─ shared/
│ ├─ ui/ # 无业务语义的基础组件
│ ├─ lib/ # 工具函数
│ └─ hooks/
└─ pages/ # 只做组合,不写业务逻辑
三条边界规则:
| 规则 | 说明 | 违反后果 |
|---|---|---|
features 之间不能互相 import | 只能通过 shared 或事件通信 | 领域耦合,改一处影响两处 |
shared 不能 import features | 基础层不能依赖业务层 | 循环依赖,构建变慢 |
pages 只组合,不写逻辑 | 页面是装配层 | 业务散落在路由文件里 |
这些规则要能被工具检查,否则三个月后一定失效:用 eslint-plugin-boundaries 或 import/no-restricted-paths 把方向写死,CI 里跑。
二、状态分层:架构的核心问题
| 类型 | 例子 | 存放位置 | 变化频率 |
|---|---|---|---|
| UI 状态 | 展开、选中、hover | 组件内 | 高 |
| 表单状态 | 输入值、校验错误 | 组件内 / 表单库 | 高 |
| URL 状态 | 筛选、分页、Tab | search params | 中 |
| 服务端状态 | 列表、详情 | 请求缓存库 | 中 |
| 应用状态 | 登录态、主题、权限 | Zustand / Context | 低 |
架构评审时先画这张表,再问"每一类有没有唯一真相"。常见问题不是"用了什么库",而是同一份数据存在两处:URL 里有 page,store 里也有 page,最后谁都不知道以谁为准。
三、四种组件模式
| 模式 | 形态 | 适用 | 代价 |
|---|---|---|---|
| 复合组件 | <Tabs><Tabs.Item/></Tabs> | 一组强关联组件 | 需要 Context,嵌套层级隐式 |
| Render Props | <List render={item => ...} /> | 需要把内部数据交给调用方渲染 | 嵌套回调可读性差 |
| HOC | withAuth(Component) | 横切关注点(埋点、权限) | props 命名冲突、层级难读 |
| Headless | 提供状态与行为,不提供样式 | 组件库、设计系统 | 学习成本高,调用方要写样式 |
选择顺序:能用组合(children)就用组合;需要内部数据就用 render props;横切逻辑用自定义 Hook;只有必须包裹组件时才用 HOC。
HOC 在现代 React 里已经很少用于业务,因为自定义 Hook 能复用同样的逻辑且没有嵌套地狱。它仍然适合"必须在渲染前拦截"的场景,比如错误边界、权限网关。
Headless 组件是设计系统的正确形态:
function useDisclosure(initial = false) {
const [open, setOpen] = useState(initial);
return {
open,
openIt: () => setOpen(true),
close: () => setOpen(false),
toggle: () => setOpen((v) => !v),
getButtonProps: () => ({
"aria-expanded": open,
onClick: () => setOpen((v) => !v),
}),
getPanelProps: () => ({ hidden: !open }),
};
}
它把可访问性属性一并给出,调用方只负责样式。这比"给一个样式写死的 Modal"可复用得多。
四、Monorepo:解决什么问题
| 问题 | Monorepo 的解法 | 代价 |
|---|---|---|
| 多个应用共享组件与工具 | 抽 packages/ui、packages/utils | 构建配置复杂 |
| 版本发布不同步 | 统一提交、原子发布 | 需要 CI 支持受影响包构建 |
| 跨仓修改成本高 | 一次 PR 改完 | 仓库变大,权限管理更复杂 |
最小方案是 pnpm workspace:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
判断标准:是否有两个以上应用真的在复用同一份代码。只有一个应用时,Monorepo 只是把简单问题复杂化。
五、微前端:最后才考虑
| 方案 | 隔离级别 | 适用 | 代价 |
|---|---|---|---|
| iframe | 最强 | 完全独立的老系统 | 通信困难、体验割裂 |
| Module Federation | 运行时共享模块 | 同技术栈、多团队 | 版本兼容与依赖共享复杂 |
| 路由分发 | 按路径拆应用 | 团队边界清晰 | 需要统一导航与鉴权 |
微前端解决的是组织问题,不是技术问题。如果只有一个团队、一个发布节奏,微前端带来的复杂度远大于收益。常见误用是"为了技术先进"引入 Module Federation,最后被 shared 依赖的版本冲突拖住。
判断标准:是否有独立团队需要独立发布。没有,就不要拆。
六、可执行的项目约束
架构靠约定守不住,要靠工具:
| 约束 | 落地方式 |
|---|---|
| 依赖方向 | ESLint 的 no-restricted-paths |
| 命名与导出 | index.ts 只导出公共 API |
| 类型共享 | packages/types 或 OpenAPI 生成 |
| 组件分层 | shared/ui 不允许 import 业务类型 |
| 提交规范 | commitlint + changesets |
| 体积预算 | CI 里检查 bundle size |
再加三条工程习惯:
- 每个领域一个
index.ts,外部只能从入口导入,内部文件可自由重构。 - API 类型由后端契约生成,不要手写两份。
- 把"页面"与"领域"分开:页面负责组装,领域负责逻辑,测试也按这个边界写。
七、一个中型项目的落地结构
src/
├─ app/
│ ├─ router.tsx
│ ├─ providers.tsx # QueryClient、Theme、Auth
│ └─ layout/
├─ features/
│ ├─ auth/
│ ├─ order/
│ └─ dashboard/
├─ shared/
│ ├─ ui/ # Button、Modal、Table
│ ├─ lib/ # http、format、storage
│ └─ hooks/
├─ pages/
│ ├─ order-list/
│ └─ order-detail/
└─ types/
providers.tsx 集中所有全局 Provider,避免在 main.tsx 里堆十几层嵌套;pages 只做"取参数 + 组合领域组件"。
八、常见坑
- 按类型分目录,
components/里堆了上百个文件。 shared反向依赖features,形成循环依赖。- 同一份数据同时存在 URL、store 与组件 state 三处。
- 为两个页面引入 Monorepo,构建链复杂度翻倍。
- 没有独立发布需求就上微前端,shared 依赖天天冲突。
- 用 HOC 包一切,DevTools 里组件名全是
WithXxx。 - 架构规则只写在文档里,没有 lint 守护。
九、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 把一个按类型组织的目录改成按领域组织,写出三条依赖规则并用 ESLint 落地。
- 画一张状态分层表,标出项目里每份数据的位置,找出重复的真相。
- 把一个"样式写死的 Modal"改成 headless 组件,补上
aria-*与键盘关闭。 - 评估当前项目是否需要 Monorepo,写出判断依据与不做的理由。
② 让 AI 写同一题
把第 3 题(headless Modal)交给 AI,补一句 无障碍要做到能用键盘完整操作。
③ 对比要盯的点
- 焦点陷阱、
aria-modal、Esc 关闭、滚动锁,它做了几项?通常缺两到三个。 open它做成受控还是内部 state?内部 state 的话调用方就管不了。- 它有没有为了省事引入一个组件库?那就不叫 headless 了。
④ 一句话取舍
它给的 Modal 能直接进生产吗?缺的那几项里,哪一条会变成无障碍合规风险?
十、自测
- 按领域分目录解决了什么问题?
- 四种组件模式的选择顺序是什么?
- Monorepo 的判断标准是什么?
- 微前端解决的是技术问题还是组织问题?
- 架构规则怎样才不会被破坏?
参考答案要点:缩小改动影响范围;组合→render props→Hook→HOC;是否有两个以上应用复用代码;组织问题;用 lint 与 CI 强制。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★☆☆ 微前端、模块联邦和 Monorepo 各自解决什么问题?
- ★★★☆☆ Monorepo 怎么划包边界和构建缓存?
- ★★★★☆ HOC、Render Props 和复合组件分别适合什么场景?
提示:读完可以顺手做几道对应题目,检验是否真的理解