通俗易懂的React原理(番外篇之一):学习React源码有什么用

代码以React v19.1.0为例,https://github.com/facebook/react/tree/v19.1.0

写在前面

不知道各位读者学习React源码是为了什么。我学习React的源码,有一部分是好奇,因为我不想把总打交道的东西当成一个黑盒。了解它的实现,不仅可以在用的时候更少踩坑,还可以知其所以然。还有一部分原因就是,我相信日常的学习积累,总有一天会派上用场。机会总是给有准备的人。

之所以写一篇番外,是前几天解决了一个公司项目偶现的问题。问题的现象是,一个React组件一直在不停的重复渲染,但是找不出重复渲染的原因。我们通过埋点,在发现短时间高频次重复渲染时,上报数据,但是也排查不出问题。这个问题困扰了我们组差不多半年时间,看起来非常玄乎,因为state,props,context全都没有发生变化。

最初我也看着一头雾水,而在最近几个月入门了解了React的源码之后,偶然间我又复现了这个问题。由于对React代码不再是完全陌生,我尝试打断点去排查问题。当时自己从晚上七点,初步定位到问题,一直到凌晨十二点半,找到问题根因。然后第二天上午,重新理一遍流程,写出最小可复现demo,心情非常舒畅。

如果说不了解React的源码,我认为是讲不清楚这个问题的原因的。从避免问题的角度来说,代码写规范一点就不会遇到这个问题。但是通常来讲,不规范的写法不至于导致这么奇特的后果,你说出去也很难令人相信。只有从源码的角度来解释,才能理解问题的真相。

包括现在很多人认为,AI的高速发展,使得人们不需要再投入大量精力去了解这些内部的实现细节,有问题可以直接问AI。实不相瞒,我学习React的源码,很大程度上也是依靠着AI,有些看不懂的代码,我会让AI去帮忙给我解释它的含义。但是我是会实际把流程串一遍的,也经常本地起一个小项目去打断点调试,来验证得到的知识和实际是否相符。

试想,如果你连React的源码都没看过,你就去问AI为什么会出现这个问题,AI给出的答案你根本没有能力去判断对错,这又有什么意义呢?我一直认为,你得出一个问题的结论,至少要能说服自己。把一个自己都没有搞清楚流程的结论,当做问题的答案,这种行为我做不到。所以所谓的“AI万能论”和“源码无用论”,我是完全无法认可的。

那么下面我讲一下我排查出的那个问题的原因吧。概括地讲,我认为这是由于React并发渲染模式下,不恰当的写法导致的无限循环渲染问题。

可复现代码在https://github.com/miku03090831/react-confusions-explain-demo,

启动后选择Infinite Loop Demo即可。

问题介绍

问题发生在React18,一个基于Remix1.x的流式渲染项目中,项目使用react-dom的新API,支持并发渲染。在某种情况下,水合的时候会产生一个低优先级的更新。很可惜的是,我还没有看到水合的部分,因此还不清楚这个低优先级的更新具体是怎么产生的。所以我的demo中,是使用useTransition来产生一个低优先级的更新。

我们看一下demo里的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
const initialObj = { count: 0, text: "hello" };

function reducer(
obj: typeof initialObj,
action: { type: string; payload?: any }
) {
switch (action.type) {
case "increment":
return { ...obj, count: obj.count + 1 };
default:
return obj;
}
}

首先,定义一个reducer,其实这个demo里不用useReducer,用useState也可以。只需要保证,我们用到的状态是个对象类型,不是基本类型,即可。

然后组件里面的逻辑也很简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const [isPending, startTransition] = useTransition();
const [ok, setOk] = useState(false);
const [obj, dispatch] = useReducer(reducer, initialObj);

useEffect(() => {
setOk(false);
}, [obj]);

const onClick = () => {
startTransition(() => {
dispatch({ type: "increment" });
});
dispatch({ type: "increment" });
};

我们看下,当触发onClick后,会去执行两次dispatch,一次低优先级,一次高优先级。而dispatch会改变obj,进而在重新渲染的时候,执行useEffect里面的逻辑。

useEffect里面的逻辑就更简单了,将ok恒设为false。那我们直觉看上去,是不是觉得点一下按钮,得到了increment两次的一个obj,和值是falseok,然后一切就结束了呢?

然而,当你本地运行,点击按钮,触发onClick后,打开控制台,你会发现这个组件一直在重新渲染。每渲染一次,他就会输出一条console.log,而控制台此刻正在不停地打印日志。

解释原因

和这个问题相关的React源码部分有,useReducer的实现、dispatch的实现、useEffect的实现、scheduler调度。以上内容虽然我没有全部讲过,但是看过前几篇文章的读者,如果能理解并发更新,可中断,以及hooks的原理,那么对于听懂这个问题的原因是有很大帮助的。

由于两次dispatch的优先级不同,React会优先执行高优先级的更新。在高优先级的更新执行完毕后,等到下一个宏任务,再执行低优先级的更新。

当你去dispatch的时候,会执行scheduleUpdateOnFiber,进而去触发React的重新渲染。

useReducer的优先级处理

