REACT · Vol.IV · LESSON 29 · 组件设计与工程化
目录结构与代码规范
两种组织法:按类型切,还是按功能切
上一篇(第 28 篇)用类型系统把契约焊死了,这一篇回答「东西往哪儿放」。别小看目录结构——目录是给三个月后的你和新同事看的第一份文档,也是第 27 篇架构意图的物理体现:好的目录,看一眼就知道分层。
前端目录有两种主流组织法,恰好对应后端的两种分包风格:
src/
├─ components/ // 所有组件挤一起,超过 50 个就只能靠搜索
│ ├─ UserList.tsx
│ ├─ ArticleCard.tsx
│ └─ CommentForm.tsx
├─ hooks/
│ ├─ useUsers.ts
│ └─ useComments.ts
└─ pages/
├─ UserPage.tsx
└─ ArticlePage.tsx
src/
├─ features/
│ ├─ user/
│ │ ├─ components/UserList.tsx
│ │ ├─ hooks/useUsers.ts
│ │ └─ api.ts
│ └─ article/
│ ├─ components/ArticleCard.tsx
│ └─ hooks/useArticles.ts
└─ pages/
| 按类型(分层包) | 按功能(领域包) | |
|---|---|---|
| 后端对应 | controller / service / mapper 各一摊 | 按领域模块分包(DDD 风格) |
| 优点 | 新人一眼看清「技术分类」 | 改一个需求只动一个目录;删功能 = 删一个文件夹 |
| 缺点 | 小需求要跨四五个远房目录 | 顶层缺少「全局技术视图」 |
| 适合 | 组件一二十个的 demo、原型 | 多人协作的中大型项目 |
大型项目选「按功能」的核心理由是高内聚:翻一翻真实项目的 commit 就知道,绝大多数改动只落在一个功能域里。按类型组织时,「给评论加点赞」要同时打开 components、hooks、pages 三个目录;按功能组织时,所有改动集中在 features/comment 里,review 范围一目了然。这和后端从「按层分包」演进到「按领域分包」是同一股力量:让一起变化的代码住在一起。
推荐目录树与命名约定
src/
├─ app/ // 应用骨架:路由表、全局布局、Provider 挂载
├─ pages/ // 路由级页面:很薄,基本只组装 features
├─ features/ // 业务功能域——第 27 篇的三层住在这里
│ └─ user/
│ ├─ components/ // UserList.tsx 等展示组件(View)
│ ├─ hooks/ // useUsers.ts 等逻辑 Hook(Service)
│ └─ api.ts // 该域的接口封装(Mapper)
├─ components/ui/ // 设计系统:Button、Input、Modal(公共 jar)
├─ lib/ // 与业务无关的工具:formatDate、debounce
├─ api/ // 跨域共享:axios 实例、拦截器、统一错误处理
└─ types/ // 全局类型(能用工具类型派生的别堆这里)
对着第 27 篇看:每个 feature 内部就是一套微缩三层——hooks 是 Service,api 是 Mapper,components 是 View;页面容器组件放在 feature 顶层或 pages。共享层(ui / lib / api)就是你熟悉的 common 包。铁律一条:feature 之间不许互相伸手,要复用就下沉到共享层。
命名约定
| 对象 | 约定 | 示例 |
|---|---|---|
| 组件 | PascalCase,文件与组件同名 | UserList.tsx 里 export function UserList |
| Hook | use 前缀 + camelCase,文件同名 | useUsers.ts → useUsers() |
| 工具函数 / 变量 | camelCase | formatDate、handleSubmit |
| 常量 | UPPER_SNAKE_CASE | MAX_RETRY_COUNT |
| 类型 / 接口 | PascalCase | interface UserProps |
两条最值钱的:「文件与组件同名」——打开 UserList.tsx 必然导出 UserList,找代码靠直觉而不是靠全局搜索;Hook 的 use 前缀不只是装饰,它是给 ESLint 和编译器看的标记(第 16 篇会细讲)——只有 use 开头的函数才允许调用 useState 这些 Hook,命名错了工具当场拦你。
路径别名 @/:别再数 ../../..
目录一深,相对路径就变成了猜谜:
import { Button } from "../../../components/ui/Button";
import { formatDate } from "../../../lib/date";
// 目录一挪,全项目 import 集体阵亡;review 时也看不出引的是谁
解法是配置路径别名:用 @/ 指向 src/。注意要说服两处——TS(否则编辑器报红线)和打包器(否则构建报错):
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});
import { Button } from "@/components/ui/Button";
import { formatDate } from "@/lib/date";
Java 从不为 import 路径纠结:全限定名 com.blog.user.UserService 由包结构唯一决定,import 只是缩写。@/ 别名就是给前端补上「包根」——@ 相当于 com.blog,src 内所有路径从它出发写全。区别只是 Java 编译器天然认包,前端要同时说服 TS 和打包器两边。
配一条纪律压住它:跨 feature 引用一律走共享层——features/user 不许直接 import features/article 的内部文件,要复用就下沉到 components/ui 或 lib。否则功能域之间又会缠成一团。这也是 Java 里「模块间只依赖 API 包,不依赖实现」的翻版。
ESLint + Prettier:一个管对错,一个管好看
两个工具经常被混为一谈,分工其实泾渭分明:
| 工具 | 管什么 | 典型规则 | Java 类比 |
|---|---|---|---|
| ESLint | 代码质量与正确性 | 未使用变量、误用 Hook、漏依赖数组 | CheckStyle / SpotBugs |
| Prettier | 纯格式(缩进、引号、换行) | 统一 2 空格、双引号、行宽 100 | Spotless / google-java-format |
边界要分清:能交给 Prettier 的格式问题,不要用 ESLint 规则去管,否则两个工具互相打架。而且格式交给机器之后,code review 的评论里终于只剩设计讨论——「这里缩进错了」这种评论一个都不要有。
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
export default [
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
plugins: { "react-hooks": reactHooks },
rules: {
"react-hooks/rules-of-hooks": "error",
// 呼应第 16 篇:依赖数组漏写是闭包陷阱的头号来源,建议直接设为 error
"react-hooks/exhaustive-deps": "error",
},
},
];
单说一条:react-hooks/exhaustive-deps 必开。第 16 篇盘点的十大陷阱里,一多半(闭包旧值、依赖漏写导致的「界面不更新」)都能被它在提交前拦下——这是前端最接近「编译器检查」的一道闸。
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
提交环节一句带过:husky + lint-staged 让 git commit 前只对暂存文件自动跑一遍 ESLint + Prettier——相当于给前端装上 git hooks 版的 Maven enforcer;再在 CI(第 42 篇)里放一道全量检查,双保险。本地快、线上严,这套组合你们早就玩熟了。
- 目录按功能组织(features/user/{components, hooks, api}),共享层只留 ui / lib / api。
- 命名从 Java 直译:组件 PascalCase 且文件同名,Hook 带 use 前缀,常量 UPPER_SNAKE_CASE。
- 路径别名 @/ = 前端的「包根」,TS 和打包器两边都要配;跨 feature 只走共享层。
- ESLint 管对错(exhaustive-deps 必开),Prettier 管格式;husky 把检查前移到 commit。
规范服务于协作:卷四收官
到这里,卷四「组件设计与工程化」的七篇连起来看,其实是一句话:把 Java 工程师的组织能力平移到组件世界。
| 篇 | 主题 | 一句话带走 |
|---|---|---|
| 23 | 组合优于继承 | 用 children 和组件嵌套表达扩展点,别找「继承」 |
| 24 | 受控与非受控 | 表单数据谁持有:value + onChange,或 defaultValue |
| 25 | children 与复合组件 | 把 JSX 当参数传——组件版的构造器模式 |
| 26 | HOC 与 render props | 逻辑复用的两件老兵器,如今多让位给 Hook |
| 27 | 组件拆分与职责边界 | 展示 / 容器 / 逻辑 Hook ≈ View / Controller / Service |
| 28 | TypeScript 与 React | 让 Props 契约变成编译期红线 |
| 29 | 目录结构与代码规范 | 让目录讲架构,让机器盯格式 |
本章回顾
目录与规范不是洁癖,而是把「架构意图 + 团队约定」固化成机器可检查、新人可读懂的东西:目录树讲清分层(第 27 篇),命名约定降低检索成本,路径别名理清依赖方向,ESLint / Prettier 把风格之争踢出 code review。规范多不多不重要,团队能不能不假思索地保持一致才重要——这正是你从 Java 工程带来的最值钱的习惯,也是「规范服务于协作」的全部含义。
卷四收官,静态的组件世界你已经拿下:会拆、会复用、有类型、有规范。接下来进入卷五「路由与数据获取」,让页面真正动起来:多页面、跳转、URL 参数、路由守卫——第 30 篇《React Router 与嵌套路由》,把你在 Spring MVC 里最熟的「路由 + 控制器」完整映射到前端。
Comments · 评论