通俗易懂的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 |
|
首先,定义一个reducer
,其实这个demo里不用useReducer
,用useState
也可以。只需要保证,我们用到的状态是个对象类型,不是基本类型,即可。
然后组件里面的逻辑也很简单
1 |
|
我们看下,当触发onClick
后,会去执行两次dispatch
,一次低优先级,一次高优先级。而dispatch
会改变obj
,进而在重新渲染的时候,执行useEffect
里面的逻辑。
而useEffect
里面的逻辑就更简单了,将ok
恒设为false。那我们直觉看上去,是不是觉得点一下按钮,得到了increment
两次的一个obj
,和值是false
的ok
,然后一切就结束了呢?
然而,当你本地运行,点击按钮,触发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
按照经验来说应该无事发生。这是依赖useState
的eagerState
优化机制,即结果不变则不触发重新渲染。
但是这个优化是有条件的,要求wip和current当前不能有待执行的更新,也就是lanes
要是NoLanes
。
1 |
|
而此刻,obj
还有一个低优先级更新等待执行。所以不满足条件,React依旧会重新渲染,并且这次重新渲染是由setOk
触发的,优先级依旧是高。
浅拷贝导致obj变化
由于这一次渲染的优先级还是高,那么针对obj
的低优先级的dispatch还是会被跳过。
在这次渲染中,useReducer
会执行一遍上次被执行过的更新(具体原理详见上一节),这一执行就出问题了,得到了一个内容虽然没有变化,但是发生了浅拷贝的对象。因为我们的reducer里面就是这么写的,返回{ ...obj, count: obj.count + 1 }
1 |
|
那么我们继续看,这一次高优先级的渲染完成后,依旧是到处理副作用的阶段,由于useEffect
的依赖发生了变化,里面的setOk(false)
又会被执行。进而,如上面已经所讲过的一样,又重新触发一次高优先级的渲染。
至此,这个流程已经陷入了死循环了。因为针对obj
的低优先级渲染永远不会被执行到,每一次跳过它之后,都会触发useEffect
,然后又产生了一个高优先级的更新。然后,就会不断重复上述的过程
流程梳理
我偷懒了不想画流程图,简要表述一下,就像这样
1 |
|
这个无限渲染的根因是三个机制叠加:
- 优先级处理:高优先级更新先执行,低优先级更新被挂起;
- eagerState 失效:由于仍存在挂起更新(
lanes
非NoLanes
),setOk(false)
不会被“结果相同则跳过”优化拦下,仍触发渲染; - 对象引用变化:
reducer
返回的新对象(以及可能的值变化)使依赖判定为变化,useEffect
再次触发setOk(false)
。
三者循环往复,形成死循环。
解决问题
那其实找到原因了,解决起来自然就是非常简单,打破循环就可以了。
比如,我们不让每一次重新渲染后,都触发useEffect
里面逻辑的执行,将useEffect
的依赖项,从obj
这么一个对象,改成obj.count
这样一个具体的值。本身useEffect
去依赖一个对象,就要考虑到对象容易变化这个风险,因此写依赖项的时候更要结合实际,看你是真的需要依赖整个对象是否变化,还是依赖对象上面的具体字段是否变化。
再比如,退一步说,你真的就是要判断obj
对象本身是否发生变化。那你也可以手动地去判一下,setOk
的目标值,和当前ok
的值是否有变化,如果没变化,不执行setOk
就好了
以上两种都可以打破这个死循环
最终感悟
你只管去学,你的积累总有一天会带给你意想不到的惊喜。