上周把项目升级到React 18正式版,结果线上出了个诡异的Bug。
页面偶尔会渲染两次,数据重复提交。用户反馈说,点一次提交按钮,有时候会收到两条相同的记录。这个Bug不是必现的,大概十次能出现一次,非常难排查。
我排查了整整一夜,从怀疑后端到怀疑浏览器,从看网络请求到翻React源码,最后发现是React 18的并发渲染和StrictMode导致的。
本文完整复盘这次Bug排查过程,从现象、排查、根因到解决方案,帮你避免同样的坑。
一、Bug现象
先说说Bug的现象。
1. 升级React 18
我们项目之前用的是React 17。React 18正式版发布后(2022年3月),我们计划升级。
升级过程很顺利:
- 把react和react-dom升级到18.0.0
- 把ReactDOM.render改成createRoot
- 开启了StrictMode
- 跑了一遍测试,没发现问题
然后就上线了。
2. 线上反馈
上线第二天,就有用户反馈:提交表单的时候,偶尔会出现两条相同的记录。
一开始我们以为是后端的问题,或者是用户点了两次。但查了日志,发现:
- 用户只点了一次按钮
- 前端发了两次相同的请求
- 两次请求间隔很短(几十毫秒)
这说明,是前端重复提交了。
3. 复现困难
这个Bug最难的地方,是复现困难。
- 不是必现的,大概十次出现一次
- 只有在特定页面、特定操作下才会出现
- 开发环境很难复现,线上容易复现
- 刷新页面后,有时候就好了
我试了很多次,终于在一次操作中复现了。然后开始了漫长的排查。
二、排查过程
1. 第一步:怀疑后端
首先怀疑的是后端。
是不是后端接口有问题,重复处理了请求?
我查了后端日志,发现确实收到了两次请求,两次请求的参数完全一样,间隔几十毫秒。后端没有做幂等处理,所以创建了两条记录。
但这说明,问题出在前端,是前端发了两次请求。后端只是"如实"处理了。
结论:后端没问题,是前端重复发请求。
2. 第二步:怀疑用户操作
然后怀疑是用户的问题。
是不是用户手抖,点了两次按钮?或者按钮没有做防抖?
我检查了按钮的代码:
<button onClick={handleSubmit}>提交</button>确实没有做防抖,也没有禁用按钮。但用户反馈说,只点了一次。而且两次请求间隔只有几十毫秒,人类不可能点这么快。
我加了一个点击计数,发现onClick确实只触发了一次。但请求发了两次。
结论:不是用户点了两次,是代码逻辑执行了两次。
3. 第三步:怀疑React渲染
然后怀疑是React的问题。
是不是组件渲染了两次,导致副作用执行了两次?
我在组件里加了console.log,发现:
- 组件函数确实执行了两次
- useEffect也执行了两次
- 但这是React 18的正常行为吗?
我查了React 18的文档,发现StrictMode在开发环境下会故意双重渲染,用来检测副作用问题。但我们是在生产环境,生产环境不应该双重渲染啊。
等等,我们的生产环境也开了StrictMode?
我检查了代码:
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);确实,生产环境也开了StrictMode。但React 18的StrictMode在生产环境不应该双重渲染啊。
结论:可能和StrictMode有关,但需要进一步确认。
4. 第四步:发现并发渲染
然后我注意到一个细节:Bug只在React 18中出现,React 17中没有。
React 18最大的变化是什么?并发渲染(Concurrent Rendering)。
React 18引入了并发渲染,React可以中断、暂停、恢复渲染。这带来了更好的用户体验,但也可能导致一些意想不到的行为。
我查了React 18的升级指南,发现了一个重要的变化:
在React 18中,状态更新可能会被批处理(Automatic Batching),而且渲染可能会被中断和恢复。如果你的副作用(useEffect)不是幂等的,可能会出问题。
我检查了我们的提交逻辑:
useEffect(() => {
if (shouldSubmit) {
submitData();
setShouldSubmit(false);
}
}, [shouldSubmit]);问题来了!如果useEffect执行了两次,第一次提交了数据,设置shouldSubmit为false。但如果渲染被中断了,第二次useEffect又执行了,shouldSubmit可能还是true,就会再次提交。
结论:可能是并发渲染导致useEffect执行了两次。
5. 第五步:确认根因
为了确认,我做了一个实验:关闭StrictMode,看Bug是否还出现。
root.render(<App />);关闭StrictMode后,测试了50次,Bug没有再出现。
然后我重新开启StrictMode,但把提交逻辑改成在事件处理函数中直接调用,而不是在useEffect中:
const handleSubmit = () => {
submitData();
};这样改了之后,Bug也没有再出现。
根因确认:React 18的StrictMode + 并发渲染,导致useEffect在某些情况下执行了两次,而我们的提交逻辑不是幂等的,所以重复提交了。
三、根因分析
详细分析一下根因。
1. React 18的StrictMode变化
在React 18中,StrictMode的行为发生了变化。
在React 17中,StrictMode只是在开发环境下双重渲染组件函数,用来检测不安全的生命周期。
在React 18中,StrictMode不仅双重渲染组件函数,还会双重执行useEffect(挂载-卸载-再挂载),用来检测副作用的清理是否正确。
而且,这个行为在开发环境和生产环境都可能出现(虽然生产环境不会像开发环境那样明显,但并发渲染可能导致类似的效果)。
2. 并发渲染的影响
React 18的并发渲染,允许React中断正在进行的渲染,去处理更高优先级的更新,然后再恢复之前的渲染。
这意味着:
- 组件函数可能执行多次
- useEffect可能执行多次
- 渲染结果可能被丢弃
如果你的代码假设"副作用只会执行一次",在React 18中可能会出问题。
3. 我们的代码问题
我们的提交逻辑有两个问题:
问题一:在useEffect中执行有副作用的操作。 提交数据是一个有副作用的操作(发请求、改数据库),不应该在useEffect中执行。useEffect应该用来处理"渲染后的副作用",比如订阅、DOM操作等。
问题二:副作用不是幂等的。 提交数据不是幂等的,执行两次就会创建两条记录。在React 18中,副作用可能执行多次,所以必须保证幂等。
4. 为什么React 17没问题
React 17没有并发渲染,渲染是同步的,useEffect只会执行一次。所以即使代码写得不规范,也不会出问题。
React 18引入了并发渲染,对代码的要求更高了。以前能跑的代码,升级后可能会出问题。
四、解决方案
找到了根因,接下来是解决方案。
1. 方案一:把提交逻辑移到事件处理函数中
这是最简单、最推荐的方案。
不要在useEffect中执行提交,而是在按钮的onClick中直接执行:
const handleSubmit = async () => {
setLoading(true);
try {
await submitData();
// 成功处理
} catch (error) {
// 错误处理
} finally {
setLoading(false);
}
};
<button onClick={handleSubmit} disabled={loading}>
{loading ? '提交中...' : '提交'}
</button>这样,只有用户点击按钮时才会提交,不会因为渲染而重复执行。
2. 方案二:保证副作用幂等
如果必须在useEffect中执行副作用,要保证它是幂等的。
比如,给每次提交加一个唯一的ID,后端根据ID做幂等处理:
useEffect(() => {
if (shouldSubmit && !submitted) {
const requestId = generateId();
submitData({ requestId });
setSubmitted(true);
}
}, [shouldSubmit]);或者用useRef标记是否已经提交:
const submittedRef = useRef(false);
useEffect(() => {
if (shouldSubmit && !submittedRef.current) {
submittedRef.current = true;
submitData();
}
}, [shouldSubmit]);3. 方案三:后端做幂等处理
不管前端怎么改,后端都应该做幂等处理。
- 给每个请求加一个唯一的requestId
- 后端根据requestId判断是否已经处理过
- 如果已经处理过,直接返回之前的结果
这样,即使前端重复发请求,后端也不会重复处理。
4. 方案四:关闭StrictMode
如果实在改不过来,可以临时关闭StrictMode。
但这只是权宜之计,不是根本解决方案。StrictMode能帮你发现潜在问题,关闭了就发现不了了。
建议还是从代码层面解决问题。
五、我们最终的方案
我们最终采用了组合方案:
- 前端:把所有提交逻辑从useEffect移到事件处理函数中,加按钮loading状态防止重复点击
- 后端:所有写接口加幂等处理,根据requestId去重
- 代码规范:新增ESLint规则,禁止在useEffect中执行有副作用的API调用
- 测试:增加并发渲染相关的测试用例
改完之后,线上再没有出现过重复提交的问题。
六、React 18升级的其他坑
顺便说说React 18升级中遇到的其他坑。
1. Automatic Batching
React 18中,所有状态更新都会自动批处理,包括setTimeout、Promise、原生事件中的更新。
这可能导致一些依赖"同步更新"的代码出问题。如果需要同步更新,可以用flushSync:
import { flushSync } from 'react-dom';
flushSync(() => {
setCount(c => c + 1);
});
// 这里count已经更新了2. 第三方库兼容性
一些老的第三方库可能不兼容React 18,比如:
- 依赖旧版context API的库
- 直接操作DOM的库
- 假设渲染是同步的库
升级前要检查依赖的兼容性。
3. 服务端渲染变化
React 18对SSR做了很大改进,支持流式SSR和选择性hydration。如果你的项目用了SSR,需要单独测试。
4. useEffect的清理函数
React 18的StrictMode会双重执行useEffect(挂载-卸载-再挂载),如果你的清理函数写得不对,可能会出问题。
确保每个useEffect都有正确的清理函数:
useEffect(() => {
const subscription = subscribe();
return () => {
subscription.unsubscribe(); // 必须正确清理
};
}, []);七、经验总结
这次Bug排查,总结了一些经验。
1. 升级大版本要充分测试
React 18是一个大版本升级,引入了很多新特性和行为变化。升级前要充分测试,尤其是边缘情况。
不要只跑通了基本功能就上线,要测试:
- 并发场景
- 快速操作
- 网络延迟
- 错误处理
2. 副作用要幂等
在React 18中,副作用可能执行多次。所有副作用都应该是幂等的,或者有正确的清理逻辑。
- API请求:加幂等ID
- 订阅:正确取消订阅
- 定时器:正确清除
- DOM操作:可重复执行
3. 不要在useEffect中做提交
提交数据、跳转页面等操作,应该在事件处理函数中执行,而不是在useEffect中。
useEffect应该用来处理"渲染后的副作用",比如同步状态到DOM、订阅外部数据源等。
4. StrictMode是好东西
StrictMode能帮你发现潜在问题,不要因为出了Bug就关闭它。
开发环境开启StrictMode,能提前发现很多问题。生产环境也可以开启,虽然会有一点性能开销,但能帮助发现问题。
5. 排查问题要有系统性
排查线上Bug,要有系统性:
- 先复现问题
- 从现象出发,一步步缩小范围
- 用排除法,一个个排除可能性
- 找到根因后,做实验验证
- 修复后,要充分测试
不要靠猜,要靠证据。
八、写在最后
这次React 18的Bug,让我排查了一夜,但也让我对React 18有了更深的理解。
React 18的并发渲染,是一个很大的变化。它带来了更好的用户体验,但也对代码质量提出了更高的要求。以前能跑的代码,升级后可能会出问题。
作为前端开发者,我们要跟上框架的变化,理解新特性的原理,写出更健壮的代码。
2022年了,React 18已经正式发布,越来越多的项目会升级。希望这次Bug复盘,能帮你在升级React 18时避免同样的坑。
最后,用一句话总结:"React 18的并发渲染,要求我们的副作用是幂等的、可清理的。把提交逻辑放在事件处理函数中,不要在useEffect中做有副作用的操作。"
愿你的React 18升级之路,一帆风顺。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录