学完这篇,你能拿到一套可直接照搬的 React 大型项目目录结构,并且知道怎么用工具把"目录约定"变成"编译期和 CI 能拦住的强制规则"。
第一步:先想清楚按什么维度切目录
新人最容易踩的坑是按文件类型切:components/、hooks/、utils/、services/ 各自一个巨型目录。项目超过 50 个页面后,改一个需求要在四个目录之间来回跳,且没人敢删任何一个文件——因为看不出谁在用。
大型项目用按业务领域切(feature-based),外层只保留极少数共享层:
src/
├── app/ # 应用装配层:入口、路由、Provider、全局初始化
│ ├── router/
│ ├── providers/
│ └── main.tsx
├── features/ # 业务领域,一个需求一个目录
│ ├── order/
│ │ ├── api/ # 请求函数 + DTO 类型
│ │ ├── components/ # 只服务 order 的组件
│ │ ├── hooks/
│ │ ├── stores/
│ │ ├── types/
│ │ ├── utils/
│ │ └── index.ts # 唯一对外出口
│ └── user/
├── shared/ # 跨 feature 的公共能力,无业务语义
│ ├── ui/ # Button/Modal/Table 这类纯展示组件
│ ├── lib/ # request、storage、date 等基础设施
│ ├── hooks/ # useDebounce、usePagination
│ └── config/
├── pages/ # 只有路由级页面,负责拼装 feature
└── assets/
关键点:features/order 只能通过它的 index.ts 被外部引用,内部文件不对外暴露。这样 order 内部随便重构,外部无感知。
第二步:配好路径别名,让 import 一眼看出层级
vite.config.ts:
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@shared': path.resolve(__dirname, 'src/shared'),
}
}
tsconfig.json 同步一份:
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["src/*"], "@shared/*": ["src/shared/*"] }
}
两个地方都要配,只配 Vite 的话 TS 会报红。
注意:别名只解决"写得短",解决不了"依赖方向乱"。相对路径 ../../user/api 跨 feature 引用一定要禁掉,靠下一步的 ESLint 规则拦截。
第三步:用 ESLint 把目录边界变成 CI 检查
装 eslint-plugin-boundaries:
npm i -D eslint-plugin-boundaries
.eslintrc.cjs:
settings: {
'boundaries/elements': [
{ type: 'app', pattern: 'src/app/*' },
{ type: 'pages', pattern: 'src/pages/*' },
{ type: 'feature', pattern: 'src/features/*', capture: ['featureName'] },
{ type: 'shared', pattern: 'src/shared/*' },
],
},
rules: {
'boundaries/element-types': [2, {
default: 'disallow',
rules: [
{ from: 'app', allow: ['pages', 'feature', 'shared'] },
{ from: 'pages', allow: ['feature', 'shared'] },
{ from: 'feature', allow: ['shared', ['feature', { featureName: '${from.featureName}' }]] },
{ from: 'shared', allow: ['shared'] },
],
}],
}
第三条规则的意思是:feature 只能引用自己和其它 feature 的公共出口,且不能引 shared 之外的业务代码。配好之后,任何违规 import 会在 npm run lint 阶段直接报错,而不是靠 code review 靠人肉记忆。
注意:先跑 npx eslint src --ext .ts,.tsx 看现有违规量。老项目一上来就是几百条,建议先设成 warn(把 2 改成 1),每周清理一批,别一次性全部放开。
第四步:统一 feature 内部的文件职责
一个 feature 内部再乱也没人管得到,所以内部要有共识:
| 目录 | 放什么 | 不许放什么 |
|---|
api/ | 请求函数、DTO、mock | 不发 UI 状态 |
components/ | 有业务语义的组件 | 不放纯 UI 原子组件 |
hooks/ | 组合逻辑,可复用 | 不放一次性行内逻辑 |
stores/ | 该领域的全局状态 | 不放服务端数据缓存 |
index.ts | 导出 pages 需要的组件和 hook | 不导出内部实现细节 |
服务端数据(列表、详情)交给 TanStack Query 之类的请求库管理,别塞进全局 store——这是大型项目里最常见的状态膨胀来源。
第五步:组件提升到 shared 的时机
不要"感觉能复用"就搬。用三次法则:同一个组件在第三个 feature 被需要时,才提升到 shared/ui。提前抽象出来的组件往往参数越加越多,最后变成 any 满天飞的怪物。
注意:提升时同步检查 shared 里有没有反向 import feature。shared 层禁止依赖任何 feature,这条规则在上面 ESLint 配置里已经写死了,别偷偷 writeoff。
第六步:从小目录平滑迁移
不要停业务一周去重构。按这个顺序做,每一步都能独立上线:
- 先建
shared/ui、shared/lib,把无争议的 Button、request 搬进去,改 import 路径
- 配置路径别名,全量替换深层相对路径为
@/
- 按业务挑一个最小的 feature(比如
notification)整目录搬到 features/,补 index.ts
- 开启 ESLint 边界规则为 warn,观察一周违规集中在哪
- 按违规数量从少到多,逐个 feature 迁移,每个 feature 一个 PR
第 5 步做完,components/、utils/ 这些老目录基本会自然清空,剩下的就是 git rm 的事。
小结
- 大型项目按业务 feature 切目录,外层只留
app / pages / features / shared 四层
- 每个 feature 用
index.ts 收口,内部实现不对外暴露
- 路径别名要 Vite 和 tsconfig 两处同步配
- 目录约定必须靠
eslint-plugin-boundaries 落成 CI 检查,否则半年后必然腐化
- 公共组件按三次法则提升,服务端数据交给请求库而不是全局 store
- 迁移分步走,每步可独立上线,ESLint 规则先 warn 再 error