如果你看到一个项目名只有三个字母——rea,你的第一反应是什么?我第一反应是:这大概是一次“重新尝试”的代号。re 是英文里最常见的前缀,表示“再”“重新”,而 a 是我惯用的 attempt 缩写。所以 rea 翻译过来就是“重试”或“再试一次”。
为什么需要重试?因为我手头刚好有个真实的重构任务:某内部后台项目里的一个数据看板页面,功能逻辑堆叠严重,两个字段的状态更新会触发整页大面积重新渲染,图表闪烁、输入卡顿,甚至请求重复发出,同事们都只敢在原有代码上“补丁摞补丁”,没人敢动核心结构。我最后决定把这个页面推倒重来,代号就叫rea。
如果你也在维护一个“只能看不能改”的前端页面,或者最近正准备用组件化的思路重新梳理自己的项目,这篇博文应该能帮到你。我会从项目命名的由来、渲染逻辑的梳理、状态管理的取舍、工程化细节的落地这四个维度完整复盘,全程用真实踩坑经历说话。这些内容不依赖某款特定的工具版本,核心是思路——只要你在写组件化的前端页面,这些经验就都适用。
1. rea的含义与重构起点:一个只能看不能改的页面
1.1 问题页面的具体症状
那个页面在业务上其实不复杂:顶部有几个筛选条件,中间是表格和图表,右侧是关联信息的预览面板。数据量不算特别大,大概几千条记录,但用户反馈“点一下要等好久”“切个日期整个页面都在闪”。
我先花了半天时间把代码从头到尾读了一遍,症状集中在四个方面:
- 状态散落在各个层级。筛选条件、表格分页、图表数据、预览面板各自维护自己的 state,互相传值全靠回调函数一层层往上抛。
- 页面根组件承担了太多职责。所有数据请求都在根组件里发,任何一个子组件状态变化,只要触发了父组件更新,整棵组件树全部重新渲染。
- 没有统一的数据请求管理。相同接口在切换条件时重复调用,返回后还要手动拼接处理逻辑。
- 代码里大量注释写着“此处逻辑暂时不能动”“这块别删,删了会崩”。这类注释背后通常是历史遗留的耦合。
这类页面最大的问题不是代码丑,而是没有任何一个人能说清楚某一时刻页面为什么呈现当前状态。数据流是乱的,组件边界是模糊的,重构就成了唯一可行的选项。
1.2 重构的第一步不是写代码,而是画数据流
拿到这种页面,很多人的第一反应是直接重写组件,赶紧把界面敲出来。我踩过这个坑,后来学乖了:在动手写代码之前,先画数据流图,哪怕是在纸上画。
我给自己定了三条约束:
- 接口返回结构不变,后端不需要配合改动。
- 页面视觉和交互方式保持不变,用户不需要重新学习。
- 组件拆分后每个文件只做一件事,所有数据传递方向必须是单向的。
然后我开始列数据流。把页面上每个区块拆出来,标注它需要哪些数据、数据从哪来、由谁触发更新、更新后影响哪些区块。这个过程花了我大概两个小时,但非常值。
比如筛选条件区,它负责产出“查询参数”;表格区消费查询参数,同时也产出一条“选中记录”;图表区消费查询参数,产出统计数值;预览面板消费选中记录。四块区域其实没有直接依赖,它们都围绕同一个状态源在转。
画完之后我意识到,最合理的结构是:用一层薄薄的页面容器管理查询参数和选中记录,下面四个子组件各自从容器拿数据、发事件,彼此完全解耦。这就是 rea 项目的基础骨架。
后来的实践也一直印证这件事:一个人写页面之前如果花半小时画数据流,后面至少省两天的返工时间。一个页面“能不能改”,本质就看它的数据流是否清晰。
2. 梳理组件渲染逻辑:数据变化如何驱动界面更新
2.1 把页面当作状态到视图的映射
在动手写 rea 的代码之前,有必要把组件渲染的底层逻辑讲透,因为后面所有优化都建立在这套逻辑之上。
你可以把组件理解成一个函数:给它一组输入,它返回一段界面描述。输入叫 props 和 state,输出是页面某一部分的虚拟描述。组件框架要做的事,就是当状态变化时对比新旧描述,找出差异,再把差异更新到真实页面中。
这个过程分两个阶段:
- 渲染阶段:组件函数重新执行,生成新的虚拟描述。
- 提交阶段:框架对比新旧描述,计算出需要改动的最小范围,然后操作真实界面。
理解了这两个阶段,很多问题就有解释了。比如“为什么我改了 state 但界面没变”——大概率是渲染阶段执行了,但对比后发现描述一致,所以没有提交任何真实改动。又比如“为什么我父组件改了数据,子组件也跟着闪”——因为父组件重新渲染,子组件被一起重新渲染了,虽然最终可能没有真实界面变更,但渲染阶段的成本已经发生了。
在 rea 项目里,我做的核心动作就是减少不必要的渲染阶段执行次数,尤其是大范围子树的重新执行。
2.2 重渲染的三个触发条件
组件函数不会无缘无故重新执行。在 rea 项目中,我归纳出触发重渲染的三类来源:
| 触发来源 | 场景举例 | 优化思路 |
|---|---|---|
| 父组件重新渲染 | 筛选条件变化,导致根组件重新执行 | 用 memo 包裹子组件,阻断无意义传递 |
| props 变化 | 父组件传入新的对象/数组/函数 | 保证引用稳定,避免每次渲染都生成新引用 |
| 自身 state 变化 | 组件内部调用状态更新函数 | 状态上移或下移,让状态尽量贴近使用者 |
日常开发中最隐蔽的是第二种。很多同学知道“props 变了会触发更新”,但没意识到每次渲染生成的普通对象、数组、箭头函数都是新引用,即使内容完全一样,子组件也会认为 props 变化了,于是跟着重新渲染。
举个最简单的例子:
function Parent() { const list = ['苹果', '香蕉', '橙子']; return <Child data={list} />; }Parent 每渲染一次,都会生成一个新的 list 数组,子组件拿到的是新引用,就会重新渲染。如果列表很长,这种无意义的重复执行就会拖慢页面。
在 rea 项目中,我遇到的情况比这个复杂,但原理相同:凡是稳定不变的数据,尽量提升到模块级常量;凡是需要缓存的复杂计算结果,用记忆化函数包装;凡是传给子组件的回调,用稳定引用包裹。
2.3 我踩过的闭包陷阱:一个看似无害的计数器
分享一个我在 rea 项目早期踩过的真实 bug,它跟组件渲染机制直接相关。
当时页面里有个弹窗,记录用户在某次操作中点击按钮的总次数。看起来极简单的功能:
function CounterPanel() { const [count, setCount] = useState(0); const handleBatchClick = () => { setTimeout(() => { // 弹窗提示当前次数 console.log(`本次操作累计点击:${count}`); }, 3000); }; return ( <button onClick={handleBatchClick}> 批量导出 </button> ); }逻辑上似乎没有毛病:用户连续点按钮,count 不断累加,3 秒后提示的是点击次数。但实测发现,每一次提示的总数都比实际值少 1,甚至在某些连点场景下少得更多。
问题出在闭包捕获。每次点击按钮时,组件渲染了一次,这次的 handleBatchClick 捕获的是本次渲染的 count 值。setTimeout 里引用的 count 是点击那一刻的快照,而不是 3 秒后的最新值。用户连点了 5 次,5 个定时器分别捕获 0、1、2、3、4,所以每个提示都比真实值少。
修复方式不复杂,用 ref 保存最新值:
function CounterPanel() { const [count, setCount] = useState(0); const countRef = useRef(0); const handleBatchClick = () => { countRef.current += 1; setCount(countRef.current); setTimeout(() => { console.log(`本次操作累计点击:${countRef.current}`); }, 3000); }; // ... }这个例子给我的启发很深:如果你没有想清楚“函数在每次渲染时都是新创建的”,你很容易写出这种玄学 bug。rea 项目后期,我要求自己和同事在写任何回调函数前先问一句:这个函数里引用的变量,是从本次渲染快照里拿的,还是从最新状态里拿的?
3. 状态管理的“渐近式”改造:先 useReducer,再谈全局状态
3.1 useState 直接堆的边界在哪里
刚拆分组件的时候,我第一版实现用了大量的 useState。筛选条件用 useState,表格页码用 useState,选中记录用 useState,图表开关用 useState……逻辑被拆开看每块都很简单,但合在一起后,问题开始显现。
最典型的场景:提交筛选条件时,需要同时重置页码、清空选中记录、刷新图表、更新 URL 参数。这一串动作在视图层就是连续调用四五个状态更新函数。单看代码没问题,但一旦中间某个状态更新被别的事件打断,或者用户连点两次提交,状态就会不一致。比如页码已经重置,但筛选条件还是旧值;图表已经请求新数据,但选中记录还停留在上一页的记录上。
useState 适合管理“独立、单一”的状态。一旦多个状态之间存在联动关系,更新某一组状态时必须保证同时生效,useState 就变得有些力不从心。这时候该考虑的是 useReducer:把一组相关状态合并成一个状态对象,用统一的 action 描述“发生了什么”,再通过 reducer 函数计算“新状态应该是什么”。
3.2 useReducer 如何整理复杂状态流
在 rea 项目中,我把筛选条件、页码、选中记录、请求状态合并为一个单独的 reducer 状态对象。举个例子,模拟其中一部分:
const initialState = { filters: { keyword: '', dateRange: null, category: 'all' }, page: 1, pageSize: 20, selected: null, loading: false, }; function queryReducer(state, action) { switch (action.type) { case 'applyFilters': return { ...state, filters: action.payload, page: 1, // 联动重置页码 selected: null, // 联动清空选中 }; case 'setPage': return { ...state, page: action.payload, }; case 'selectRecord': return { ...state, selected: action.payload, }; case 'setLoading': return { ...state, loading: action.payload, }; default: return state; } }在组件里使用时:
const [queryState, dispatch] = useReducer(queryReducer, initialState); function handleSubmit() { dispatch({ type: 'applyFilters', payload: { keyword, dateRange, category } }); }改造后最大的变化是:任何一次页面状态的改变,都可以通过查看 action 和 reducer 清晰地追踪。“applyFilters 一定重置页码、清空选中”在这个 switch 分支里一目了然。不再有散落在各个回调里的状态更新逻辑。
我在 rea 项目里特别体会到一个点:写 reducer 的时候,不要一开始就追求“优雅”“抽象”,先把业务规则一条条写清楚,哪怕 case 分支多一点都没关系。等所有联动逻辑都沉淀下来了,你自然会发现哪些分支是重复的,再做合并。先有正确,再谈 DRY。
3.3 什么时候才需要引入外部状态方案
很多前端初学者一上来就用全局状态管理库,百度一搜一大把教程,以为状态管理只有这一种解法。但 rea 项目的经验告诉我一个更务实的结论:先把 useState 和 useReducer 用到极致,再考虑外部状态库。
外部状态库解决的核心问题是:当多个组件共享一份状态数据,且组件层级很深、传递成本很高时,避免逐层 prop 传递。它的代价也很明显:引入新的依赖、增加调试成本、状态来源不再局限于组件内部,新手容易滥用全局状态导致所有数据都塞在里面。
我判断的标准很简单:
- 状态只在两个相邻组件之间共享 → 直接在父组件里管理。
- 状态在多个不相邻的组件之间共享 → 用一个共享层级管理。
- 状态更新逻辑复杂,且多组件需要响应同一数据源 → 考虑外部状态库或者自建一个轻量的状态容器。
rea 项目的用户权限信息、主题配置这类跨页面数据,最终没有引入完整的外部状态库,而是用了一个简单的模块级单例模式来管理。这个选择纯粹是从成本收益出发:项目状态量不大,模块级单例 + useReducer 足够覆盖所有场景,何必为一个复杂度还不存在的项目预支额外依赖?
也许有人会反驳:以后肯定会用上。我的应对是:等到真的出现“十个以上组件共享同一条数据、更新链条超过三层”的时候,再引入也不迟,迁移成本可比预支成本可控得多。
4. 工程化落地:路由、数据请求与错误兜底
4.1 路由拆分的思路:不是按文件拆,而是按边界拆
rea 项目重构到组件层之后,我开始处理路由层的工程化问题。原来页面是把所有区块放在同一个路由下,加载时一次性请求所有数据。这样做的坏处是:用户只是来看表格,图表模块的代码和请求也必须先执行。
路由拆分的原则只有一个:面向用户的访问意图拆分,而不是面向代码文件拆分。我把页面路由分成三层:
- 基础壳路由:只包含公共布局、导航和登录状态校验。
- 业务路由:按“看板视图”“明细视图”“配置视图”拆开。
- 区块级懒加载:在业务路由内部,对图表组件、表格组件、预览面板组件按需懒加载。
具体到代码层面,是标准的做法:
const DashboardView = lazy(() => import('./views/DashboardView')); const DetailView = lazy(() => import('./views/DetailView')); const ConfigView = lazy(() => import('./views/ConfigView')); function AppRoutes() { return ( <Routes> <Route path="/dashboard" element={<DashboardView />} /> <Route path="/detail" element={<DetailView />} /> <Route path="/config" element={<ConfigView />} /> </Routes> ); }拆分后有一个很直接的变化:用户进入看板页不再需要等“明细视图”的代码下载和解析完成。首屏时间降了大约三分之一。这个效果不是通过复杂的性能优化方案实现的,仅仅是让代码在用户真正需要的时候才加载。
4.2 数据请求的生命周期管理与竞态问题
路由拆分后,数据请求成了新的并发瓶颈。在 rea 项目里,筛选条件切换时经常遇到这样的情况:用户选择 A 条件发起请求,还没等返回,又切换成 B 条件。A 和 B 的响应先后到达,最后 B 的条件下面展示的却是 A 的响应数据。这就是典型的请求竞态。
我处理这个问题的方式是在请求函数里加一个失效标记:
useEffect(() => { let ignore = false; async function fetchList() { setLoading(true); const result = await fetch('/api/list', { params: queryState.filters, }); if (!ignore) { setList(result.data); setLoading(false); } } fetchList(); return () => { ignore = true; }; }, [queryState.filters, queryState.page]);这里的关键是 useEffect 的清理函数:当依赖项变化或组件卸载时,清理函数会执行,把 ignore 置为 true。之后旧请求即使返回,也不会再更新状态。这个模式简单可靠,几乎覆盖了日常开发里大部分竞态场景。
rea 项目里还有一些请求是组件外部的,比如路由切换后的初始化数据拉取。这些请求我用了一个可取消的请求封装,原理一样:请求对象上的 abort 方法在卸载时被调用。当用户在加载中离开页面,请求立刻终止,不会造成无意义的网络消耗。
4.3 用错误边界接管不可控异常
列表或图表渲染异常时,如果没有任何兜底,框架会卸载整棵组件树,出现白屏。这就是错误边界(error boundary)解决的问题。
rea 项目里我用了一个比较朴素的实现:包一层高阶组件,配合 getDerivedStateFromError 展示友好的错误提示。
class ErrorBoundary extends Component { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, info) { console.error('页面崩溃详情:', error, info); } render() { if (this.state.hasError) { return <div>这块区域暂时无法显示,请刷新重试</div>; } return this.props.children; } }包裹位置也很有讲究。我没把错误边界放在页面最外层,而是放在三个区块各自的外层:表格区一个、图表区一个、预览面板一个。这样任何单个区块崩溃,只影响它自己,其他区块照常工作。用户依然能看到大部分信息,只是有一小块提示需要刷新。这个体验比整页白屏好太多了。
5. 重构后的效果与一份“rea”检查清单
5.1 改动后到底省了什么
重构完成后,我对 rea 项目的效果做了一个简单统计,数据挺直观:
| 指标 | 重构前 | 重构后 |
|---|---|---|
| 页面初始加载的代码总量 | 全部加载 | 按需加载,首屏少约 35% |
| 切换筛选条件触发的组件重渲染次数 | 几乎整页所有组件 | 只重新渲染表格和图表区块 |
| 请求重复发送次数 | 平均每个操作多发出 0.5 到 1 次冗余请求 | 稳定为 1 次 |
| 页面状态定位时间 | 需要读十几处回调函数才能追清链路 | 打开 reducer 一眼定位 |
数字不是重点,重点是维护体验的变化。后来某同事接手维护 rea 项目,我在旁边看着,他只花了半天就理清了整个数据流,第一句话是“这个结构可以直接加需求”。对前端代码来说,这大概是最高的评价了。
5.2 适用于绝大多数项目的“rea 检查清单”
重构完 rea 项目后,我把整个过程中反复印证的经验整理成了一份检查清单,写进团队文档里。每次发布新页面或者重构旧页面,我都会拿这份清单过一遍:
- 状态是否尽量贴近使用者?如果一个子组件只用某个 state,别把 state 放在高层容器里。
- 数据流向是否保持单向?子组件只能通过事件上报,不应直接修改父级状态。
- 组件是否需要阻止无意义重渲染?先用 React 开发者工具观察高频更新组件,再用 memo 或稳定引用优化。
- 连线多个状态的操作是否集中管理?如果一组状态总是同时变化,考虑 useReducer 合并。
- 所有异步请求是否有竞态标记?离开页面时旧请求是否不会污染新状态?
- 每个独立业务区域是否有错误边界兜底?边界位置是否足够细?
- 代码中是否存在“这里不能动”的神秘注释?如果存在,说明依赖没有理顺,需要重建依赖关系。
现在每接到一个“页面卡顿”“请求重复”“状态错乱”的反馈,我都会先按这份清单过一遍。大多数问题都能在十分钟内定位到根因。
5.3 关于 rea 项目的一点个人体会
最后聊一点主观感受。对于这种“从头开始重新整理一个组件项目”的事,我最初总以为难点在写漂亮的组件、上复杂的技术方案。真正做完 rea 项目才发现,难点其实是克制——克制住写代码的欲望,先画数据流;克制住引入高级工具的冲动,先在现有能力范围内把状态管理理顺;克制住把所有代码塞进根组件的惯性,认真思考每个组件的边界。
如果你手头也维护着一个叫不出“为什么”的页面,我的建议是先别急着重写,花一个小时内把当前状态流转画出来。你会惊讶地发现,很多问题在纸上就已经痛痛快快地暴露出来了。rea 的意思是“再试一次”,但真正的再试一次,不是推翻重写,而是换一种思考方式重新看待同一个页面。