
一句话:目录结构有「按类型划分(type-first)」与「按功能划分(feature-first)」两派——中大型项目推荐 feature-first(改需求只动一个目录);组件则按「基础 → 组合 → 业务 → 页面」四层组织,公共组件的沉淀要有标准。
一、两种划分方式
① 按类型划分(type-first)——按「文件是干什么的」分
src/
├── components/ 所有组件
├── hooks/ 所有自定义 Hook
├── utils/ 所有工具
├── api/ 所有接口
├── pages/ 所有页面
└── store/ 所有状态
② 按功能划分(feature-first)——按「属于哪个业务」分
src/
├── features/
│ ├── auth/
│ │ ├── components/ 这个功能自己的组件
│ │ ├── hooks/
│ │ ├── api.ts
│ │ ├── authSlice.ts
│ │ └── index.ts 对外出口
│ ├── orders/
│ └── users/
├── components/ 跨功能的公共组件
├── lib/ 通用工具
└── app/ 应用级装配(路由、Provider)
| 维度 | type-first | feature-first |
|---|---|---|
| 找文件的路径 | 要在多个目录间跳 | 一个功能一个目录 |
| 改一个需求 | 改动散落在 5~6 个目录 | 只动一个目录 |
| 删除一个功能 | 要小心「哪些文件属于它」 | 删一个目录 |
| 新人理解 | 需要「脑内拼接」 | ✅ 目录即功能地图 |
| 复用与共享 | 容易「什么都塞 components」 | ⚠️ 需要约定「什么算公共」 |
| 适合 | 小项目(< 20 个页面) | 中大型项目 |
⭐ 经验结论:
· 小项目 / 原型 → type-first(简单直白)
· 中大型项目 → feature-first(可维护性更好)
· 两者可以混合:features/ 按功能 + components/ 放公共组件 + lib/ 放工具
二、推荐的完整结构
src/
├── app/ 应用级装配(不写业务)
│ ├── router.tsx 路由配置
│ ├── providers.tsx 全局 Provider 组合
│ └── store.ts (如用 Redux)store 配置
│
├── features/ ⭐ 按业务功能划分(改需求只动这里)
│ ├── auth/
│ │ ├── components/ 该功能的组件
│ │ │ ├── LoginForm.tsx
│ │ │ └── LoginForm.test.tsx
│ │ ├── hooks/
│ │ │ └── useLogin.ts
│ │ ├── api.ts 该功能的接口
│ │ ├── types.ts 该功能的类型
│ │ ├── constants.ts
│ │ └── index.ts ⭐ 对外出口(只导出「允许被依赖」的东西)
│ └── orders/
│
├── components/ ⭐ 跨功能的公共组件(不含业务逻辑)
│ ├── ui/ 基础组件(无业务)
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.module.css
│ │ │ └── index.ts
│ │ ├── Modal/
│ │ └── Table/
│ └── layout/ 布局组件
│ ├── AppLayout.tsx
│ └── AuthLayout.tsx
│
├── hooks/ 通用 Hook(与业务无关)
│ ├── useDebounce.ts
│ ├── useLocalStorage.ts
│ └── useEventListener.ts
│
├── lib/ 通用工具与基础设施
│ ├── http.ts 请求层(封装 fetch/axios)
│ ├── query-client.ts TanStack Query 配置
│ ├── format.ts 格式化(日期/数字/货币)
│ └── storage.ts localStorage 封装
│
├── pages/ 路由级页面(组装 features 与 components)
│ ├── LoginPage.tsx
│ ├── OrderListPage.tsx
│ └── NotFoundPage.tsx
│
├── types/ 全局类型(跨功能共享)
│ ├── api.d.ts
│ └── global.d.ts
│
├── assets/ 静态资源(图片、字体、图标)
├── locales/ 国际化文案
└── main.tsx 入口
⭐ 四条结构原则:
① 【app/ 不写业务】——只做「装配」(路由、Provider、store)
② 【features/ 自包含】——一个功能的所有代码(组件/hook/接口/类型)在同一个目录
③ 【components/ 不含业务】——只放「与业务无关」的通用组件(否则该放到 feature 里)
④ 【依赖方向单向】——pages → features → components/lib(不允许反向依赖)
三、组件分层的四个层次
① 基础组件(Primitives / UI)
· 无业务逻辑、无接口调用、纯「UI + 交互」
· 例子:Button、Input、Modal、Tooltip、Table、Icon
· 位置:components/ui/
· 特点:可被任何地方复用;有完整的「无障碍与键盘支持」
② 组合组件(Composites)
· 由基础组件组合、解决一类「通用交互模式」
· 例子:SearchBar、Pagination、FilterPanel、UserCard(展示型)
· 位置:components/ 或 features/xxx/components/
· 特点:可能带「弱业务」(如「分页组件的页码逻辑」),但不知道具体业务
③ 业务组件(Feature Components)
· 含业务逻辑(调接口、读写 store、业务规则)
· 例子:OrderTable、OrderForm、PaymentPanel
· 位置:features/orders/components/
· 特点:只在对应的 feature 内部使用;不跨业务复用
④ 页面组件(Pages)
· 路由级入口,负责「组装」(布局 + 业务组件 + 数据获取)
· 例子:OrderListPage、OrderDetailPage
· 位置:pages/ 或 features/xxx/pages/
· 特点:通常只做「编排与布局」,尽量薄
| 层次 | 有业务逻辑吗 | 可复用范围 | 位置 |
|---|---|---|---|
| 基础组件 | ❌ | 全局 | components/ui/ |
| 组合组件 | ⚠️(弱) | 全局或跨功能 | components/ |
| 业务组件 | ✅ | 仅在所属 feature | features/x/components/ |
| 页面组件 | ✅(组装) | 不可复用 | pages/ |
// ✅ 各层次的样子
// ① 基础组件(无业务)
export function Button({ variant = 'primary', ...rest }: ButtonProps) {
return <button className={clsx('btn', `btn--${variant}`)} {...rest} />;
}
// ② 组合组件(通用交互模式)
export function SearchBar({ onSearch, placeholder }: SearchBarProps) {
const [keyword, setKeyword] = useState('');
return (
<form onSubmit={(e) => { e.preventDefault(); onSearch(keyword); }}>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder={placeholder} />
<Button type="submit">搜索</Button>
</form>
);
}
// ③ 业务组件(含业务逻辑)
export function OrderTable() {
const { data, isLoading } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders });
const cancelOrder = useMutation({ mutationFn: cancelOrderApi });
// ...
return <Table /* ... */ />;
}
// ④ 页面组件(只做组装)
export function OrderListPage() {
return (
<AppLayout title="订单列表">
<OrderFilters />
<OrderTable />
</AppLayout>
);
}
四、公共组件的沉淀标准
⭐ 「要不要把它抽到公共组件」的判断标准(四条全满足才抽):
① 【被 2 个以上业务复用】
—— 只有一处用 → 留在原地(YAGNI)
② 【有稳定的接口】
—— API 还在频繁变 → 先别抽(抽了会「改一处崩多处」)
③ 【不依赖具体业务】
—— 依赖了业务类型/接口 → 应该留在 feature 里
④ 【有文档或测试】
—— 否则「别人不敢用、不知道怎么用」
⚠️ 反模式:「一见相似就抽公共组件」
· 两个页面「看起来像」但「会向不同方向演化」→ 硬抽会导致「加一堆 if 分支」
· 判断标准是「它们是否真的会一起变化」(Change Together 原则)
⭐ 「组件库内部」的三个成熟度标志:
① API 稳定(有版本号、有 breaking change 政策)
② 有文档(Props 表、示例、常见组合)
③ 有测试与视觉回归(改样式不会回归)
五、文件命名与导出
⭐ 命名约定:
· 组件文件与组件同名:UserCard.tsx(PascalCase)
· Hook:useUserList.ts
· 工具:formatDate.ts(camelCase)
· 样式:Button.module.css(与组件同名)
· 测试:Button.test.tsx(与被测文件同目录)
· 类型:types.ts 或 同目录的 xxx.types.ts
⭐ 目录式组件(复杂组件推荐):
components/ui/Button/
├── Button.tsx
├── Button.module.css
├── Button.test.tsx
├── Button.stories.tsx
└── index.ts ← export { Button } from './Button'
// ✅ index.ts(barrel 文件)的取舍
// 优点:导入更短(from '@/components/ui' 而不是 '@/components/ui/Button/Button')
// 缺点:① 可能引发循环依赖
// ② 打包工具「可能」无法 tree-shake(现代工具基本没问题)
// ③ 「跳转到定义」会先跳到 index(体验稍差)
// ✅ 关键:feature 的 index.ts「只导出允许被外部依赖的东西」
// features/orders/index.ts
export { OrderListPage } from './pages/OrderListPage';
export type { Order } from './types';
// ⚠️ 不导出「内部实现」(如 OrderTable、useOrderFilters)
// → 这样「外部依赖变少」→ 重构更容易
六、依赖规则(很重要)
⭐ 单向依赖(必须遵守):
pages → features → components / hooks / lib
↑
(只允许「上层依赖下层」)
❌ 禁止:
① features/orders 依赖 features/users 的内部实现
→ 如果确实需要:通过「公共层」(components/lib)或「公开出口(index.ts)」
② components 依赖 features(公共组件不能有业务)
③ 循环依赖(A → B → A)
→ 会导致「模块初始化顺序问题」、「打包体积异常」、「难以测试」
// ✅ 用 ESLint 插件强制约束(eslint-plugin-boundaries 或 no-restricted-imports)
{
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{ "group": ["@/features/*/components/*"], "message": "feature 内部组件不可跨功能直接引用,请通过 index.ts 导出" },
{ "group": ["@/features/*"], "importNames": ["default"], "message": "请使用具名导出" }
]
}]
}
}
⭐ 检测与避免循环依赖:
① 用 `eslint-plugin-import` 的 `import/no-cycle` 规则(运行时报错)
② 用 `madge --circular src` 扫描
③ 常见成因:
· barrel 文件(index.ts)互相引用
· 「类型」与「实现」循环(用 `import type` 可避免运行时循环)
· store ↔ api ↔ components 互相引用(应该是「单向」)
七、常见坑
① 所有组件都塞进 components/(按类型划分的通病)
→ 半年后没人敢删(不知道哪些还在用)
② 一个巨型 components/ 目录有 200 个文件(没有分层与子目录)
③ feature 之间「互相直接引用内部文件」
→ features/orders 里 import 'features/users/components/UserTable'
→ 应该:① 通过 users 的 index.ts 导出;② 或把共用的提到公共层
④ 公共组件里写了业务逻辑(如「提交订单」)
→ 它就不能被复用了
⑤ 「一见相似就抽公共组件」→ 后来靠 if 分支应付差异
⑥ 用 index.ts 造成循环依赖
⑦ 目录层级过深(src/features/a/components/b/ui/c/...)→ 5 层以上难导航
⑧ 命名不一致(有的 PascalCase、有的 kebab-case、有的 index.tsx)
⑨ 测试文件与源文件混在任意位置(应该「就近 + 统一后缀」)
⑩ 把「类型定义」散落各处(应该集中在 types.ts 或就近 xxx.types.ts)
⑪ 「页面」里写了大量业务逻辑(应该把逻辑抽到 hook 或 feature 组件)
⑫ 「路由配置」散落在各处(应该集中在 app/router.tsx)
⑬ 用「相对的深层路径」(../../../components/Button)
→ 用路径别名 @/components/ui/Button
⑭ 静态资源散落(应该统一 assets/,并按类型分子目录)
⑮ 没有「feature 的边界」概念(谁都能 import 谁)
⑯ 目录结构与「路由结构」不一致(找页面要找半天)
⑰ 「公共 Hook」里依赖了业务类型(应该放 feature 里)
⑱ 没有「废弃组件的清理机制」(旧组件长期堆积)
⭐ 清理「没人用的组件」的做法:
① 用工具检测(如 knip、ts-prune)找出「未被引用的导出」
② 加「弃用标记」(JSDoc 的 @deprecated)并在 code review 里提醒
③ 定期(如每季度)清理一次
八、面试延伸
- 「你们的目录结构是怎么组织的?」
推荐「feature-first(按功能划分)」:src/features/下每个业务功能(auth、orders、users)自包含自己的「组件 / hooks / 接口 / 类型」,并有index.ts作为对外出口;src/components/放「跨功能的公共组件」(分ui/基础组件与layout/布局);src/hooks/放通用 Hook;src/lib/放请求层与工具;src/app/只做「装配」(路由、Provider);src/pages/是路由级页面。核心好处是「改一个需求只动一个目录」「删除一个功能只需删一个目录」。 -
「type-first 和 feature-first 有什么区别?」
type-first(按类型)把「所有组件放components/、所有 Hook 放hooks/」——小项目直白,但中大型项目里「改一个需求要在 5~6 个目录间跳」,而且「删除功能时不知道哪些文件属于它」。feature-first(按功能)把「同一业务的代码放在一起」——改需求只动一个目录、删除功能只删一个目录、新人能「通过目录看懂业务」。结论:小项目可以 type-first,中大型推荐 feature-first(或两者混合)。 -
「组件怎么分层?」
四层:① 基础组件(ui/)——无业务逻辑,如 Button/Input/Modal(可全局复用);② 组合组件——由基础组件组合、解决「通用交互模式」(如 Pagination/SearchBar),可能带弱业务;③ 业务组件——含业务逻辑(调接口、读写 store),只在所属 feature 内使用;④ 页面组件——路由入口,只做「组装与布局」(尽量薄,逻辑抽到 hook 或业务组件)。判断标准是「有没有业务逻辑」与「复用范围」。 -
「什么时候该把组件抽成公共组件?」
四条标准全满足才抽:① 被 2 个以上业务复用(只有一处用 → 留在原地,YAGNI);② 有稳定的接口(API 还在频繁变时抽出来会导致「改一处崩多处」);③ 不依赖具体业务(依赖了业务类型/接口 → 应该留在 feature 里);④ 有文档或测试(否则别人不敢用)。反模式是「一见相似就抽」——如果两处代码「看起来像但会向不同方向演化」,硬抽会变成「一堆 if 分支」。 -
「怎么避免循环依赖?」
三点:① 强制单向依赖——pages → features → components/lib,不允许反向;② feature 之间只能通过index.ts暴露的「公共出口」互相引用(不能直接 import 对方内部文件);③ 类型用import type(这样「类型引用」不会形成运行时的循环)。检测手段:eslint-plugin-import的import/no-cycle规则,或madge --circular src扫描。常见成因是「barrel 文件互相引用」与「store/api/components 三角引用」。 -
「
index.ts(barrel 文件)该不该用?」
有利有弊:优点——导入路径更短、feature可以「控制对外暴露什么」(只导出允许被依赖的东西 → 重构更容易)。缺点——① 可能引发循环依赖(A 的 index 引用 B 的 index,B 又引用 A);② 某些打包配置下影响 tree-shaking(现代工具基本没问题);③ 「跳转到定义」会先跳到index(体验稍差)。推荐用法:在 feature 的边界用(作为「公开 API」),组件内部少用(直接用具体文件路径)。 -
「路径别名有什么好处?」
主要有三:① 消除深层相对路径——../../../components/Button变成@/components/ui/Button(可读且不会因移动文件而失效);② 明确「依赖层次」——看到@/features/...就知道是跨 feature 依赖(而不是同目录);③ 便于重构——目录结构调整时只需改别名配置。注意要在两处配置:构建工具(vite.config.ts的resolve.alias)与 TS(tsconfig.json的paths),否则会出现「编辑器报错但能构建」或反之。 -
「页面组件应该有多厚?」
越薄越好——它只应该做「组装」:布局、把数据传给业务组件、组合多个业务组件。不该做:① 写复杂的业务逻辑(应该抽到自定义 Hook);② 写大量 JSX(应该抽成业务组件);③ 直接调接口(应该放在 feature 的api.ts+ hook 里)。判断标准是「这个文件里有没有『编排以外』的东西」——如果有,就该下沉。薄页面的好处是「易于测试、易于复用(同一套业务组件可以配不同页面)、易于调整布局」。
一句话速记
中大型项目用 feature-first(
features/xxx/自包含 +index.ts作为对外出口),配上「components/(跨功能公共)、hooks/(通用 Hook)、lib/(请求层与工具)、app/(装配)、pages/(路由级)」;组件分四层:基础(ui/,无业务)→ 组合(通用交互)→ 业务(含业务逻辑,只在 feature 内用)→ 页面(只做组装,越薄越好);抽公共组件的四条标准是「被 ≥2 处复用 + 接口稳定 + 不依赖业务 + 有文档/测试」(别一见相似就抽);依赖必须单向(pages → features → components/lib),feature 之间只能通过index.ts互相引用,用import type与import/no-cycle/madge防循环依赖;用路径别名(两处都要配)代替深层相对路径;分工口诀是「app 不写业务、features 自包含、components 不含业务」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!