为何添加空Fragment可修复React.memo组件意外重渲染问题?
这是React的预期行为,不是bug。你猜的没错,问题确实和前面的条件渲染<Foo />直接相关,核心是React对子节点的协调规则导致的。
底层逻辑
React解析JSX的时候,会把相邻花括号里表达式返回的所有节点全部打平,塞到最近一层父容器的直接子节点列表里,不会自动给不同表达式的返回值加任何分组边界。
你最开始写的代码里,外层div的直接子节点其实是两部分拼出来的一个扁平列表:第一部分是三目运算返回的<Foo />或者null,第二部分是arr.map返回的一长串Bar组件。这两部分没有任何分隔,共享同一个子节点序列。
这时候只要第一部分的节点数量变了——比如isFoo从true变false,Foo从列表头部消失,后面所有Bar的位置都会整体往前挪一位;反过来Foo从无到有,所有Bar又会往后挪一位。React的diff算法是靠「key+节点在父级下的位置」判断能不能复用旧节点的,位置一错位,React就会认为原来的Bar都被删掉了,当前位置的是全新的节点,直接把旧Bar卸载、重新挂载新Bar。这就是你在Profiler里看到"This is the first time the component is rendered"的原因——这根本不是普通的重渲染,是组件被销毁重建了,这种情况React.memo根本拦不住,因为memo只处理组件复用场景下的props对比,组件都被删了重建,memo当然不生效。
哪怕isFoo的值完全没变,只要动态列表和其他动态节点平级放在同一个父节点下,没有稳定的边界,React在重渲染时也可能出现fiber节点归属判定错误,导致列表节点复用失败。
为什么加空Fragment就好了
你加的空标签<>...</>是React的Fragment组件,它不会渲染真实DOM,但是会生成一个稳定的fiber节点作为分组边界:
- 套了Fragment之后,外层div的直接子节点就固定是两个槽位:第一个槽位放条件渲染的Foo/null,第二个槽位永远是这个Fragment。不管Foo存不存在,Fragment在div下的位置都不会变,React每次渲染都会直接复用这个Fragment的实例,不会把它当成新节点。
- 这时候所有Bar都变成了Fragment的子节点,不再是外层div的直接子节点,Bar列表的diff是在Fragment自己的独立上下文里跑的,和外层的Foo完全没关系。只要
arr没变化,Bar的key就能稳定匹配,React.memo就能正常工作,自然不会出现无意义的重渲染。
开发建议
以后写代码的时候,只要遇到平级混写条件渲染、动态列表的场景,最好用Fragment给逻辑上独立的块做个分组,避免子节点错位导致的无效重渲染、状态丢失问题。
内容的提问来源于stack exchange,提问作者Leo Jiang

