17 · 前端架构分层与模块边界
本篇解决什么问题: 项目到中期最常见的病:改一处崩三处、找不到代码在哪、不敢删任何文件。 病根不是代码写得差,而是没有边界——任何模块都能 import 任何模块。 本篇讲 feature-based 组织 + 分层 + 依赖方向,让"该放哪"有唯一答案。
📌 核心原则:依赖只能单向流动,且指向更稳定的方向。
开篇速览
| 主题 | 关键决策 |
|---|---|
| 目录组织 | 按"技术类型"分(components/)还是按"业务特性"分(features/) |
| 分层 | 展示层 / 领域层 / 数据层 各自的职责 |
| 依赖方向 | 谁可以 import 谁(画成图,用工具强制) |
| 循环依赖 | 为什么出现、怎么破 |
| 模块 API | 每个模块只暴露一个"入口",内部随意改 |
一、目录组织:feature-based 胜过 type-based
1.1 两种组织方式
❌ 按技术类型(type-based)—— 小项目还行,大了就失控
src/
components/ ← 200 个组件混在一起
hooks/ ← 80 个 hook
services/ ← 所有接口
store/
utils/✅ 按业务特性(feature-based)—— 高内聚
src/
features/
user/ ← 用户域
components/ UserCard.tsx
hooks/ useUser.ts
api/ userApi.ts
model/ types.ts
index.ts ← ✅ **唯一对外入口**
order/
payment/
shared/ ← 真正跨特性复用的东西
ui/ Button, Modal
hooks/ useDebounce
utils/
app/ ← 应用级:路由、布局、全局配置1.2 为什么 feature-based 更好
| 维度 | type-based | feature-based |
|---|---|---|
| 找代码 | 要跨 5 个目录找 | 一个目录搞定 |
| 删功能 | 满仓库找相关文件 | 删一个目录 |
| 新人上手 | 看不出业务边界 | 目录即业务 |
| 复用 | 混在一起,不敢改 | 明确的 shared/ |
判断标准(放哪):
这段代码只在一个特性里用? → 放该 feature/ 内
被 2+ 个特性用到? → 提到 shared/
是路由/全局配置? → 放 app/⚠️ 反面模式:
utils/变成垃圾桶。 约定:shared/utils里的东西必须被 2 个以上 feature 使用,否则留在 feature 内。
二、分层:三层职责
即使在 feature 内部,也要分三层:
┌──────────────────────────────────┐
│ 展示层 components/ │ UI 渲染、用户交互
│ 只依赖 ↓ │ 可以用 hooks,不直接写 fetch
├──────────────────────────────────┤
│ 领域层 model/ hooks/ │ 业务规则、状态、计算
│ 只依赖 ↓ │ **不依赖 UI**
├──────────────────────────────────┤
│ 数据层 api/ │ 接口请求、数据适配
└──────────────────────────────────┘| 层 | 职责 | 禁止 |
|---|---|---|
| 展示层 | 渲染、交互、转发事件 | ❌ 直接 fetch、❌ 写业务规则 |
| 领域层 | 业务规则、状态机、计算 | ❌ 依赖 React/Vue 组件、❌ 碰 DOM |
| 数据层 | 请求、适配(Adapter,见 A19) | ❌ 含业务规则 |
收益:
| 收益 | 说明 |
|---|---|
| 可测试 | 领域层不依赖 UI,纯逻辑可单测(见 A11) |
| 可替换 | 换 UI 框架时领域层不动;换后端时只改数据层 |
| 好定位 | "这个价格算错了"→ 直接去领域层找 |
三、依赖方向:画出来,并用工具强制
3.1 允许的方向
app/ ──► features/* ──► shared/
features/A ──► shared/
features/A ──► features/A/* (自己内部)
❌ 禁止:
features/A ──► features/B/* (跨 feature **直接**引用内部文件)
shared/ ──► features/* (shared 反向依赖业务)
数据层 ──► 展示层 (反向依赖)3.2 跨 feature 怎么通信
| 方式 | 做法 | 耦合度 |
|---|---|---|
经 shared/ | 把共用部分下沉到 shared | 低(推荐) |
| 经 feature 的 index | import { UserCard } from '@/features/user' | 中(可以) |
| 经应用级组装 | 在 app/ 层把 A 的结果传给 B | 低 |
| 全局状态 | 通过 store(见 A07) | 中 |
| ❌ 直接 import 内部文件 | import { useUser } from '@/features/user/hooks/useUser' | 高(禁止) |
🎯 关键约定:每个 feature 只暴露
index.ts。index.ts之外的一切都是私有实现,改了不用通知别人。
ts
// features/user/index.ts —— 唯一对外契约
export { UserCard } from './components/UserCard'
export { useUser } from './hooks/useUser'
export type { User } from './model/types'
// ❌ 不导出内部的 api、内部工具、子组件3.3 用工具强制(否则约定会腐化)
ESLint import/no-restricted-paths:
js
// .eslintrc.cjs
'import/no-restricted-paths': ['error', {
zones: [
// shared 不能反向依赖 features
{ target: './src/shared', from: './src/features' },
// feature 之间不能直接互相 import(只能经 app 或 shared)
{ target: './src/features', from: './src/features',
except: ['./'] }, // 允许自己内部
],
}]dependency-cruiser(更强,能出图):
bash
npx depcruise src --include-only "^src" --output-type dot | dot -T svg > dep.svg⚠️ 没有工具强制的架构约定,三个月后一定失效。这是本篇最重要的一条。
四、循环依赖:为什么出现、怎么破
4.1 症状
Warning: Circular dependency detected: a.ts -> b.ts -> a.ts或更隐蔽:运行时拿到 undefined(ESM 的 live binding 也救不了执行顺序)。
4.2 根因与解法
| 根因 | 解法 |
|---|---|
| 两个模块互相需要对方的东西 | 抽出第三方 common.ts(最常用) |
| 分层被打破(数据层反向依赖了上层) | 修正依赖方向:把回调通过参数传进去(依赖注入) |
| 一个模块装太多东西 | 拆小(职责单一) |
| index.ts 导出一切导致间接循环 | index 只导出必要的;内部用具体路径 |
ts
// ❌ 循环:user 需要 order 的类型,order 需要 user 的类型
// ✅ 解法:把共享类型下沉
// shared/types.ts
export interface UserRef { id: string; name: string }
// features/user/model.ts
import type { UserRef } from '@/shared/types'
// features/order/model.ts
import type { UserRef } from '@/shared/types'💡 依赖倒置(DIP):高层定义接口,低层实现—— 前端最常见的应用是把"回调/接口"作为参数传入,而不是下层 import 上层。
五、模块 API 设计(对外契约)
| 原则 | 说明 |
|---|---|
| 单一入口 | 只暴露 index.ts,内部文件视为私有 |
| 显式导出 | 明确列出导出的东西,不用 export *(避免意外暴露) |
| 类型与实现一起导出 | 用的人需要类型 |
| 不要导出内部细节 | 否则外部依赖了,你就不能改了 |
六、落地步骤(老项目怎么改)
不要大爆炸重构,按这个顺序渐进:
① 先加工具:接 ESLint import 规则 + dependency-cruiser(**只对新代码报错**)
② 新功能一律用 feature-based 组织(新代码先合规)
③ 拆最痛的一个 feature(把散落的文件收进 features/xxx/)
④ 逐个 feature 迁移,每次一个 PR
⑤ 最后收紧规则:全量强制🎯 渐进迁移比"停两周重写"现实得多。老文件可以用
// TODO: 迁移标记 + 规则豁免列表。
自检清单
- [ ] 能说清 feature-based 优于 type-based 的三个理由
- [ ] 能说出三层(展示/领域/数据)各自的职责与"禁止"
- [ ] 知道跨 feature 通信的正确方式,以及只能经 index 导入
- [ ] 能说出循环依赖的 3 个根因与对应解法
- [ ] 知道必须用工具(ESLint / dep-cruiser)强制架构约定
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "目录怎么分都行,能跑就行" | 目录就是架构的可视化;乱 = 后面改不动 |
| "约定写在文档里就够了" | 没有工具强制的约定三个月就废 |
| "跨 feature 直接 import 内部文件更方便" | 会把两个 feature 焊死,以后拆不开 |
| "shared 里放点业务也行" | shared 一旦依赖业务,就变成新的耦合中心 |
| "重构要一次做完" | 风险高;渐进迁移才是正解 |
动手练习
- 画出你们项目的依赖图(
depcruise出 svg),找出反向依赖与跨 feature 直连的地方。 - 给项目加
import/no-restricted-paths规则,配置 "shared不能依赖features",看能报出多少处。 - 挑一个最乱的功能,按 feature-based 重组(components/hooks/api/model/index),对比改动前后的文件跨度。
相关文档
- 状态该放哪 → A07 · 状态管理演进与选型
- 适配器与依赖倒置 → A19 · 前端设计模式
- 可测试性与分层 → A11 · 前端测试策略
- 多包仓库的边界 → A02 · 前端工程化(Monorepo)
- 微前端的物理隔离 → A24 · 微前端架构落地