Skip to content

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-basedfeature-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 的 indeximport { 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 一旦依赖业务,就变成新的耦合中心
"重构要一次做完"风险高;渐进迁移才是正解

动手练习 ​

  1. 画出你们项目的依赖图(depcruise 出 svg),找出反向依赖与跨 feature 直连的地方。
  2. 给项目加 import/no-restricted-paths 规则,配置 "shared 不能依赖 features",看能报出多少处。
  3. 挑一个最乱的功能,按 feature-based 重组(components/hooks/api/model/index),对比改动前后的文件跨度。

相关文档 ​