说实话。我已经专业地使用React多年了,但对React的重新渲染过程并没有真正理解。
我认为这对许多React开发者来说都是真实的情况。我们了解的足够多以应付工作,但如果你向一群React开发者提问:"什么会触发React的重新渲染?"你可能会得到几个不同且含糊的答案。
关于这个问题有很多误解,这可能会导致很多不确定性。如果我们不了解React的渲染周期,又怎么能理解如何使用React.memo,或者何时应该在函数周围包裹useCallback呢?
在本教程中,我们将建立一个心理模型,了解何时以及为什么React会重新渲染。
React的核心循环
让我们从一个基本的事实开始:React中的每次重新渲染都始于状态变化。这是React中唯一的"触发器",用于使组件重新渲染。
现在,这可能听起来不太对...毕竟,当组件的props变化时,它们不会重新渲染吗?那上下文(context)呢?
事实是:当一个组件重新渲染时,它的所有后代组件也会重新渲染。
让我们看一个例子:
js
import React from 'react';
function App() {
return (
<>
<Counter />
<footer>
<p>Copyright 2022 Big Count Inc.</p>
</footer>
</>
);
}
function Counter() {
const [count, setCount] = React.useState(0);
return (
<main>
<BigCountNumber count={count} />
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</main>
);
}
function BigCountNumber({ count }) {
return (
<p>
<span className="prefix">Count:</span>
{count}
</p>
);
}
export default App;
在这个例子中,我们有3个组件:顶层的App组件,它渲染Counter组件,而Counter组件又渲染BigCountNumber组件。
在React中,每个状态变量都与特定的组件实例相关联。在这个例子中,我们有一个单独的状态变量count,它与Counter组件相关联。
每当这个状态发生变化时,Counter组件重新渲染。而由于BigCountNumber是由Counter组件渲染的,它也会重新渲染。
好的,让我们澄清一个误解:每当状态变量发生变化时,整个应用都会重新渲染。
我知道有些开发者认为在React中每次状态变化都会强制整个应用重新渲染,但这是不正确的。重新渲染只会影响拥有该状态的组件以及其后代(如果有的话)。在这个例子中,当count状态变量发生变化时,App组件不必重新渲染。
然而,我们不必把这视为一条规则,让我们退后一步,看看为什么会是这样的。
React的"主要任务"是将应用程序UI与React状态保持同步。重新渲染的目的是找出需要变化的部分。
让我们考虑上面的"Counter"例子。当应用程序首次挂载时,React会渲染所有组件,并得出以下DOM草图:
js
<main>
<p>
<span class="prefix">Count:</span>
0
</p>
<button>
Increment
</button>
</main>
<footer>
<p>Copyright 2022 Big Count Inc.</p>
</footer>
当用户点击按钮时,count状态变量从0变为1。这将如何影响UI呢?这就是我们希望通过进行另一次渲染来了解的!
React重新运行Counter和BigCountNumber组件的代码,并生成我们想要的新的DOM草图:
js
<main>
<p>
<span class="prefix">Count:</span>
1
</p>
<button>
Increment
</button>
</main>
<footer>
<p>Copyright 2022 Big Count Inc.</p>
</footer>
每次渲染都是一个快照,就像相机拍摄的照片一样,它展示了基于当前应用程序状态应该是什么样子的UI。
React进行了一个"寻找差异"的游戏,以确定这两个快照之间发生了什么变化。在这种情况下,React发现我们的段落有一个文本节点,从0变为1,因此它编辑文本节点以与快照匹配。满意地完成了工作,React回到原位,等待下一次状态变化。
这就是React的核心循环。
我们的count状态与Counter组件相关联。由于数据在React应用程序中不能向"上"流动,我们知道这个状态变化不可能影响组件。因此我们不需要重新渲染那个组件。
但是我们需要重新渲染Counter的子组件BigCountNumber。这是实际显示count状态的组件。如果我们不渲染它,我们将不知道我们的段落的文本节点应该从0变为1。我们需要在草图中包含这个组件。
重新渲染的目的是确定状态变化应该如何影响用户界面。因此,我们需要重新渲染所有可能受影响的组件,以获得准确的快照。
这与props无关。
好的,让我们来谈谈误解#2:一个组件会因为其props的改变而重新渲染。
让我们用一个更新后的例子来探讨。
在下面的代码中,我们的"Counter"应用程序新增了一个全新的组件Decoration:
js
//App.js
import React from 'react';
import Counter from './Counter';
function App() {
return (
<>
<Counter />
<footer>
<p>Copyright 2022 Big Count Inc.</p>
</footer>
</>
);
}
export default App;
//Counter.js
import React from 'react';
import Decoration from './Decoration';
import BigCountNumber from './BigCountNumber';
function Counter() {
const [count, setCount] = React.useState(0);
return (
<main>
<BigCountNumber count={count} />
<button onClick={() => setCount(count + 1)}>
Increment
</button>
{/* 👇 This fella is new 👇 */}
<Decoration />
</main>
);
}
export default Counter;
//Decoration.js
function Decoration() {
return (
<div className="decoration">
⛵️
</div>
);
}
export default Decoration
//BigCountNumber.js
function BigCountNumber({ count }) {
return (
<p>
<span className="prefix">Count:</span>
{count}
</p>
);
}
export default BigCountNumber;
(由于将所有组件放在一个大文件中有些拥挤,我稍微重新组织了一下。但是总体的组件结构与之前相同,除了新增的Decoration组件。)
现在我们的计数器在角落里有一艘可爱的小帆船,由Decoration组件渲染。它不依赖于count,所以当count发生变化时它可能不会重新渲染,对吗?
嗯,不完全是这样的。
当一个组件重新渲染时,它会尝试重新渲染所有后代组件,无论它们是否通过props传递了特定的状态变量。
现在,这似乎有些违反直觉...如果我们没有将count作为props传递给,为什么它需要重新渲染呢?
答案是:React很难以100%的确定, 是否直接或间接地依赖于count状态变量。
在理想的情况下,React组件应该始终是"纯粹"的。纯组件是指当给定相同的props时,它总是产生相同的UI。
但在现实世界中,我们的许多组件是"不纯"的。创建一个不纯组件非常容易:
js
function CurrentTime() {
const now = new Date();
return (
<p>It is currently {now.toString()}</p>
);
}
这个组件每次渲染时会显示不同的值,因为它依赖于当前的时间!
更隐蔽的问题版本涉及到refs。如果我们将一个ref作为props传递,React将无法确定在上一次渲染后是否对其进行了更改。因此,为了保险起见,它选择重新渲染。
React的首要目标是确保用户看到的UI与应用程序状态保持"同步"。因此,React会在重新渲染方面保持谨慎。它不想冒着显示过时UI的风险。
所以,回到我们的误解:props与重新渲染无关。我们的组件并不是因为count prop发生变化而重新渲染的。
当一个组件重新渲染时,因为它的某个状态变量已更新,这次重新渲染将会级联地传递到整个组件树,以便React填充这个新草图的细节,捕捉新的快照。
这是标准的操作流程,但有一种方法可以稍微调整它。
创建纯组件
你可能对React.memo或React.PureComponent类组件很熟悉。这两个工具允许我们忽略某些重新渲染请求。
具体实现如下:
javascript
function Decoration() {
return (
<div className="decoration">
⛵️
</div>
);
}
export default React.memo(Decoration);
通过使用React.memo包裹我们的Decoration组件,我们告诉React:"嘿,我知道这个组件是纯组件。除非它的props发生了变化,你不需要重新渲染它。"
这使用了一种被称为"记忆化"的技术。
虽然少了一个re-Render,但我们可以把它看作是"记忆化"。其思想是,React会记住之前的快照。如果props都没有发生变化,React将重用那个过时的快照,而不是费劲地生成一个全新的快照。
假设我将BigCountNumber和Decoration两者都用React.memo包裹。这将如何影响重新渲染呢?
当count发生变化时,我们重新渲染Counter,React将尝试渲染两个后代组件。
由于BigCountNumber接受count作为prop,并且该prop已经改变,BigCountNumber会被重新渲染。但由于Decoration的props没有改变(因为它没有任何props),所以原始快照会被重用。
我喜欢假装React.memo有点像一个懒惰的摄影师。如果你让它拍摄同样的东西5张照片,它只会拍摄1张照片并给你5份拷贝。只有当你的指令改变时,摄影师才会拍摄新的照片。
以下是一个实时代码版本,如果你想自己尝试一下。每个被memo化的组件都添加了console.info调用,这样你可以在控制台中看到每个组件的渲染时间:
js
//App.js
import React from 'react';
import Counter from './Counter';
function App() {
return (
<>
<Counter />
<footer>
<p>Copyright 2022 Big Count Inc.</p>
</footer>
</>
);
}
export default App;
//Counter.js
import React from 'react';
import Decoration from './Decoration';
import BigCountNumber from './BigCountNumber';
function Counter() {
const [count, setCount] = React.useState(0);
return (
<main>
<BigCountNumber count={count} />
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<Decoration />
</main>
);
}
export default Counter;
//Decoration.js
import React from 'react';
function Decoration() {
console.info('Decoration render');
return (
<div className="decoration">
⛵️
</div>
);
}
export default React.memo(Decoration);
//BigCountNumber.js
import React from 'react';
function BigCountNumber({ count }) {
console.info('BigCountNumber render');
return (
<p>
<span className="prefix">Count:</span>
{count}
</p>
);
}
export default React.memo(BigCountNumber);
你可能会想:为什么这不是默认行为?大多数情况下,这不是我们想要的吗?如果我们跳过不需要重新渲染的组件,性能肯定会有所提升,对吗?
作为开发者,我们往往高估了重新渲染的成本。对于我们的Decoration组件而言,重新渲染是非常快速的。
如果一个组件有很多props而且后代组件不多,与检查任何props是否发生变化相比,重新渲染该组件实际上可能更慢。
因此,对于我们创建的每个组件都进行记忆化处理可能会适得其反。React被设计得非常快速地捕获这些快照!但在特定情况下,对于有很多后代组件或执行大量内部工作的组件,这个辅助函数可以帮助提升性能。
这种情况在未来可能会发生改变! React团队正在积极研究是否可以在编译阶段"自动记忆化"代码。目前仍处于研究阶段,但早期的实验看起来很有希望。
想了解更多信息,请查看Xuan Huang的演讲《React without memo》。
那么上下文(context)呢?
我们还没有讨论过上下文,但幸运的是,它并不会让这些东西变得太复杂。
默认情况下,如果一个组件的状态发生变化,它的所有后代组件都会重新渲染。因此,如果我们通过上下文将该状态提供给所有后代组件,这并不会改变任何事情;无论如何,这些组件都会重新渲染!
从纯组件的角度来看,上下文有点像是"隐形props",或者可以说是"内部props"。
让我们看一个例子。下面是一个纯组件,它使用了一个UserContext上下文:
ini
const GreetUser = React.memo(() => {
const user = React.useContext(UserContext);
if (!user) {
return "Hi there!";
}
return `Hello ${user.name}!`;
});
在这个例子中,GreetUser是一个没有props的纯组件,但它有一个"隐形"或"内部"的依赖:存储在React状态中的用户,并通过上下文传递。
如果用户状态变量发生变化,将会进行重新渲染,GreetUser会生成一个新的快照,而不是依赖于过时的图片。React可以知道这个组件正在使用特定的上下文,因此它将其视为一个prop。
这基本上相当于:
javascript
const GreetUser = React.memo(({ user }) => {
if (!user) {
return "Hi there!";
}
return `Hello ${user.name}!`;
});
请尝试一个实时例子:
js
import React from 'react';
const UserContext = React.createContext();
function UserProvider({ children }) {
const [user, setUser] = React.useState(null);
React.useEffect(() => {
// Pretend that this is a network request,
// fetching user data from the backend.
window.setTimeout(() => {
setUser({ name: 'Kiara' });
}, 1000)
}, [])
return (
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
);
}
function App() {
return (
<UserProvider>
<GreetUser />
</UserProvider>
);
}
const GreetUser = React.memo(() => {
const user = React.useContext(UserContext);
if (!user) {
return "Hi there!";
}
return `Hello ${user.name}!`;
});
export default App;
请注意,这仅在纯组件使用React.useContext钩子消费上下文时才会发生。如果纯组件不尝试使用上下文,您不必担心上下文会破坏大量纯组件的功能。
深入探讨
当你开始使用性能分析工具时,你会注意到有时候纯组件(Pure Components)即使在没有任何变化的情况下也会重新渲染!
React 中一个微妙又让人费解的事情是组件本质上是 JavaScript 函数。当我们渲染一个组件时,实际上是在调用这个函数。
这意味着每次渲染时,React 组件内部定义的所有内容都会被重新创建。
举个快速的例子,考虑以下情况:
js
function App() {
const dog = {
name: 'Spot',
breed: 'Jack Russell Terrier'
};
return (
<DogProfile dog={dog} />
);
}
每次渲染这个App组件,我们都在生成一个全新的对象。这可能会对我们的纯组件造成严重影响;即使我们用React.memo包裹它,这个DogProfile子组件还是会重新渲染!
还想继续学习吗?我发布了第二篇博文《理解useMemo和useCallback》,更深入地探讨了记忆化和优化的概念。我们在这篇博文中扩展了我们在之前博文中构建的思维模型,并学习了如何使用React中两个最难解的钩子。