最近接手一个维护了大半年的 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。回答不清楚的代码一律打回。听起来基础得不像话但执行一段时间后代码库会稳定到让你陌生。希望你也试试这套思路从下一个需求开始先画能力清单再写组件最后再谈用什么库。