我们前面dispatch了两次,第一次低优先级,第二次高优先级。那么在并发模式下,React会如何处理呢?

React会优先取更高的优先级,去重新渲染。

在重新渲染的时候,执行到useReducer,React会根据此次渲染的优先级,去计算最终的State。大致描述为,执行高优先级的更新,跳过低优先级的更新。

具体逻辑如下:优先级符合的更新被执行,低优先级的更新被跳过并保留起来。最重要的是,排在低优先级更新后的更新(例如第二个dispatch,它排在第一个dispatch后面,但它优先级更高),不论是否执行,都会被一并保留。具体原因详见上一篇对于useReducer的详解。

而第一次高优先级的更新渲染完毕后,由于obj发生了变化,那么useEffect里面的代码会被执行,执行setOk(false)

useState的eagerState优化失效

这次setOk(false)是否会导致重新渲染呢?

理论上ok的值一直从最开始就是false,那么将它改成false按照经验来说应该无事发生。这是依赖useStateeagerState优化机制,即结果不变则不触发重新渲染。

但是这个优化是有条件的,要求wip和current当前不能有待执行的更新,也就是lanes要是NoLanes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
let prevDispatcher = null;
if (__DEV__) {
prevDispatcher = ReactSharedInternals.H;
ReactSharedInternals.H = InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
const currentState: S = (queue.lastRenderedState: any);
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
// 如果state没变则不重新渲染。前提是能进入最上面的if分支
if (is(eagerState, currentState)) {
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
if (__DEV__) {
ReactSharedInternals.H = prevDispatcher;
}
}
}
}

而此刻,obj还有一个低优先级更新等待执行。所以不满足条件,React依旧会重新渲染,并且这次重新渲染是由setOk触发的,优先级依旧是高。

浅拷贝导致obj变化

由于这一次渲染的优先级还是高,那么针对obj的低优先级的dispatch还是会被跳过。

在这次渲染中,useReducer会执行一遍上次被执行过的更新(具体原理详见上一节),这一执行就出问题了,得到了一个内容虽然没有变化,但是发生了浅拷贝的对象。因为我们的reducer里面就是这么写的,返回{ ...obj, count: obj.count + 1 }

1
2
3
4
5
6
7
8
9
10
11
12
// updateReducerImpl的实现
// Process this update.
const action = update.action;
if (shouldDoubleInvokeUserFnsInHooksDEV) {
reducer(newState, action);
}
if (update.hasEagerState) {
newState = ((update.eagerState: any): S);
} else {
// 走到这个分支
newState = reducer(newState, action);
}

那么我们继续看,这一次高优先级的渲染完成后,依旧是到处理副作用的阶段,由于useEffect的依赖发生了变化,里面的setOk(false)又会被执行。进而,如上面已经所讲过的一样,又重新触发一次高优先级的渲染。

至此,这个流程已经陷入了死循环了。因为针对obj的低优先级渲染永远不会被执行到,每一次跳过它之后,都会触发useEffect,然后又产生了一个高优先级的更新。然后,就会不断重复上述的过程

流程梳理

我偷懒了不想画流程图,简要表述一下,就像这样

1
2
3
4
5
6
7
8
9
10
11
12
点击按钮
dispatch 高优先级更新
→ 渲染 → useEffectsetOk(false)
→ 再次渲染(高优先级)
→ 低优先级更新继续挂起
reducer 返回浅拷贝对象
useEffect 再次执行 setOk(false)
→ 再次渲染(高优先级)
→ 低优先级更新继续挂起
reducer 返回浅拷贝对象
→ ……

这个无限渲染的根因是三个机制叠加:

  1. 优先级处理:高优先级更新先执行,低优先级更新被挂起;
  2. eagerState 失效:由于仍存在挂起更新(lanesNoLanes),setOk(false) 不会被“结果相同则跳过”优化拦下,仍触发渲染;
  3. 对象引用变化reducer 返回的新对象(以及可能的值变化)使依赖判定为变化,useEffect 再次触发 setOk(false)

三者循环往复,形成死循环。

解决问题

那其实找到原因了,解决起来自然就是非常简单,打破循环就可以了。

比如,我们不让每一次重新渲染后,都触发useEffect里面逻辑的执行,将useEffect的依赖项,从obj这么一个对象,改成obj.count这样一个具体的值。本身useEffect去依赖一个对象,就要考虑到对象容易变化这个风险,因此写依赖项的时候更要结合实际,看你是真的需要依赖整个对象是否变化,还是依赖对象上面的具体字段是否变化。

再比如,退一步说,你真的就是要判断obj对象本身是否发生变化。那你也可以手动地去判一下,setOk的目标值,和当前ok的值是否有变化,如果没变化,不执行setOk就好了

以上两种都可以打破这个死循环

最终感悟

你只管去学,你的积累总有一天会带给你意想不到的惊喜。


通俗易懂的React原理(番外篇之一):学习React源码有什么用
https://miku03090831.github.io/2025/08/31/通俗易懂的React原理(番外篇之一):学习React源码有什么用/
作者
qh_meng
发布于
2025年8月31日
许可协议