最近接手一个维护了大半年的 React 后台项目,新同学改第一个需求就卡了三天。不是需求复杂,是代码已经拧成一团了:改一个筛选条件要牵连五个组件,一个输入框的值从useState传到Context再传回props,没人能一次说清楚“数据到底从哪来”。
我把这段经历连同之前几个项目的维护教训揉在一起,整理成一套能落地的 React 工程实践,内部代号就叫rea。它不是 npm 包,也不是新框架,就是三条约定加一堆实操方法。这篇文章写给正在被大型 React 项目拖住的同学,也写给刚走上工程化这条路、想少走弯路的初级开发。看完之后,你可以直接拿这套思路去调整现有的项目结构、状态管理策略和测试写法。
1. rea 的核心约定:先把复杂度分类,再谈优化
我给rea定的第一条原则很简单:遇到问题不要先想“用什么库”,先想“这个问题属于哪一类”。React 项目的复杂度通常能从三个维度切开——组件边界、状态来源、副作用流向。这三个维度不解决,加多少层封装都只是把乱摊子盖住。
1.1 约定一:按“能力单元”拆组件,而不是按 UI 区块拆
很多人拆组件的习惯是“看页面长什么样”,一堵卡片拆成CardHeader、CardBody、CardFooter,这样一个页面上半屏全是文件夹和嵌套。真正稳定的拆分单位是“能力单元”:一段完整的业务行为,加上它需要的 UI,需要处理的状态,以及对外暴露的操作。
还是拿用户卡片举例。CardHeader这种拆法只描述了“位置”,它不回答任何业务问题。拆成UserSummary(展示用户资料)、FollowButton(关注/取关行为)、ArticleList(加载和展示文章列表)之后,每个组件的职责一目了然,修改关注逻辑不会碰展示样式,加载文章的状态也只在列表内部流转。
// 正确的拆分:按能力单元 export function UserCard({ userId }: { userId: string }) { return ( <div> <UserSummary userId={userId} /> <FollowButton userId={userId} /> <ArticleList userId={userId} /> </div> ) }判断边界是否正确的标准很直接:如果新需求只是“关注按钮要增加一个确认弹窗”,你需要改几个文件?理想情况下,只改FollowButton内部。如果这个改动会把UserCard、CardHeader一并带崩,说明边界划错了。
1.2 约定二:状态必须分级,全局状态是最后的选择
我在代码评审里最常听到的一句话是“这个数据两个组件都要用,扔全局 store 吧”。多数情况根本不用扔。我给rea定了五级状态来源,一级一级考虑,前面能解决绝不往后走:
- 本地状态:
useState,组件自己管 - 路由状态:查询参数、路径参数,例如筛选条件、页码、Tab 选中项
- 服务端缓存:接口返回的数据,交给请求缓存层
- 客户端全局状态:主题、登录态、语言偏好这类真跨模块数据
- 外部持久化:
localStorage、IndexedDB,需要离线或恢复场景
顺序是“离渲染最近的地方先用”。一个输入框的值就放组件里,一个页面级的筛选条件放 URL 里,一组从服务端拉来的列表放请求缓存里。最后才轮到 Redux 或者 Context。这套顺序执行下来,我见过的大部分项目根本不需要引入全局状态库。
1.3 约定三:副作用单向流动,不在渲染期间派发动作
这是新手最容易踩、老手也容易翻车的一条。渲染函数应当是纯函数:输入 props 和 state,输出 UI。任何在渲染过程中发起的setState、接口请求、路由跳转,都会造成“渲染触发了状态变化,状态变化又触发渲染”的死循环或性能塌方。
// 反例:渲染期间直接改状态 export function BadList({ items }: { items: string[] }) { const [localItems, setLocalItems] = useState(items) if (localItems.length !== items.length) { setLocalItems(items) // 渲染期间 setState,严格模式下直接报警 } return <ul>{localItems.map(...)}</ul> }副作用只允许从四个地方发起:用户事件、接口回调、订阅回调、路由切换。给代码画一条数据流箭头:事件触发动作,动作更新状态,状态驱动渲染。画不出这条箭头的代码,就该重构了。
2. 组件拆分实操:先画能力清单,再写 UI 结构
纯讲原则容易飘,我给rea配了一个能落地的拆分流程。不管新项目还是改造老项目,都按这套顺序走。
2.1 拆之前先回答三个问题
第一个问题:这个组件要解决什么业务问题?比如“关注博主”是业务问题,“卡片右上角按钮”是 UI 描述。按业务问题拆,也就是能力单元视角。第二个问题:它会因为什么原因变化?如果原因是“接口返回结构不同”或“业务规则不同”,说明它有独立变化的边界。第三个问题:它的状态是内部事务还是外部共享?如果状态只服务于它自己的交互,就锁在内部,别暴露出去。
三个问题答完之后,组件边界基本就出来了。以某个“订单列表页”为例,能力清单可能是:订单筛选器、订单表格、分页器、导出按钮。不是“左侧筛选区域”“右侧表格区域”——这样拆,之后任何一个业务改动都会同时波及两边。
2.2 展示组件、业务组件、页面组件的分层
rea不要求把所有组件都压平,它认可三个大分层,但要求每个文件明确自己属于哪层:
| 层级 | 职责 | 允许依赖 | 典型位置 |
|---|---|---|---|
| 页面组件 | 组装整页,持有路由参数和页面级状态 | 业务组件、展示组件 | pages/ |
| 业务组件 | 完成一项业务能力,管理自己的状态与请求 | 展示组件、请求层 | features/ |
| 展示组件 | 纯 UI,只接收 props 和事件回调 | 不依赖业务逻辑 | components/ |
这套分层最重要的作用是限制依赖方向:页面组件可以引用业务组件,业务组件可以引用展示组件,但展示组件不允许反过来引业务逻辑。边界清晰之后,测试也好写很多——下一章会细说。
2.3 出现这三个信号,别犹豫,立刻重构
信号一:一个文件超过两百行。超过这个阈值,说明它承担了太多能力单元的职责。信号二:同一个组件里塞了四个以上互不相关的数据请求。比如一个ProfilePage既拉用户资料又拉消息列表又拉日历事件,这些能力应该拆开。信号三:给这个组件写测试时需要 mock 七八个业务依赖。“测试难写”几乎是边界错误的直接投影。
3. 状态管理怎么做:我给状态排的优先级顺序
状态管理是最容易过度设计的地方。我见过一个模拟项目为了两个组件共享一个用户名,引入了完整的 Redux 全家桶,结果三个月后没人敢动那个 store。在rea里,状态方案的选择不是凭喜好的,是按顺序排查出来的。
3.1useState永远是默认答案
一个状态如果只被一个组件自己使用,就直接useState。这听起来像废话,但很多人会被“以后可能要共享”这种猜测带偏。等真需要共享那天再提出来并不难,提前抽象反而让当前逻辑变脏。两个组件需要共享状态,就把状态提升到它们的公共父组件,用 props 传下去;三个组件再往上提一级,到页面组件。
3.2 URL 是最容易被忽略的全局状态
筛选条件、搜索关键词、页码、Tab 选中的值,这类状态有一个共同特征:用户想通过链接分享,刷新后保持不变。它们最好的归宿不是内存,而是 URL 查询参数。
// 用 URL 管理筛选条件,刷新和分享都不丢 import { useSearchParams } from 'react-router-dom' export function useFilter() { const [searchParams, setSearchParams] = useSearchParams() const keyword = searchParams.get('keyword') ?? '' const setKeyword = (value: string) => { const next = new URLSearchParams(searchParams) if (value) next.set('keyword', value) else next.delete('keyword') setSearchParams(next) } return { keyword, setKeyword } }把状态放 URL 还有一个额外收益:它天然是全局唯一的,不会出现两个组件各存一份再互相同步的鬼畜操作。很多“状态不同步” bug 的根源,就是把本应在 URL 里的状态塞进了组件内部。
3.3 服务端数据放“缓存层”,别放全局 store
接口返回的数据,本质是服务端状态的缓存副本。它的生命周期由请求动作和失效策略管理,跟“登录态”这种客户端状态完全不是一类东西。把它们混进同一个全局 store,会让 store 越来越像个垃圾桶。
我现在的做法是用请求缓存库管理,比如TanStack Query或SWR,把接口数据、加载态、错误态全部交给它们。全局 store 里只剩两类东西:跨模块的客户端状态,比如主题、权限、登录态;以及不可恢复的临时交互状态。全局 store 越小,项目越好维护。
3.4 什么场景才值得上 Redux 这类库
坦白说,我现在的项目几乎不主动引入 Redux。真正需要它的场景有这么几个:多个页面共享的大状态,且更新逻辑非常复杂;需要撤销/重放时间旅行;团队里已经有成熟的配套基建。如果没有这些硬理由,Context加规范的useReducer往往就够了。注意Context也有自己的问题,后面踩坑章节会专门讲。
3.5 数据流方向的统一约定
rea约定父组件通过 props 向下传数据,子组件通过回调向上传事件,兄弟组件共享状态一律提升到最近公共祖先。这个约定不新,但严格执行和不执行,代码库是两个世界。不严格的项目里到处都是“祖孙组件跨层通信”,代码读起来像悬疑小说。
4. 性能优化真正该做的三件事:别一上来就全站 memo
很多团队一聊性能优化,第一反应就是把所有组件包一层React.memo。实际上这往往是最无效的投入。组件能不能被 memo 优化,取决于它是否频繁被父组件带动重渲染,以及渲染成本是否真的高。盲目包裹只增加内存开销,不带来任何收益。
4.1 先找出真正的重渲染范围
优化之前,先用 React DevTools 的 Profiler 跑一遍典型交互,看哪些组件被重复渲染了。核心关注两个指标:渲染耗时和渲染次数。一个很常见的假象是:页面卡顿,但卡点根本不在自己写的组件,而在一张巨大的表格或者一个无谓监听了高频事件的副作用里。
定位到具体组件之后,再看它为什么被渲染。rea的做法是优先把“变化部分”隔离到一个小组件里,让大块稳定 UI 不参与更新。举个例子,一个列表页的搜索框每次输入都触发整页重渲染,最有效的方案不是给列表 memo,而是把搜索框连同输入状态单独抽成组件,让列表组件收到稳定的 props。
4.2memo、useMemo、useCallback的使用前提
memo起作用的唯一前提是:props 在两次渲染之间引用相同。这也就是为什么你经常看到“明明 memo 了,还是照样重新渲染”——因为父组件每次渲染都生成了新的对象、数组、函数,子组件拿到的 props 引用全变了。
// 正确使用 useCallback:让子组件的 memo 真正生效 export function Parent() { const [count, setCount] = useState(0) const handleSave = useCallback(() => { // 保存逻辑 }, []) return <ExpensiveChild onSave={handleSave} /> }写代码之前先问自己:这个 props 在每次渲染中是否真的会变?如果会,那memo就是白写的。只有当 props 来自稳定引用,且组件渲染成本确实高(大列表、复杂表单、图表组件)时才值得包一层。普通展示组件没必要。
4.3 用交互反馈延迟来降低感知成本
还有一类性能问题不是“做了太多计算”,而是“每次输入都立刻触发了昂贵操作”,比如搜索框实时请求接口。这种场景不该用 memo 硬扛,应该把搜索词的更新延迟到用户停止输入之后,再触发请求。useDeferredValue是 React 18 之后很适合这个场景的 API,或者用最简单的防抖函数。
import { useDeferredValue, useEffect, useState } from 'react' export function SearchBox() { const [keyword, setKeyword] = useState('') const deferredKeyword = useDeferredValue(keyword) useEffect(() => { // 只有 deferredKeyword 稳定后才发起搜索 fetchSearch(deferredKeyword) }, [deferredKeyword]) return ( <input value={keyword} onChange={(e) => setKeyword(e.target.value)} /> ) }5. 测试策略跟着拆分层走,少写“勉强能跑”的用例
测试写不下去,很多时候不是姿势不对,是组件本身拆得太乱。按rea的三层结构去搭测试,你会发现每一层该测什么、测到什么程度,是自动浮现的。
5.1 单元测试测纯逻辑,组件内部不测
业务组件里的状态流转、工具函数、数据转换逻辑,这些是单元测试最喜欢的目的地。它们没有 DOM 依赖、没有网络依赖,跑得快且稳定。而组件内部有没有useState、有没有某个局部变量,这类“实现细节”不要测。原因是实现细节极易变化,测试一旦绑定内部实现,重构时就是最大的阻碍。
5.2 组件测试模拟用户视角
组件测试的核心是“用户会怎么操作,界面会怎么响应”,不是“我调用了某函数,它返回了某值”。用Testing Library的userEvent模拟点击和输入,用getByRole、getByLabelText这类语义查询去找元素,不要用 CSS 类名或组件内部变量去找。
import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' it('点击按钮后显示确认文案', async () => { render(<SubmitPanel />) await userEvent.click(screen.getByRole('button', { name: /提交/i })) expect(await screen.findByText(/已提交/i)).toBeInTheDocument() })这套写法有底气的前提,是组件边界清晰。如果组件里塞了一堆外部依赖,测试就不得不 mock 全部,最后测出来的只是自己的 mock 脚本。
5.3 端到端测试只保护主路径
端到端测试不要追求“覆盖所有按钮”,那是页面级组件该干的事。E2E 只保护用户每日高频走的主路径,比如“登录 → 创建订单 → 查看订单列表”。三到五条核心链路就足够挡住重大回归。一条 E2E 用例跑几分钟,意义在于验证整个系统跨组件、跨接口的协作没有断,而这是任何单元测试都替代不了的。
6. 我反复踩过的三个 React 坑,以及最终规避方式
这一章不是理论推演,全是我在模拟项目和实际业务里踩过、然后爬出来的实战记录。写出来给你当路标。
6.1 Context 值放得太粗,整棵组件树跟着重渲染
我之前维护过一个中后台系统,登录用户信息放在全局Context里,Provider 的value每次渲染都生成一个新对象。结果只要登录态有任何变动,所有消费这个 Context 的组件全部重渲染,页面明显卡顿。
排查时用 Profiler 确认了渲染范围,问题很快浮出水面:value指向新对象,导致所有useContext组件都认为 Context 变了。修复方案有两个,二选一即可:第一,把value用useMemo缓存,只有真正依赖的数据变化时才生成新对象;第二,把 Context 拆细,登录态和用户资料放两个 Provider,分开消费。
const value = useMemo( () => ({ user, setUser }), [user] ) return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>这个坑最值得记住的地方在于:Context不是性能免费的,它是一个“隐式依赖注入 + 广播机制”,用不对代价极高。
6.2useEffect同步两处状态,产生幽灵请求和闪烁
有一个页面需求是“URL 里的筛选条件变化后,重新请求列表”。我最初写的代码是:URL 变化后,在useEffect里更新本地状态,本地状态又触发另一个useEffect去请求接口。结果一次普通筛选操作,发出了两次接口请求,第一次用的还是旧数据。
后来我把链路改成:URL 就是唯一数据源,列表请求只由一个useEffect响应 URL 变化。本地不保存筛选副本,这样就不存在“本地状态”和“URL 状态”的同步问题。很多“幽灵请求”“点击后数据闪回旧状态”的 bug,本质都是同一个衍生数据被两份状态重复管理。
写代码前可以多问一句:这个数据,是不是能从另一个状态派生出来?凡是能派生的,都不应该被单独保存。这是根治这类问题最核心的心法。
6.3 组件直接 import 全局单例,测试互相污染
早期写代码时,我喜欢把 API 客户端或日志实例放在模块顶层导出,然后各组件直接 import。听起来很干净,但测试一跑就翻车:第一个用例往这个单例里塞了拦截器,第二个用例没清理,接口调用直接失败。
修复方式很简单:把这类外部依赖通过Provider注入组件,或至少提供一个工厂函数在每次测试里创建新实例。组件从“依赖一个全局单例”改成“依赖一个可注入的接口”,依赖关系清晰了,测试隔离也顺了。
// 通过 props 注入,组件不直接 import 模块级单例 export function PaymentPanel({ apiClient }: { apiClient: ApiClient }) { // 使用 apiClient }在生产代码里由上层传入真实实例,在测试里传入可控的假实例。这个改动看起来只是参数传递,但它切开了组件与环境的耦合,后续所有测试难题都跟着变小。
最后分享一个我在rea实践里对新人唯一强制的要求:新代码提交后,必须能一口气回答出三个问题——这条数据从哪里来,由谁修改,它变化后影响哪些 UI。回答不清楚的代码一律打回。听起来基础得不像话,但执行一段时间后,代码库会稳定到让你陌生。希望你也试试这套思路,从下一个需求开始,先画能力清单,再写组件,最后再谈用什么库。