上周把项目升级到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能帮你发现潜在问题,关闭了就发现不了了。

建议还是从代码层面解决问题。

五、我们最终的方案

我们最终采用了组合方案:

  1. 前端:把所有提交逻辑从useEffect移到事件处理函数中,加按钮loading状态防止重复点击
  2. 后端:所有写接口加幂等处理,根据requestId去重
  3. 代码规范:新增ESLint规则,禁止在useEffect中执行有副作用的API调用
  4. 测试:增加并发渲染相关的测试用例

改完之后,线上再没有出现过重复提交的问题。

六、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升级之路,一帆风顺。