首页 / React 学习笔记 / 29

REACT · Vol.IV · LESSON 29 · 组件设计与工程化

目录结构与代码规范

实战#工程化#规范

两种组织法:按类型切,还是按功能切

上一篇(第 28 篇)用类型系统把契约焊死了,这一篇回答「东西往哪儿放」。别小看目录结构——目录是给三个月后的你和新同事看的第一份文档,也是第 27 篇架构意图的物理体现:好的目录,看一眼就知道分层。

前端目录有两种主流组织法,恰好对应后端的两种分包风格:

组织法 A · 按类型:像后端的 controller/service/mapper 分层包
src/
├─ components/          // 所有组件挤一起,超过 50 个就只能靠搜索
│  ├─ UserList.tsx
│  ├─ ArticleCard.tsx
│  └─ CommentForm.tsx
├─ hooks/
│  ├─ useUsers.ts
│  └─ useComments.ts
└─ pages/
   ├─ UserPage.tsx
   └─ ArticlePage.tsx
组织法 B · 按功能:像后端的 com.blog.user / com.blog.article 领域包
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 范围一目了然。这和后端从「按层分包」演进到「按领域分包」是同一股力量:让一起变化的代码住在一起。

回想你们的 Java 单体:是不是也经历过「controller/service/mapper 按层分包」,后来才拆出 user-center、order-center 这样的模块甚至微服务?前端从按类型到按功能,走的是同一条演进路——区别只是,这次你可以在项目第二天就做对。

推荐目录树与命名约定

my-app/src · 按功能组织 + 薄共享层(可直接当脚手架用)
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 之间不许互相伸手,要复用就下沉到共享层。

features/user components/ hooks/ api.ts 微缩三层(第 27 篇) features/article components/ hooks/ api.ts 互不伸手 features/comment components/ hooks/ api.ts 要复用就下沉 共享层:components/ui · lib · api 依赖永远向下:feature → 共享层,单向、无环
图 1功能优先的目录像「竖着切」的模块:feature 之间互不依赖,只向共享层伸手

命名约定

对象约定示例
组件PascalCase,文件与组件同名UserList.tsx 里 export function UserList
Hookuse 前缀 + camelCase,文件同名useUsers.ts → useUsers()
工具函数 / 变量camelCaseformatDate、handleSubmit
常量UPPER_SNAKE_CASEMAX_RETRY_COUNT
类型 / 接口PascalCaseinterface UserProps

两条最值钱的:「文件与组件同名」——打开 UserList.tsx 必然导出 UserList,找代码靠直觉而不是靠全局搜索;Hook 的 use 前缀不只是装饰,它是给 ESLint 和编译器看的标记(第 16 篇会细讲)——只有 use 开头的函数才允许调用 useState 这些 Hook,命名错了工具当场拦你。

路径别名 @/:别再数 ../../..

目录一深,相对路径就变成了猜谜:

❌ 相对路径 · 来自 features/user/components/UserList.tsx
import { Button } from "../../../components/ui/Button";
import { formatDate } from "../../../lib/date";
// 目录一挪,全项目 import 集体阵亡;review 时也看不出引的是谁

解法是配置路径别名:用 @/ 指向 src/。注意要说服两处——TS(否则编辑器报红线)和打包器(否则构建报错):

tsconfig.json · 让 TS 认识 @
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}
vite.config.ts · 让构建器也认识 @(两边都要配)
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 空格、双引号、行宽 100Spotless / google-java-format

边界要分清:能交给 Prettier 的格式问题,不要用 ESLint 规则去管,否则两个工具互相打架。而且格式交给机器之后,code review 的评论里终于只剩设计讨论——「这里缩进错了」这种评论一个都不要有。

eslint.config.js · 平面配置(ESLint 9+),react-hooks 插件必须装
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 篇盘点的十大陷阱里,一多半(闭包旧值、依赖漏写导致的「界面不更新」)都能被它在提交前拦下——这是前端最接近「编译器检查」的一道闸。

.prettierrc · 格式之争写进配置,别写进评审意见
{
  "printWidth": 100,
  "semi": true,
  "singleQuote": false,
  "trailingComma": "all"
}

提交环节一句带过:husky + lint-stagedgit 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
25children 与复合组件把 JSX 当参数传——组件版的构造器模式
26HOC 与 render props逻辑复用的两件老兵器,如今多让位给 Hook
27组件拆分与职责边界展示 / 容器 / 逻辑 Hook ≈ View / Controller / Service
28TypeScript 与 React让 Props 契约变成编译期红线
29目录结构与代码规范让目录讲架构,让机器盯格式

本章回顾

目录与规范不是洁癖,而是把「架构意图 + 团队约定」固化成机器可检查、新人可读懂的东西:目录树讲清分层(第 27 篇),命名约定降低检索成本,路径别名理清依赖方向,ESLint / Prettier 把风格之争踢出 code review。规范多不多不重要,团队能不能不假思索地保持一致才重要——这正是你从 Java 工程带来的最值钱的习惯,也是「规范服务于协作」的全部含义。

卷四收官,静态的组件世界你已经拿下:会拆、会复用、有类型、有规范。接下来进入卷五「路由与数据获取」,让页面真正动起来:多页面、跳转、URL 参数、路由守卫——第 30 篇《React Router 与嵌套路由》,把你在 Spring MVC 里最熟的「路由 + 控制器」完整映射到前端。

Comments · 评论