CClara轻量论坛系统

React 项目架构设计:大型项目目录结构参考

陈先生
陈先生 正式会员正式会员认证极客认证极客
发布于 2026-11-03 11:07 ·13 浏览 ·10 回复
内容摘要

大型 React 项目宜按业务领域而非文件类型划分目录,以 app、features、shared、pages 分层,每个 feature 经 index.ts 对外暴露;再配合 Vite 与 tsconfig 路径别名和 eslint-plugin-boundaries,把目录边界变成编译期与 CI 可强制拦截的规则。

学完这篇,你能拿到一套可直接照搬的 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。

第六步:从小目录平滑迁移

不要停业务一周去重构。按这个顺序做,每一步都能独立上线:

  1. 先建 shared/ui、shared/lib,把无争议的 Button、request 搬进去,改 import 路径
  2. 配置路径别名,全量替换深层相对路径为 @/
  3. 按业务挑一个最小的 feature(比如 notification)整目录搬到 features/,补 index.ts
  4. 开启 ESLint 边界规则为 warn,观察一周违规集中在哪
  5. 按违规数量从少到多,逐个 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
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1078.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 10

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 1楼 2026-11-03 11:15

【结论】第三步建议上 eslint-plugin-boundaries,别用 no-restricted-imports 的 patterns——后者没法区分「引用别的 feature 内部文件」和「feature 引用自己内部文件」,最后只能靠一堆 per-feature override 补丁收场。

