2909 字
约 9 分钟
18
S14-A07 · 前端架构分层:组件、状态与数据流

"组件越拆越乱,改一个按钮影响三处",几乎不是拆分粒度的问题,而是依赖方向的问题。组件拆得再细,只要视图能直接拿全局状态、状态能直接发请求、两个 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. 自测

  1. 三层的依赖方向是什么?反向依赖各带来什么具体代价?
  2. 容器组件弱化后,最常见的塌陷形态是什么?
  3. 判断一份状态该放哪,第一问是什么?
  4. 服务端数据进 store 后,多出来的那条链路是什么?
  5. 数据流三模式按可调试性怎么排?排序依据是什么?
  6. 什么时候才该抽公共组件?
S14-A07 · 前端架构分层:组件、状态与数据流
https://www.clxhxhhr.top/posts/6169/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。