"组件越拆越乱,改一个按钮影响三处",几乎不是拆分粒度的问题,而是依赖方向的问题。组件拆得再细,只要视图能直接拿全局状态、状态能直接发请求、两个 feature 能互相 import,改动半径就永远收不住。
分层不是目录美学,是让"改一处只影响一处"这件事在代码结构上成立。这篇给三层的职责判据、依赖规则的可执行形式,以及分层的止损边界。
1. 三层职责与越界症状
| 层 | 只做一件事 | 越界症状 | 判据 |
|---|---|---|---|
| 视图 | 把数据变成 DOM | 组件里出现 fetch、业务 if 堆叠 |
能否只传 props 就渲染 |
| 状态 | 持有跨渲染的数据与派生 | store 里出现 DOM 引用、组件实例 | 能否序列化、能否重放 |
| 取数 | 请求、缓存、失效 | 同一接口在五个组件各请求一次 | 每份数据是否有唯一 owner |
依赖方向是 视图 → 状态 → 取数,反向即负债:视图直接调接口,等于把缓存策略散进 UI;取数层 import 组件,等于让数据知道怎么画。
分层的收益不看整齐度,看改动半径:改接口字段时,需要动的文件数是 1 还是 7。
2. 组件分类及其弱化
| 类型 | 职责 | 现代框架下的变化 | 何时仍需要 |
|---|---|---|---|
| 展示组件 | props in / events out,无副作用 | hooks 让容器逻辑可内联,纯展示退化为叶子 | 设计系统、Storybook、跨页复用 |
| 容器组件 | 连接数据与展示 | 被取数 hook 取代,只剩页面级编排 | 路由级页面 |
| 布局组件 | 只管槽位与间距 | 基本没变,反而最稳 | 所有页面骨架 |
分类的意义不是贴标签,是规定谁能 import 谁:展示组件不许 import 取数层,布局组件不许出现业务字段名。
pages/OrderList.vue 编排:取数 + 布局,不含样式细节
├─ layout/TableShell 布局:表头、分页槽位,不认识订单
├─ ui/StatusTag 展示:props -> 文案与颜色
└─ features/order/Filter 组合:表单状态 + 提交
容器组件被削弱之后,团队常见的失误是把编排逻辑塞回页面组件:一个 600 行的 OrderList.vue 里同时有取数、筛选、权限、埋点、样式。分层没消失,只是塌进了一个文件。
3. 状态的三类与各自归属
| 类型 | 例子 | 正确归属 | 放错的代价 |
|---|---|---|---|
| UI 状态 | 弹窗开关、选中 tab、hover | 组件本地,可分享的放 URL | 全局 store 卸载不清理,跨页串味 |
| 服务端状态 | 列表、详情、字典 | 取数库缓存(按 key) | 手写失效,必然漏一个 |
| 表单状态 | 字段值、校验、脏标记 | 表单库或组件本地 | 每次输入触发全局重渲染 |
判据一句话:这份数据有没有第二个 owner? 没有就是本地状态;有,再看它是否来自服务端——是则归缓存,否则才考虑共享 store。
4. 服务端状态为什么不该进全局 store
| 维度 | 放进 store | 放进取数缓存 |
|---|---|---|
| 失效 | 手写 invalidation,漏一处就脏 | key 驱动,过期/聚焦自动重取 |
| 来源 | 多组件各存一份,互相覆盖 | 单一缓存,按 key 共享 |
| 一致性 | 需要额外同步链路 | 天然单点 |
// 同一份服务端数据只有一个缓存键,组件只声明依赖
const { data, isLoading } = useQuery({
queryKey: ['order', id],
queryFn: () => api.order(id),
})
把服务端数据抄进 store 的那一刻,你同时得到了两份真相和一条必须手维护的同步链路——而"改一处影响三处"正是这条链路的产物。缓存库解决的就是这件事:它把"这份数据什么时候过期"变成声明而不是流程。
5. 数据流的三种模式与可调试性
| 模式 | 流向 | 可调试性 | 适用 | 崩坏形态 |
|---|---|---|---|---|
| 单向(props down / events up) | 明确、可画图 | 高:静态可追 | 树深 ≤ 3 层 | prop drilling |
| 双向绑定 | 赋值点分散 | 中:谁改的要看运行时 | 表单控件 | 值被谁覆盖说不清 |
| 事件总线 / 全局订阅 | 任意方向 | 低:无静态调用边 | 跨模块通知 | 订阅泄漏、循环触发 |
排序的理由只有一个:静态可分析性。单向数据流能用 grep 和类型系统回答"这个值从哪来",总线只能用运行时回答。
// 总线传数据:调用图断开,grep 找不到谁在监听
bus.emit('order:updated', order)
// 改法:数据走参数与状态,事件只做通知
updateOrder(order) // 数据
bus.emit('order:saved') // 通知,无载荷
总线只该用于"通知某事发生了",不该用于"把数据送过去"。前者没有消费方也不出错,后者没有消费方就是数据丢失。
6. 目录结构如何表达边界
src/
features/order/{api,model,ui,index.ts} 按功能切:改动集中、可整体删除
shared/{ui,lib}/index.ts 公共层:只导出稳定 API
app/{router,providers}/ 组合层:唯一允许跨 feature
| 切法 | 优点 | 代价 | 适用 |
|---|---|---|---|
| 按类型(components/、hooks/、utils/) | 新人好找 | 一个需求改四个目录 | 小项目 |
| 按功能(features/order/) | 改动集中、删除干净 | 需要公共层纪律 | 中大型 |
| 混合(features + shared) | 兼顾两者 | 边界必须靠规则守 | 多数项目 |
目录是依赖规则的可执行形式:同 feature 内自由,跨 feature 只走 index.ts,shared 不许 import features。这三条写不进检查工具,目录再漂亮也只是命名习惯。
7. 共享代码的提取时机与跨模块治理
判据是第三次重复再抽:第一次写死,第二次复制并观察差异,第三次才抽象。过早抽象会把两份"看起来一样"的代码锁进一个带参数的怪物,而它们的差异其实是业务规则。
| 信号 | 处理 |
|---|---|
| 两处相似但参数不同 | 先参数化,不抽象 |
| 第三处出现且差异稳定 | 抽象,并补测试 |
| 差异是业务规则而非参数 | 不抽象,用组合 |
| 只有调用方一处 | 不抽,等第二处 |
// .dependency-cruiser.js:每个 feature 生成一条(示例为 order)
forbidden: [
{ name: 'order-no-cross', severity: 'error',
from: { path: '^src/features/order/' },
to: { path: '^src/features/(?!order/)(?!.*?/index\\.ts$)' } },
{ name: 'shared-pure', severity: 'error',
from: { path: '^src/shared/' },
to: { path: '^src/features/' } },
]
禁止深层相对路径(../../../)不是洁癖:它让"这个模块依赖了什么"变成无法静态回答的问题,重构时评估不了影响面,也就没人敢动。
8. 组件通信的穿透问题
| 方案 | 代价 | 适用 |
|---|---|---|
| 逐层透传 props | 中间层被迫知道无关字段 | 2–3 层 |
| Context / provide-inject | 隐式依赖,跳层耦合,测试要包 provider | 主题、会话、i18n |
| 插槽 / children | 结构反转,内容由父层决定 | 布局组件 |
| 状态提升到公共父级 | 父组件变胖 | 兄弟通信 |
// 插槽优于透传:布局组件不需要认识业务字段
<TableShell header={<OrderHeader />} footer={<Pager total={n} />} />
Context 的代价是"隐式":它把依赖从组件签名里拿走了,读代码看不出组件需要什么。所以只放稳定的横切关注点,不放业务数据——业务数据放进去,等于把隐式依赖变成隐式的业务耦合。
9. 设计系统与业务组件的分界
| 维度 | 设计系统组件 | 业务组件 |
|---|---|---|
| 知道什么 | 尺寸、语义色、可访问性 | 订单状态、金额规则 |
| 依赖 | 无业务依赖 | 依赖 api / model |
| 变更频率 | 低,需版本化 | 高,随需求走 |
| 归属 | 独立目录或包,可发布 | 跟随 feature |
判据:设计系统只收"与业务无关的可复用交互"。把业务组件塞进去,等于让设计系统的版本号跟着业务改——下游所有项目都要为你的一个活动规则升版。
10. 重构安全:先加边界,再改实现
- import { fetchOrder } from '../../../api/order'
+ import { fetchOrder } from '@/features/order'
顺序不能反:先立规则(lint / 依赖检查)→ 再加薄适配层(旧路径 re-export)→ 最后动实现。先重写实现再补边界,等于在没有护栏时搬代码,冲突面积翻倍,而且没人能判断搬完是否漏了依赖。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 改一处崩三处 | 共享状态被多处写 | 列出所有写入点,收敛到单一 owner |
| 组件测试要挂整个应用 | 依赖 Context / 全局 store | 改为 props 或注入 |
| 循环 import 得到 undefined | 运行时初始化顺序 | 抽公共模块或延迟取用 |
| 首屏同一接口请求多次 | 各组件各自取数 | 统一 query key |
| 样式互相覆盖 | 全局样式无命名空间 | 作用域样式 + 设计令牌 |
| 包体积随功能线性涨 | 无路由级切分 | 路由动态 import |
12. 边界:什么时候不要分层
| 场景 | 更合适 | 理由 |
|---|---|---|
| 单页小应用(组件不足十来个) | 单层目录或单文件 | 分层成本大于收益 |
| 两人以下团队、迭代极快 | 约定 + lint 就够 | 目录仪式没人维护 |
| 纯静态页面 | 直接 HTML/CSS | 没有状态与数据流可管 |
| 一次性活动页 | 单文件组件 | 生命周期短于重构周期 |
| 交付后不再维护的项目 | 最简结构 | 没有人会读架构 |
分层的门槛是改动半径真的疼。不到十来个组件、两三个人、一周上线,加三层目录只会让每次改动多走三个文件;等到"改一个字段要开七个文件"时再分,成本反而更低。
13. 自测
- 三层的依赖方向是什么?反向依赖各带来什么具体代价?
- 容器组件弱化后,最常见的塌陷形态是什么?
- 判断一份状态该放哪,第一问是什么?
- 服务端数据进 store 后,多出来的那条链路是什么?
- 数据流三模式按可调试性怎么排?排序依据是什么?
- 什么时候才该抽公共组件?