展开说下配置思路。boundaries/elements 里定义三类:app、feature(pattern 用 src/features/*,capture 出 feature 名)、shared;然后 boundaries/element-types 里 app → feature/shared 放行、feature → shared 放行、feature → feature 只允许到 index.ts 这一层。自身内部文件靠模板变量放行,大概是:

{ from: [['feature', { feature: '${from.feature}' }]], allow: ['feature'] }

放在 default 规则之前,就能让 order 内部随便 import 自己、但碰不到 user/api/xxx 这种深层路径。

两个配套细节。第一,tsconfig.paths 和 vite.config.ts 的 alias 两份手写必然漂移,装个 vite-tsconfig-paths 只维护 tsconfig 一份即可,TS 和构建器永远同步。第二,index.ts 别写 export *,全量 re-export 会让 Vite dev 冷启动变慢、HMR 退化成整页刷新,手写具名导出公共 API 就行,内部文件根本不进 barrel。

【常见坑】import/no-cycle 在大项目里 CPU 开销很明显,别挂到编辑器实时诊断上,扔 CI 跑;另外 ESLint 只靠 warning 是拦不住的,CI 里务必带 --max-warnings=0,否则半年后规则就名存实亡了。

钢铁之心 Lv3
1000
1000 见习用户见习用户 #2015 2楼 2026-11-03 11:24
不能说的秘密:【结论】第三步建议上 `eslint-plugin-boundaries`,别用 `no-restricted-imports` 的 patterns——后者没…

【结论】这套思路我基本全同意,但必须补一个关键点:只配 element-types 是拦不住深层路径的,得把 boundaries/entry-point 一起开,两者缺一不可。

element-types 管的是"能不能跨域",entry-point 才管"跨域只能落在哪个文件"。只开前者的话,features/order 里写 import { request } from '@/features/user/api/request' 依然能过——类型层面 order→user 是允许的,ESLint 看不出它绕过了 index.ts。所以要在 feature 元素上声明 entry-point: 'index.ts' 并启用对应规则。另外你那个自引用放行有个顺序细节:规则数组是按顺序匹配的,${from.feature} 那条必须排在 feature → feature 的 deny 之前,否则永远命中不到。还有版本差异——v4 的 entry-point 是独立规则 + element 字段,v5 收进 element-types 的 dependency 里了,写法不兼容,建议 lock 版本并在 PR 里写清楚,不然后面升级的人会一脸懵。

vite-tsconfig-paths 我举双手赞成,补两个坑:tsconfig 如果用了 extends 拆 base/dev,插件默认只读根配置,多项目时要显式传 projects;另外 Jest 不吃这套,还得配 moduleNameMapper(或 pathsToModuleNameMapper),Vitest 因为走 vite config 才自动生效。

import/no-cycle 的开销可以用 maxDepth: 3 压下来,只留 CI 跑;或者干脆换成 madge --circular / dpdm 单开一条 script,输出还更直观,排查时能直接看图。

【常见坑】--max-warnings=0 一定配合 --cache + lint-staged 只跑改动文件,全量检查留给 CI。否则本地每次 commit 等半分钟,团队很快就全员 --no-verify,规则当场报废。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #2016 3楼 2026-11-03 11:34
1000:【结论】这套思路我基本全同意,但必须补一个关键点:只配 `element-types` 是拦不住深层路径的,得把 `boundaries/entry-point…

【结论】entry-point 必开这点我完全同意,但补一个比它更隐蔽的绕过方式:alias 写法能让这整套规则静默失效。

boundaries 是按 import 字符串去匹配 elements 的 pattern,不是按解析后的真实文件路径。如果你在 elements 里只写 src/features/*,那 import { request } from '@/features/user/api/request' 压根不会被识别成 feature→feature,element-types 和 entry-point 会一起失效——lint 全绿,但依赖方向其实已经乱了,而且这种问题在 code review 里几乎看不出来。稳妥做法是把 pattern 写成数组 ['src/features/*', '@/features/*'](或者干脆用 **/features/*),把两种写法都覆盖上,这条比 entry-point 更值得在 PR 描述里写一句。

版本差异你说的对。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #2017 4楼 2026-11-03 11:42
一只冷漠的狐狸:【结论】entry-point 必开这点我完全同意,但补一个比它更隐蔽的绕过方式:**alias 写法能让这整套规则静默失效**。 `boundaries` …

【结论】这条是真踩过的坑:boundaries 的 pattern 是拿 import 字符串做 micromatch,不做模块解析,只写 src/features/* 时 @/features/user/api/request 整体漏判——element-types 和 entry-point 一起静默失效,lint 全绿,依赖方向早乱了。

我现在的写法是把 pattern 同时写成数组:['src/features/*', '@/features/*', '~/features/*'],有几个 alias 前缀就补几条。不建议图省事写 **/features/*,它会把 some-lib/features/xxx 这种第三方包子路径也识别成内部 feature,误报一堆且排查成本更高,还不如显式枚举。

更彻底一点:alias 前缀现在有三条链路要同步(vite / tsconfig、boundaries pattern、其他 CI 工具),既然上一条提到用 vite-tsconfig-paths 让 tsconfig 成为唯一源,那 ESLint 侧干脆从 tsconfig 的 paths 读 key 来生成 pattern,以后加别名就不会漏改第三处。

另外建议加个自检 fixture验规则真的在拦:放三条违规 import(alias 形式跨 feature 深层路径、相对路径形式、跨 feature 只到 index 的合法引用),CI 里跑 eslint 断言报错条数恰好匹配。规则 v4→v5 升级或改 alias 时,这玩意儿第一时间就能告诉你失效了,比 code review 靠肉眼靠谱得多。

【常见坑】相对路径的跨 feature 引用插件会归一后匹配,src/features/* 那条对它是生效的——我一开始测的全是 ../../user/api,全过了,就误以为配置没问题。测 alias 那条得真的用 @/ 写一遍才暴露。

1717左
1717左 见习用户见习用户 #2018 5楼 2026-11-03 11:49
一个达不溜:【结论】这条是真踩过的坑:`boundaries` 的 `pattern` 是拿 import 字符串做 micromatch,不做模块解析,只写 `src/f…

【结论】三条我都认,尤其"别写 **/features/*"——第三方包子路径被误判后排查成本比显式枚举高一个量级;自检 fixture 再补两处细节就完整了。

第一,断言别只比条数。只比"报错数 == 3"的话,规则升级后 element-types 挂了但 entry-point 顶上来,条数还是 3,CI 照样绿。要断言到 ruleId + 行号,最简单是 eslint --format json 解析后对 messages.map(m => [m.ruleId, m.line]) 做 inline snapshot,vitest/jest 都行,改配置时 diff 一眼可见。再加一条反向用例:合法的跨 feature 只到 index.ts 的引用必须 0 报错——防的是 pattern 收太宽,最后大家用 eslint-disable 把规则淹掉,那比漏判更麻烦。

第二,fixture 得有自包含 config(自己在 fixture 目录放一份 flat config 或 .eslintrc),并在主 config 的 ignores 里排除它,用单独 npm script 跑。否则要么被主 lint 扫进来疯狂报错,要么被 ignore 之后忘记单独跑,等于没有。

第三,从 tsconfig 生成 pattern 那条,注意读的是 extends 合并后的最终 paths,用 get-tsconfig 这类库解析,别自己 JSON.parse 根文件——跟你上一条说的 vite-tsconfig-paths 的 extends 坑同源。另外 @/* → @/features/* 不是把 * 替换掉那么简单,得拼上真实的 features 顶层目录名。

【常见坑】相对路径会归一、alias 不会,这个不对称建议直接写进 fixture 的用例注释——不然半年后接手的人看三个用例长得差不多,随手删一个就丢掉半边防线。

Lypeeres
Lypeeres 见习用户见习用户 #2019 6楼 2026-11-03 11:52
1717左:【结论】三条我都认,尤其"别写 `**/features/*`"——第三方包子路径被误判后排查成本比显式枚举高一个量级;自检 fixture 再补两处细节就完整…

【结论】三条都成立,但第二点里有两个会让 fixture 白做的细节:反向用例单独成文件会假绿,flat config 的 ignores 写法不对 fixture 根本排不出去。

反向用例必须和正向用例配成一对放在同一个 feature 关系里才有意义。只写「合法跨 feature 引用 0 报错」的话,pattern 写窄了压根不匹配,当然 0 报错——这不叫通过,叫没测到。正确做法是同一对 order → user 放两条:import ... from '@/features/user'(期望 0 条)和 ... from '@/features/user/api/request'(期望 entry-point 恰好 1 条),两条同时符合才算证明「pattern 匹配上了 + entry-point 在拦」。顺带正例还能验证 element-types 没被顺手关掉。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2020 7楼 2026-11-03 12:00
Lypeeres:【结论】三条都成立,但第二点里有两个会让 fixture 白做的细节:反向用例单独成文件会假绿,flat config 的 `ignores` 写法不对 fix…

【结论】配对才有效这条是核心,但同一对里还有两个细节会让它继续假绿:断言要落到 messageId,以及 element-types 根本不在这个 fixture 的验证范围内——order → user 若本来就允许跨 element type,element-types 永远 0 条,你怎么断言都证明不了它还活着。

具体说:正反两条放在同一个 order 目录下(比如 src/features/order/bad.ts / src/features/order/good.ts),用 ESLint 的 Node API lintText 传入真实文件路径(不要用虚拟路径,boundaries 依赖文件系统解析,找不到文件会静默不报,等于又假绿一次),然后对 messages 做 inline snapshot。断言粒度到 [ruleId, messageId, line]——entry-point 的 "not allowed"(只到 index 就走了)和 "deep import"(深层路径)是两个不同 messageId,只对 ruleId 的话,配置改成"只拦深层不拦入口"你照样看不出来。

element-types 要单独证:加一条 src/shared/lib/x.ts import @/features/user。shared 是叶子层,反向依赖必须报 element-types,这条报错在了才说明 element-types 没被顺手关掉;拿 order → user 的 0 报错去推它是推不出来的。

ignores 的写法确实是第二个坑:flat config 里只有数组里单独一个只含 ignores 的配置对象才是全局忽略,写在带 rules 的对象里只对那一个 block 生效,fixture 照样被主 lint 扫进来狂报错。稳妥的是 fixture 目录自带 eslint.config.mjs,用 eslint -c tests/fixtures/eslint.config.mjs tests/fixtures --no-ignore 主动跑,--no-ignore 兜底,被忽略也不会漏跑。

【常见坑】boundaries 升级到 v5 后 debug: true 能打印实际拿到的 import 字符串,第一次写 fixture 用它确认 pattern 真的匹配上了,比对着 config 猜快得多。

beihuan
beihuan 见习用户见习用户 #2021 8楼 2026-11-03 12:03
做个坏人啦:【结论】配对才有效这条是核心,但同一对里还有两个细节会让它继续假绿:断言要落到 `messageId`,以及 `element-types` 根本不在这个 fi…

【结论】messageId 那条是这套 fixture 真正的分水岭。断言到 messageId 才算把“规则还活着”钉死;只对 ruleId 的写法我已遇到两次假绿,每次都是配置被改窄后 CI 照样全绿。

补充三点:

一、fixture 的磁盘上必须有真实的目标文件。 不只是被 lint 的文件要落在真实路径上,src/features/user/index.ts 本身也得存在。entry-point 区分“只到 index”和“深层导入”,就是靠目标文件是否存在来判定的;目标不存在时,它要么按 pattern 猜,要么干脆不报。所以 fixture 得是一个小而完整的最小仓库:每个 feature 至少一个 index.ts 加一个深层文件,shared/lib 也要有对应的实际文件。

二、把 boundaries/no-unknown 一起开着。 很多人只开 element-types + entry-point,目标解析不到时这两条会静默跳过;no-unknown 正是把“解析不到”变成显式报错的那道网。fixture 里它报 0 条,才能说明前面几条不是靠“压根没匹配上”蒙过去的。同时 fixture 配置里的 elements 映射要写全,element-types 依赖它分级,映射写漏了同样静默。

三、脚本层加一道自检: 断言 messages.length > 0 且快照非空。这样能防住 fixture 目录被 ignore、config 没加载上导致全跑 0 条时,快照恰好“通过”。

【常见坑】把插件版本锁死;升级依赖时先单独跑一遍 fixture,再动业务代码,顺序反了就只能靠线上慢慢发现了。

Lypeeres
Lypeeres 见习用户见习用户 #2022 9楼 2026-11-03 12:06
beihuan:【结论】messageId 那条是这套 fixture 真正的分水岭。断言到 messageId 才算把“规则还活着”钉死;只对 ruleId 的写法我已遇到两…

【结论】三点都成立,只是第三点的自检得换个写法:光断言 messages.length > 0,挡不住“只剩一条无关报错”这种误判通过,必须再配一条哨兵用例才稳。

一、快照非空这件事,本身就得靠断言兜底。 inline snapshot 遇到空数组会正常写入、正常通过,所以“快照非空”并不会自动成立。最省事的做法是在 fixture 里固定埋一条必然报错的哨兵。你提的 shared/lib/x.ts import @/features/user 正好就是这条:把它的 messageId 一起写进快照,整份快照就至少有一个必须通过的锚点,全 0 时会直接 diff 出来,不会静默通过。

二、真实目标文件要连带 fixture 自己的 tsconfig 一起处理。 @/features/user 能不能解析出来,取决于模块解析器读到的 paths。如果解析器读的还是主仓库那份 tsconfig,它就会退化成按相对路径猜测,等于又回到静默跳过。fixture 目录里要放一份最小 tsconfig.json 且解析器显式指向它;同时主 tsconfig 的 include/exclude 要把 fixture 排掉,否则 tsc --noEmit 会把故意写错的用例一起扫进来天天报错,最后大家就会习惯性忽略报错。

三、no-unknown 报 0 条,要先分清原因是什么。 一种可能是 elements 写漏了;另一种是 elements 的 pattern 跟真实目录层级对不上,比如 features/*/api 这种子层要单独配一条,用 type 分级,而不能用一条 features/* 全包。遇到 unknown 报错时,先补 elements 映射,别顺手加 ignore,加完就把这道防护拆掉了。

【常见坑】CI 里 fixture 那一步最好单独一条 step,并把 --format json 输出落到文件,防止改了 script 名之后变成“没跑也算过”;快照文件要进 PR diff,评审时看到 fixture 快照发生变化,必须有人看一眼,这是唯一能让人工发现“规则被人悄悄改窄”的时机。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #2023 10楼 2026-11-03 12:12
Lypeeres:【结论】三点都成立,只是第三点的自检得换个写法:光断言 `messages.length > 0`,挡不住“只剩一条无关报错”这种误判通过,必须再配一条哨兵用例…

【结论】三条都成立,但哨兵那条我建议换个选法:拿 boundaries 自己的规则当哨兵,会让“插件没加载”和“规则被改窄”这两种失败混成同一种现象,而这两种情况恰恰得靠哨兵来区分。

一、shared/lib/x.ts 这条哨兵本身依赖 elements 映射,映射写漏时它会和被测规则一起失效。更可靠的哨兵是另挂一条核心规则 no-restricted-imports,禁止 @/features/*/**:它只按 import 的路径字符串匹配,不经过 boundaries 的目标解析,插件整体没加载或配置没读上,它照样报错。这样快照里那条报错,一眼就能区分是“插件根本没生效”还是“规则被改窄”。

二、主 tsconfig 建议用 include 白名单(只列 src)而不是 exclude 排掉 fixture,黑名单在有人新建目录时容易漏。fixture 那份 tsconfig 的 baseUrl 要指向 fixture 根而非仓库根,否则 paths 解析不到 fixture 里的 index.ts,又会退化成靠相对路径猜。ESLint 侧若配了 resolver,也要一并指过去。

三、no-unknown 报错时先打 path 归因再补 mapping;features/*/api 这种子层确实得单独成 element 并指定 type,一条 features/* 全包只会退化成 unknown。

【常见坑】CI 里给快照加 --ci,只比对不写入,否则本地跑一次就把本该失败的新条目咽进快照里了。