我在研究react时,碰到一个关于父组件重新渲染导致子组件重新渲染的问题:
当前代码结构如下:
import "./styles.css";
import React, { useState } from "react";
export default function App() {
return (
<div className="app">
<Clicker>
<ComponentToRender />
</Clicker>
</div>
);
}
function Clicker({ children }) {
const [count, setCount] = useState(0);
return (
<div className="clicker">
<h2>You clicked {count} times!</h2>
<button onClick={() => setCount(count + 1)}>Increment</button>
{children}
</div>
);
}
function ComponentToRender() {
const count = React.useRef(0);
React.useEffect(() => {
console.log("component rendered", count.current++);
});
return (
<div className="ComponentToRender">
<p>sub</p>
</div>
);
}
当点击按钮时,count增加,<Clicker />
组件重新渲染,同时子组件ComponentToRender并不会重新渲染;
但是,当我把代码结构改成如下这样:
import "./styles.css";
import React, { useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<h2>You clicked {count} times!</h2>
<button onClick={() => setCount(count + 1)}>Increment</button>
<ComponentToRender />
</div>
);
}
function ComponentToRender() {
const count = React.useRef(0);
React.useEffect(() => {
console.log("component rendered", count.current++);
});
return (
<div className="ComponentToRender">
<p>sub</p>
</div>
);
}
此时,count增加,<App />
组件state发生变化导致重新渲染,同时子组件ComponentToRender会重新渲染;
既然都是父组件状态发生变化后重新渲染,子组件也都没有使用React.memo
,所以子组件本应该重新渲染。但是第一种情况<ComponentToRender />
却没有重新渲染呢?
React更新原理分析
要想知道这个问题的原因,我们得知道一些
react
的原理,在react中在每次更新产生后都会进行一个叫做reconciliation(协调)的过程,在其中react中会找到此次更新中fiber树发生的变化,并将他们打上一些标记,在下一个阶段中在根据这些标记对该fiber节点进行一些操作,比如当一个fiber节点被打上Placement
标记就说明待会该fiber节点对应的dom节点需要被插入dom树,理所当然的如果我们想要每次更新都能迅速的完成那么我们肯定就不能傻乎乎的去比较整颗fiber树,这是一个非常耗时的操作,所以react
实际在进行reconciliation
的过程中使用了一种启发式的diff算法
,这种算法能根据现有信息提前将一些压根没有更新的fiber子树的reconciliation
过程省去,以免白白的花时间做无用工,接下来我们会简单的介绍以下这个算法,根据代码我们可以构建出这样的一颗fiber树(省略了一些底层节点)在
setState
后,react会将该更新从产生更新的节点上向上冒泡并一路为他的父节点打上需要更新的标记,注意这种标记还需要区分是该节点有更新还是他下面的子树中有更新,这一点非常重要。下面我们就用在这棵树中把标记更新这个过程展现出来在图中我们把有更新的节点标记为红色,子树中存在更新的标记为绿色,由于
Clicker
都是HostRoot
,App
,div[class="app"]
的节点他所以他们都需要被标记为绿色标记完成后,
react
就会开始从HostRoot
开始reconciliation
的过程,在其中会更具该节点更新前后的props是否严格相等(Object.is)和是否被标记了更新决定是否继续进行他子树的reconciliation
过程,HostRoot
是个特例他是React中的一个抽象根节点,他不对应任何组件和dom节点他的props也永远都是为null
,所以再进行他的diff时他的前后props
永远是相等的,我们可以吧reconciliation
的过程中的情况分为三种:(1)一个节点更新前后props相等,且该节点没有更新,并且其子树中也没有更新,这种情况下以该节点为根的fiber子树的
reconciliation
过程就会到此为止停止diff(2)一个节点更新前后props不相等,则继续他子节点的diff过程
(3)一个节点更新前后props相等,且他自身没有更新,但是他的子树中存在更新,这种情况要复杂一点,该节点会复用前一轮更新时他的直接子fiber节点(注意这里的直接子节点就是严格意义上的子节点,比如HostRoot的直接子节点就只有App),这种情况下会发生非常有趣的情况,由于是直接复用的节点而没有重新调用组件生成JSX元素,所以复用的节点的props就是前一轮更新是的props,所以更新前后复用节点的props总是严格相等的,光说可能还有点不太直观让我们考虑以下代码
下面会使用jquery选择器的方式指明我们说的时哪个fiber节点比如$('#container')就表示哪个id为container的div所对应的fiber,虽然App中的TriggerUpdate会触发更新但是他冒泡的更新标记并不会影响到并不在他冒泡路径上和他同级的$('#static')节点所以他自己和他子树中都是没有更新标记的,因为也知道他的父级节点$('#container') div也没有更新,所以在创建$('#static') div对于的fiber节点时复用前一次的props,当接下来进行$('#static')
diff
时,由于前后props没变且它自身和子树中不包含更新,也就是上面说的第一种情况他的diff过程就会终止,即使此次更新中$('#static')对应的jsx对象的style属性是全新的对象,对于一些不会产生更新的节点react在运行时就会将他识别出来,以减少diff
的工作量问题逻辑梳理
知道上面的工作原理后,我们再来看看我们这颗fiber树的
diff
过程,不难看出直到Clicker
进行diff
是才会重新调用创建组件创建JSX元素
应为在他上层的节点都满足第三种情况,但是我们可以发现ComponentToRender
并没有被重新创建,应为他是在App
中被创建并以props.children
的形式穿给Clicker
的,又应为App
组件并没有被调用,他复用了他的子节点,所以实际上更新前后Clicker
props的children
都是严格相等的所以当进行ComponentToRender
的diff
时就会发现它属于第一种更新情况,直接结束他的diff
,而你的问题的第二种情况中可就没那么幸运了,由于ComponentToRender
的JSX元素
在App
中被重新创建,所以他的props就不相等了,所以属于第二种更新情况继续他子树的diff所以ComponentToRender
也会被调用所以会看到useEffect
中的打印,在这种情况下,如果ComponentToRender
的执行代价很高的话,就可以将这个组件包裹在memo
中这时候比较他更新前后的props是否变更,就会转换为对其中的每个属性进行浅比较,而不是直接判断严格相等,这样也会停止ComponentToRender
的diff
备注
由jsx元素的信息创建出来的fiber节点的结构(不完整,只显示了部分属性),其中type中存了
'div'
,节点是否更新和子树中是否有更新则分别存储在了lanes
和childLanes
中更多
(1) 如果你看到这里还不明白的话可以去看下这个问题,我用几十行代码实现了和react启发式diff思路相同的关键代码
(2) 如果想了解更多启发式算法内容可以查看
(3) 如果想了解更多react原理,可以了解我的项目