通俗易懂的React原理(番外篇之二):意料之外的组件卸载
代码以React v19.1.0为例,https://github.com/facebook/react/tree/v19.1.0 。
最近又在工作中遇到了一个比较怪的问题,排查的时候也是用到了前面学习React源码的知识。今天再写一篇番外来记录一下。
问题介绍
背景是,在一个商品列表里,有很多折叠状态的商品卡片。当我点击商品卡片的时候,会调接口获取商品详情,和推荐的低价商品。接口返回后,卡片状态从折叠变为展开,下面会渲染出商品的详情。此外,列表中的其他某个商品,会被打上低价商品的标签。下图为简单示意

而我遇到的问题就是,最开始只实现了折叠/展开功能的时候,卡片是能正常展开的。但是当我加上了给指定卡片打上低价标签的功能之后,再点击卡片就不会展开了。
分析问题
代码实现的思路是,在商品卡片组件内部,维护一个折叠/展开的state。在商品列表里,维护一个低价商品的id的state。然后,列表组件将修改id的dispatch方法,通过context传递给商品卡片。当点击商品卡片后,调用接口,拿到接口数据后,修改卡片内部的折叠/展开状态,并且通过dispatch去修改列表中维护低价商品id的状态。
你看我上面的描述,可能以为代码就是这样
1 | |
按上面的代码来实现这个功能,不会有任何问题。
但是实际上的代码要更复杂很多,这就是我们容易犯的毛病,我们容易过度简化问题,略去的复杂度里有些不关键,但有些很关键。
不重要的复杂度,最后接受点击事件的只是Card里的一个按钮组件,它其实和外面的List隔了好几层,这导致了我排查问题的时候多花了点时间,因为我要一层一层往外查,才能知道问题出在了哪。
而重要的复杂度在于,List组件根本就不是这样的。由于列表本身内容也比较多,所以列表组件其实分为了header,body,footer三部分,而上面我们写的其实只是body的部分。并且最为关键的一点是,header,body,footer三部分,都是被定义在List组件里面的。也就是说List的代码是这样的
1 | |
经过排查,我发现,当点击卡片后,确实执行到了setExpanded(true),但是再次渲染的时候,expanded的值却是false。这说明,卡片组件被卸载掉了。因为我们知道,只有当组件被首次mount的时候,才会使用useState传入的初始值,而后续update的时候,会使用被重新设定后的值。这一点在前面讲解useState/useReducer的文章里,如果了解了它的源码实现,理解这一点是没有问题的。
为了进一步验证这一点,我在Card组件里,增加了一个useEffect,它依赖项为空,return一个方法,里面是console.log语句,就像这样
1 | |
这个useEffect只会在组件被unmount的时候被执行,这样可以很简单验证组件有没有被unmount过。验证后发现Card组件确实被unmount了,那原因就要去他的上级组件里面去找。因为要么是父组件里面渲染Card组件的时候除了什么问题,或者是父组件也被unmount掉了导致的Card被unmount。我们逐层向上排查,看看究竟是哪一层组件开始,最先发生了unmount。
不过由于我上面的demo简化了组件的结构,所以也没有什么好逐层排查的了,将嫌疑犯锁定到ListBody身上。因为List组件本身没有被卸载,而Card组件又被卸载了,那就说明问题出在这个ListBody上面了。
原因解析
我们可以看到ListBody是在List组件中,用const定义的一个组件。说他是组件吧,它里面内容还挺简单的,直接返回了一段jsx,没有任何多余的代码。但是他被调用的时候,是<ListBody />这样被调用的,那说明我们这里是把他当成一个组件来看待的。
而他不管说是个函数组件也好,是个普通方法也好,或者说就是个被const定义的变量也好,他都是在List这个方法的作用域里的。每次List这个函数组件一重新渲染,ListBody就被重新定义了一遍。也就是说两次的ListBody是不同的函数,他们在内存里面的地址是不同的。而前面我们讲diff算法的那篇文章里,提到了React是如何去判断Fiber能否复用的。如果没有key的时候,Fiber的key是null,在重新渲染前后会被认为是相等的。而key相等的时候,就回去判断它们的elementType是否相等,如果相等,就服用这个Fiber。如果不相等,就卸载掉这个Fiber,重新创建一个新的Fiber。对应这里,多个子节点diff的场景,应该是这段代码
key相同:

elementType不同的时候重新创建Fiber(useFiber就是复用,没进这个分支就是重新创建):

删除未被复用的Fiber:

而很不巧,对于函数组件来说,它的elementType就是这个函数定义本身。那前面说过了,List每一次重新渲染,里面定义的ListBody都是一个新的函数,所以elementType比较这一步他是无法通过的。也就是说,他会被React的diff算法判定是一个新组件,执行mount操作。而老的组件会被unmount。
到这里,我们也就明白了,当我们实现了给指定商品打上低价标签的功能的时候,我们修改到了List组件里面的lowId,引起了List组件的重新渲染。进而由于ListBody是定义在List里面的组件,导致ListBody被反复地卸载挂载,连带着子组件Card也被卸载后重新挂载。这才有了Card里面的expanded值一直是初始值的现象。
修复问题,以及反思
修复这个问题很简单,问题出在ListBody被React卸载掉了。那我们只要不把它当做一个组件,不就好了吗?我前面说它可以被当成一个组件来调用,但是这是否有必要呢?它里面没有使用任何的hooks,那他和一个普通的函数有什么区别呢?我当然也可以把他当做一个普通的函数来调用。也就是说,从<ListBody />改为{ListBody()}。这样的话,React就不会管它了,因为在React眼里他并不是个组件。它相当于被替换成它return的那一段jsx,而不是被替换成React.createElement。
回顾一下这个问题,刚开始遇到的时候,我其实还觉得挺离谱的,主要是外面List的代码也不是我写的,没想到会有这种问题。后面排查下来感觉还是在意料之中,因为确实是能用学过的知识来解释的。这再一次印证了我在番外篇一种所说的,学习React源码是有帮助的。你积累的知识,总会在某个时刻起到作用。
而说回有问题的代码,我本身是相当反感在函数组件里面,再去定义组件这种写法的。而这次的问题也证明这种写法是很糟糕的。如果要定义组件,请在最外层,也就是模块作用域级别,去定义。
如果你只是嫌jsx层级太复杂,想要做简单的聚合的话,可以使用辅助的render函数,里面不要使用hooks,但可以写计算逻辑,最后return想要聚合到一起的jsx。明明要用小写首字母,这样可以避免后面使用它的时候把它当成组件。
而如果你除了要聚合jsx,还要处理很多逻辑,那么不妨就将它提到外面,单独写一个组件算了。必要的参数通过props传入,而不是把组件定义在父组件里,通过闭包去获取父组件的参数以图省事。这点省下来的力气,在以后排查问题的时候都是需要还债的。