陌上人如玉
公子世无双

10.6 目录结构与组件分层

10.6 目录结构与组件分层

一句话:目录结构有「按类型划分(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 里提醒
③ 定期(如每季度)清理一次

八、面试延伸

  1. 「你们的目录结构是怎么组织的?」
    推荐「feature-first(按功能划分)」:src/features/ 下每个业务功能(auth、orders、users)自包含自己的「组件 / hooks / 接口 / 类型」,并有 index.ts 作为对外出口;src/components/ 放「跨功能的公共组件」(分 ui/ 基础组件与 layout/ 布局);src/hooks/ 放通用 Hook;src/lib/ 放请求层与工具;src/app/ 只做「装配」(路由、Provider);src/pages/ 是路由级页面。核心好处是「改一个需求只动一个目录」「删除一个功能只需删一个目录」。

  2. 「type-first 和 feature-first 有什么区别?」
    type-first(按类型)把「所有组件放 components/、所有 Hook 放 hooks/」——小项目直白,但中大型项目里「改一个需求要在 5~6 个目录间跳」,而且「删除功能时不知道哪些文件属于它」。feature-first(按功能)把「同一业务的代码放在一起」——改需求只动一个目录、删除功能只删一个目录、新人能「通过目录看懂业务」。结论:小项目可以 type-first,中大型推荐 feature-first(或两者混合)。

  3. 「组件怎么分层?」
    四层:① 基础组件(ui/)——无业务逻辑,如 Button/Input/Modal(可全局复用);② 组合组件——由基础组件组合、解决「通用交互模式」(如 Pagination/SearchBar),可能带弱业务;③ 业务组件——含业务逻辑(调接口、读写 store),只在所属 feature 内使用;④ 页面组件——路由入口,只做「组装与布局」(尽量薄,逻辑抽到 hook 或业务组件)。判断标准是「有没有业务逻辑」与「复用范围」。

  4. 「什么时候该把组件抽成公共组件?」
    四条标准全满足才抽:① 被 2 个以上业务复用(只有一处用 → 留在原地,YAGNI);② 有稳定的接口(API 还在频繁变时抽出来会导致「改一处崩多处」);③ 不依赖具体业务(依赖了业务类型/接口 → 应该留在 feature 里);④ 有文档或测试(否则别人不敢用)。反模式是「一见相似就抽」——如果两处代码「看起来像但会向不同方向演化」,硬抽会变成「一堆 if 分支」。

  5. 「怎么避免循环依赖?」
    三点:① 强制单向依赖——pages → features → components/lib,不允许反向;② feature 之间只能通过 index.ts 暴露的「公共出口」互相引用(不能直接 import 对方内部文件);③ 类型用 import type(这样「类型引用」不会形成运行时的循环)。检测手段:eslint-plugin-import 的 import/no-cycle 规则,或 madge --circular src 扫描。常见成因是「barrel 文件互相引用」与「store/api/components 三角引用」。

  6. 「index.ts(barrel 文件)该不该用?」
    有利有弊:优点——导入路径更短、feature 可以「控制对外暴露什么」(只导出允许被依赖的东西 → 重构更容易)。缺点——① 可能引发循环依赖(A 的 index 引用 B 的 index,B 又引用 A);② 某些打包配置下影响 tree-shaking(现代工具基本没问题);③ 「跳转到定义」会先跳到 index(体验稍差)。推荐用法:在 feature 的边界用(作为「公开 API」),组件内部少用(直接用具体文件路径)。

  7. 「路径别名有什么好处?」
    主要有三:① 消除深层相对路径——../../../components/Button 变成 @/components/ui/Button(可读且不会因移动文件而失效);② 明确「依赖层次」——看到 @/features/... 就知道是跨 feature 依赖(而不是同目录);③ 便于重构——目录结构调整时只需改别名配置。注意要在两处配置:构建工具(vite.config.ts 的 resolve.alias)与 TS(tsconfig.json 的 paths),否则会出现「编辑器报错但能构建」或反之。

  8. 「页面组件应该有多厚?」
    越薄越好——它只应该做「组装」:布局、把数据传给业务组件、组合多个业务组件。不该做:① 写复杂的业务逻辑(应该抽到自定义 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 不含业务」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.6 目录结构与组件分层

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