通俗易懂的React原理(番外篇之二):意料之外的组件卸载

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

最近又在工作中遇到了一个比较怪的问题,排查的时候也是用到了前面学习React源码的知识。今天再写一篇番外来记录一下。

问题介绍

背景是,在一个商品列表里,有很多折叠状态的商品卡片。当我点击商品卡片的时候,会调接口获取商品详情,和推荐的低价商品。接口返回后,卡片状态从折叠变为展开,下面会渲染出商品的详情。此外,列表中的其他某个商品,会被打上低价商品的标签。下图为简单示意

而我遇到的问题就是,最开始只实现了折叠/展开功能的时候,卡片是能正常展开的。但是当我加上了给指定卡片打上低价标签的功能之后,再点击卡片就不会展开了。

分析问题

代码实现的思路是,在商品卡片组件内部,维护一个折叠/展开的state。在商品列表里,维护一个低价商品的id的state。然后,列表组件将修改id的dispatch方法,通过context传递给商品卡片。当点击商品卡片后,调用接口,拿到接口数据后,修改卡片内部的折叠/展开状态,并且通过dispatch去修改列表中维护低价商品id的状态。

你看我上面的描述,可能以为代码就是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export const List = ()=>{
const [lowId, setLowId] = useState(null)
// 中间省略
return <ListContextProvider value={setLowId}>
{
list.map(item=> <Card />)
}
</ListContextProvider>
}

export const Card = ()=>{
const [expanded, setExpanded] = useState(false)
const {setLowId} = useListContext()
// 中间省略
const queryDetail = ()=>{
fetch().then(
setLowId(xx)
setExpanded(true)
)
}
return <div onClick = {queryDetail}>
{expanded && <Detail />}
</div>
}

按上面的代码来实现这个功能,不会有任何问题。

但是实际上的代码要更复杂很多,这就是我们容易犯的毛病,我们容易过度简化问题,略去的复杂度里有些不关键,但有些很关键。

不重要的复杂度,最后接受点击事件的只是Card里的一个按钮组件,它其实和外面的List隔了好几层,这导致了我排查问题的时候多花了点时间,因为我要一层一层往外查,才能知道问题出在了哪。

而重要的复杂度在于,List组件根本就不是这样的。由于列表本身内容也比较多,所以列表组件其实分为了header,body,footer三部分,而上面我们写的其实只是body的部分。并且最为关键的一点是,header,body,footer三部分,都是被定义在List组件里面的。也就是说List的代码是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export const List = () => {
const [lowId, setLowId] = useState(null)

const ListHeader = () => {
return <div>Header</div>
}

const ListBody = () => {
return <>
{list.map(item=> <Card />)}
</>
}

const ListFooter = () => {
return <div>Footer</div>
}

return <>
<ListHeader />
<ListBody />
<ListFooter />
</>
}

经过排查,我发现,当点击卡片后,确实执行到了setExpanded(true),但是再次渲染的时候,expanded的值却是false。这说明,卡片组件被卸载掉了。因为我们知道,只有当组件被首次mount的时候,才会使用useState传入的初始值,而后续update的时候,会使用被重新设定后的值。这一点在前面讲解useState/useReducer的文章里,如果了解了它的源码实现,理解这一点是没有问题的。

为了进一步验证这一点,我在Card组件里,增加了一个useEffect,它依赖项为空,return一个方法,里面是console.log语句,就像这样

1
2
3
4
5
useEffect(()=>{
return ()=>{
console.log('component unmount')
}
},[])

这个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传入,而不是把组件定义在父组件里,通过闭包去获取父组件的参数以图省事。这点省下来的力气,在以后排查问题的时候都是需要还债的。


通俗易懂的React原理(番外篇之二):意料之外的组件卸载
https://miku03090831.github.io/2025/11/11/通俗易懂的React原理(番外篇之二):意料之外的组件卸载/
作者
qh_meng
发布于
2025年11月11日
许可协议