🔗 原文链接:Why React Re-Renders
👨💻 原作者:Josh W. Comeau
📅 发布时间:2022年8月16日,最后更新:2025年5月9日
📢 译者说明
⚠️ 关于本译文
本文基于 Josh W. Comeau 的原文进行忠实翻译,力求准确传达原作者的技术观点和逻辑结构。🎨 特色亮点:
- 保持原文的完整性和技术准确性
- 采用自然流畅的中文表达,避免翻译腔
- 添加画外音板块,提供译者的补充解读和实践心得
- 使用生动比喻帮助理解复杂概念
💡 画外音说明:文中标注为画外音的部分是译者基于实际开发经验添加的拓展解释,旨在帮助读者更好地理解和应用这些概念,不代表原作者观点。
📖 建议结合英文原版阅读,获得最完整的学习体验!
🚀 开篇:为什么 React 重新渲染让人困惑?
说实话,我在 React 江湖上混了好几年,才真正搞明白这个"重新渲染"到底是个什么鬼!😅 就像学开车,一开始只知道踩油门刹车,后来才明白发动机是怎么工作的。
我想这对很多 React 开发者来说都是事实。我们理解得足够多,能够应付工作,但如果你问一群 React 开发者一个问题,比如"什么会触发 React 中的重新渲染?",你可能会得到一堆五花八门的答案,就像问"为什么天会下雨"一样,答案从"云朵哭了"到"大气循环"都有!
关于这个话题有很多误解,这可能导致很多不确定性。如果我们不理解 React 的渲染周期,我们怎么能理解如何使用 React.memo,或者什么时候应该用 useCallback 包装我们的函数呢??
在这个教程中,我们将一起揭开 React 重新渲染的神秘面纱,构建一个清晰的心理模型。我们还将学习如何用 React devtools 这个"侦探工具"来找出为什么特定组件重新渲染。
目标受众
本教程是为帮助初级/中级 React 开发者更舒适地使用 React 而写的。如果你刚开始学习 React,你可能希望先收藏这个,几周后再回来!就像学武功,先练基本功,再学绝招!
🧠 核心概念:React 的"心脏"是如何跳动的?
所以,让我们从一个基本事实开始:React 中的每个重新渲染都始于状态变化。 这是 React 中组件重新渲染的唯一"触发器",就像心脏跳动需要血液流动一样!*在过去,有一个"forceUpdate()"方法也会触发重新渲染,但现在不存在了,就像过时的武功秘籍一样被淘汰了
现在,这听起来可能不对...毕竟,当组件的 props 改变时,组件不会重新渲染吗?那 context 呢??别急,让我们慢慢来,就像侦探破案一样,一步步揭开真相!
事情是这样的:当组件重新渲染时,它也会重新渲染所有它的后代。 就像多米诺骨牌,一旦第一块倒下,后面的都会跟着倒下!
让我们看一个例子:
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 渲染,它也会重新渲染。
这里有一个交互式图表,展示了这个机制在行动中的样子。点击"Increment"按钮来触发状态变化:
(绿色闪烁表示组件正在重新渲染。)
好的,让我们清除大误解 #1:每当状态变量改变时,整个应用程序都会重新渲染。
我知道一些开发者相信 React 中的每个状态变化都会强制进行应用程序范围的渲染,但这不是真的!这就像以为打喷嚏会传染整个城市一样夸张。重新渲染只影响拥有状态的组件 + 它的后代(如果有的话)。在这个例子中,App 组件在 count 状态变量改变时不必重新渲染,就像楼下邻居家装修不会影响你家一样。
但是,与其把这个作为规则来记忆,让我们退后一步,看看我们是否能弄清楚为什么它这样工作。
React 的主要工作是让应用程序 UI 与 React 状态保持同步,就像一个尽职的管家,时刻关注主人的需求变化。重新渲染的目的是弄清楚需要改变什么。
让我们考虑上面的Counter例子。当应用程序首次挂载时,React 就像一位画家,渲染我们所有的组件,并得出以下关于 DOM 应该是什么样子的草图:
<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 的草图:
<main>
<p>
<span class="prefix">Count:</span>
</p>
<button>
Increment
</button>
</main>
<footer>
<p>Copyright 2022 Big Count Inc.</p>
</footer>
每次渲染都是一个快照,就像相机拍摄的照片,显示了基于当前应用程序状态 UI 应该是什么样子。
React 玩一个"找不同"的游戏来弄清楚这两个快照之间有什么变化,类似于玩"大家来找茬"一样!在这种情况下,它看到我们的段落有一个文本节点从 0 变成了 1,所以它编辑文本节点以匹配快照。满意于工作完成,React 就像完成任务的员工一样,安定下来等待下一个状态变化。
这就是 React 的核心循环。 就像心脏的跳动,有规律、有节奏,永不停息!
有了这个框架,让我们再次看看我们的渲染图:
我们的 count 状态与 Counter 组件相关联。因为数据不能在 React 应用程序中"向上"流动,我们知道这个状态变化不可能影响 <App />。所以我们不需要重新渲染那个组件。
但我们确实需要重新渲染 Counter 的子组件,BigCountNumber。这是实际显示 count 状态的组件。如果我们不渲染它,我们就不会知道我们段落的文本节点应该从 0 变成 1。我们需要在我们的草图中包含这个组件。
重新渲染的目的是弄清楚状态变化应该如何影响用户界面。所以我们需要重新渲染所有可能受影响的组件,以获得准确的快照。
💡 画外音
这里有个关键理解:React 的重新渲染不是惩罚,而是必要的计算过程。就像解数学题,你需要重新计算才能得到新答案,而不是简单地在旧答案上涂改。这就像做饭,你不能把昨天的剩菜热一下就说是新菜,需要重新烹饪!
🎭 不是关于 props 的问题:又一个"美丽的误会"!
好的,让我们谈谈大误解 #2:组件会因为它的 props 改变而重新渲染。
这个误解就像以为"下雨是因为云朵哭了"一样可爱,但事实并非如此!让我们用一个更新的例子来探索,就像侦探一样一步步揭开真相。
在下面的代码中,我们的"Counter"应用程序被赋予了一个全新的组件,Decoration,就像给房子加了个装饰品:
// 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.jsx
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.jsx
function Decoration() {
return (
<div className="decoration">
⛵️
</div>
);
}
export default Decoration;
// BigCountNumber.jsx
function BigCountNumber({ count }) {
return (
<p>
<span className="prefix">Count:</span>
{count}
</p>
);
}
export default BigCountNumber;
(因为在一个大文件中放置所有组件变得有点拥挤,所以我重新组织了。但整体组件结构是相同的,除了新的
Decoration 组件。)
我们的计数器现在在角落有一个可爱的小帆船,由 Decoration 组件渲染,它不依赖于 count,所以当 count 改变时它可能不会重新渲染,对吧?
好吧,呃,不完全是这样。这就像你以为"只要我不动,别人就不会注意到我"一样天真!
当组件重新渲染时,它试图重新渲染所有后代,无论它们是否通过 props 传递特定的状态变量。
现在,这似乎违反直觉。为什么 Decoration 需要重新渲染?它甚至不知道 count 是什么!这就像为什么你感冒了,你室友也要跟着戴口罩一样奇怪!
答案是这样的:React 很难 100% 确定 <Decoration> 是否直接或间接依赖于 count 状态变量。
🧠 画外音
这里有个重要的概念:React 的重新渲染是自上而下的瀑布流。就像多米诺骨牌,一旦父组件倒下,所有子组件都会跟着倒下。也像瀑布一样,水从高处流下,下面的水池都会受到影响!React 需要重新渲染
Decoration的原因是:它不知道Decoration是否依赖于count。想象一下,如果
Decoration组件内部有这样的代码:function Decoration() { // 这个组件实际上 DOES 依赖于 count! const count = React.useContext(CountContext); return ( <div className="decoration"> {count > 5 ? '🎈' : '🚢'} </div> ); }如果 React 跳过重新渲染
Decoration,我们就不会看到装饰从帆船变成气球!这就像错过了魔术表演最精彩的部分一样遗憾!所以 React 采取安全的方法:重新渲染所有后代,以防万一。 就像"宁可错杀一千,不可放过一个"的谨慎策略。
这也是为什么 React 被称为"声明式"而不是"命令式"的原因。我们告诉 React "UI 应该是什么样子",而不是"如何改变 UI"。就像点菜时告诉服务员"我要一份宫保鸡丁",而不是"请先切鸡丁,再放花生米,最后勾芡"一样!
在理想世界中,React 组件应该总是"纯"的。纯组件是指当给定相同的 props 时,总是产生相同 UI 的组件。
但在现实世界中,我们的很多组件都是不纯的。创建一个不纯的组件出奇地容易:
function CurrentTime() {
const now = new Date();
return (
<p>It is currently {now.toString()}</p>
);
}
这个组件每次渲染时都会显示不同的值,因为它依赖于当前时间!就像时钟一样,每分每秒都在变化。
这个问题的更隐蔽版本与 refs 有关。如果我们传递一个 ref 作为 prop,React 就无法判断我们是否在上次渲染后修改了它。所以它选择重新渲染,以确保安全。
React 的首要目标是确保用户看到的 UI 与应用程序状态保持"同步"。因此,React 宁愿渲染过多,也不愿冒险向用户显示过时的 UI。这就像宁愿多跑几趟确认,也不愿错过重要的信息一样!
所以,回到我们的误解:props 与重新渲染无关。我们的 <BigCountNumber> 组件不是因为 count prop 改变而重新渲染的。
当组件重新渲染时,是因为它的一个状态变量被更新了,这个重新渲染会像瀑布一样一直向下级联,以便 React 能够填充这个新草图的细节,捕获一个新的快照。
这是标准的操作程序,但有一种方法可以稍微调整它。
🎯 创建纯组件:给组件装上"智能开关"!
好的,所以重新渲染是不可避免的,就像太阳每天都要升起一样。但是如果我们能跳过某些组件的重新渲染会怎样呢?这就像给房间装上智能开关,只有需要的时候才开灯!
你可能熟悉 React.memo,或者 React.PureComponent 类组件。这两个工具允许我们忽略某些重新渲染请求。
这就是它的样子:
function Decoration() {
return (
<div className="decoration">
⛵️
</div>
);
}
export default React.memo(Decoration);
通过用 React.memo 包装我们的 Decoration 组件,我们告诉 React:"嘿,我知道这个组件很乖。除非它的 props 改变,否则你不需要重新渲染它。"
这使用了一种称为记忆化(memoization)的技术。
虽然缺少字母 R,但我们可以把它想象成"记忆化"。想法是 React 会记住之前的快照。如果没有 props 改变,React 会重用那个过时的快照,而不是费事地生成一个全新的快照。就像记住了一个数学题的答案,下次遇到同样的题目直接写答案就行。
让我们假设我用 React.memo 助手包装了 BigCountNumber 和 Decoration。这会影响重新渲染的方式:
当 count 改变时,我们重新渲染 Counter,React 会尝试渲染两个后代组件。
因为 BigCountNumber 接受 count 作为 prop,而且那个 prop 已经改变,所以 BigCountNumber 被重新渲染。但是因为 Decoration 的 props 没有改变(因为它没有任何 props),所以使用了原始快照。
我喜欢假装 React.memo 有点像懒惰的摄影师。如果你要求它拍摄 5 张完全相同的东西的照片,它会拍摄 1 张照片并给你 5 份副本。只有当你的指示改变时,摄影师才会拍新照片。就像复印机一样,一次扫描,多次打印!
这里有一个实时代码版本,如果你想自己试试的话。每个记忆化组件都添加了一个 console.info 调用,所以你可以在控制台中看到每个组件确切何时渲染:
💡 译者注:下面这块的代码请跳到原文查看~
你可能会想:为什么这不是默认行为??这不是我们大多数时候想要的吗?如果我们跳过不需要渲染的组件,性能肯定会提高吧?
我认为作为开发者,我们倾向于高估重新渲染的成本。在我们上面的 Decoration 组件的情况下,重新渲染是闪电般快速的,就像眨眼一样快。
如果一个组件有一堆 props 但没有很多后代,检查是否有任何 props 改变实际上可能比重新渲染组件更慢。
💡 译者注:原文作者表示没有这个说法的具体来源,但他看到过像 Dan Abramov 这样的知名开发者在社交媒体上提出过这个观点。
因此,记忆化我们创建的每个组件会适得其反。React 被设计为可以非常快速地捕获这些快照!但在特定情况下,对于有很多后代的组件或做大量内部工作的组件,这个助手可以帮上很多忙。就像给每个房间都装智能开关,虽然高级,但可能有点过度了。
😄 这在未来可能会改变!
React 团队正在积极研究是否有可能在编译步骤中自动记忆化代码。它仍处于研究阶段,但早期实验看起来很有希望。就像自动驾驶一样,虽然还在测试,但未来可期!
🎭 画外音
React.memo就像给组件装了个"智能门卫",只有真正需要的时候才放行,否则一律拦截。这大大提高了性能!就像给公司装了门禁系统,只有员工才能进,闲杂人等一律挡在门外!
🔄 Context 呢?这个"隐形杀手"!
我们还没聊过 context 呢,不过好消息是,它并没有让事情变得太复杂!
简单来说,如果组件的状态变了,它的所有"孩子"都会重新渲染。所以,不管你是通过 context 把状态传给所有后代,还是用其他方式,结果都一样——那些组件都得重新渲染!就像你感冒了,全家人都得戴口罩一样,谁也跑不掉。用 context 传状态并不会改变这个事实,该重新渲染的还是得重新渲染!
现在,对于纯组件来说,context 就像"隐形的 props",或者说是"藏在肚子里的 props"。
让我们看个例子。这里有个消费 UserContext 的纯组件:
const GreetUser = React.memo(() => {
const user = React.useContext(UserContext);
if (!user) {
return "Hi there!";
}
return `Hello ${user.name}!`;
});
在这个例子中,GreetUser 看起来是个没有 props 的纯组件,但它其实有个"隐形"的依赖——藏在 React 状态里,通过 context 传递的用户信息。就像你表面上没带钱包,但口袋里其实有钱一样!
如果用户状态变了,就会触发重新渲染,GreetUser 会生成一个新的"照片",而不是用上次的旧照片。React 很聪明,它能看出来这个组件在"偷吃"这个 context,所以把它当作一个 prop 来处理。
这基本上等同于这样:
const GreetUser = React.memo(({ user }) => {
if (!user) {
return "Hi there!";
}
return `Hello ${user.name}!`;
});
来玩玩这个可以实时运行的例子:
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);
console.log('Render with user', user);
if (!user) {
return "Hi there!";
}
return `Hello ${user.name}!`;
});
export default App;
注意,这只发生在纯组件消费 context 时,使用 React.useContext hook。你不必担心 context 破坏一堆不试图消费它的纯组件。
🚨 画外音
这是 React 中一个重要的性能陷阱!Context 虽然方便,但会破坏纯组件的优化,就像方便面虽然好吃,但吃多了对身体不好一样!所以在使用 context 时要格外小心。
🔍 使用 React Devtools 进行分析:你的私人侦探!
如果你使用 React 一段时间了,你可能有过试图弄清楚为什么特定组件重新渲染的令人沮丧的经历。在真实世界的情况下,这通常一点都不明显!幸运的是,React Devtools 可以提供帮助。
首先,你需要下载 React Devtools 浏览器扩展。它目前在 Chrome 和 Firefox 上可用。为了本教程的目的,我假设你使用的是 Chrome,尽管说明不会有太大差异。
用 Ctrl + Shift + I(或在 MacOS 上用 ⌘ + Option + I)打开 devtools。你应该看到两个新标签出现:
我们感兴趣的是"Profiler"。选择那个标签。
点击小齿轮图标,启用标记为"在分析时记录每个组件渲染的原因"的选项:
一般流程是这样的:
- 通过点击小蓝色"记录"圆圈开始记录。
- 在你的应用程序中执行一些操作。
- 停止记录。
- 查看记录的快照以了解更多关于发生了什么。
每次渲染都被捕获为单独的快照,你可以使用箭头浏览它们。关于组件为什么渲染的信息在侧边栏中可用:
通过点击到你感兴趣的组件,你可以看到特定组件重新渲染的确切原因。在纯组件的情况下,它会让我们知道哪个 prop(s) 负责这次更新。
我个人不经常使用这个工具,但当我使用时,它就是一个救星!
🎨 高亮重新渲染
还有一个技巧:React profiler 有一个选项,你可以高亮重新渲染的组件。
这是问题中的设置:
启用此设置后,你应该看到绿色矩形在重新渲染的组件周围闪烁:
🚀 深入探索:React 的"小秘密"
当你开始使用 profiler 时,你会注意到一件事:有时,纯组件即使看起来什么都没改变也会重新渲染!这就像你以为"我没动,别人就看不见我"一样天真!
React 有个微妙而令人困惑的地方:组件其实就是 JavaScript 函数。当我们渲染组件时,实际上就是在调用那个函数。(在类组件的情况下,我们调用的是与类关联的 render 方法,道理是一样的。)
这意味着在 React 组件内定义的任何东西,都会在每次渲染时重新创建。就像每次做饭都要重新准备食材一样!
举个简单的例子:
function App() {
const dog = {
name: 'Spot',
breed: 'Jack Russell Terrier'
};
return (
<DogProfile dog={dog} />
);
}
每次我们渲染这个 App 组件时,都会生成一个全新的对象。这会破坏我们的纯组件;这个 DogProfile 子组件无论是否用 React.memo 包装,都会重新渲染!就像每次都给同一个演员发新的身份证一样,虽然人还是那个人,但身份证号变了!
想继续学习? 我写了第二篇博客文章,Understanding useMemo and useCallback,它更深入地挖掘了记忆化和优化的概念。我们扩展了在这篇文章中构建的心理模型,学习如何使用 React 的两个最神秘的 hooks。
译者注:上面说的那篇文章,我已经进行了翻译,可以直接点击下面的连接跳转阅读:
我还有个坦白: 这些教程都来自我全新的课程"React 的乐趣"。
我使用 React 已经超过十年了,学到了很多如何有效使用它的知识。我绝对热爱用 React 工作;我尝试过几乎所有前端框架,但没有什么让我感觉像 React 一样高效。
在《React 的乐趣》中,我们会构建一个关于 React 真正如何工作的心理模型,深入挖掘像这个教程中这样的概念。不过,与博客文章不同,我的课程采用"多模态"方法,混合了书面内容、视频内容、练习、交互式探索,甚至一些迷你游戏!
你可以在这里了解更多课程信息:
🎁 性能提示:React 性能优化的"锦囊妙计"!
React 中的性能优化是个大话题,我完全可以写好几篇博客文章来聊这个。希望这个教程能帮你打好基础,让你更好地学习 React 性能!
不过,我还是想分享几个我在 React 性能优化上学到的小技巧,就像武林高手的独门秘籍一样珍贵:
- React Profiler 的数字不可信:它显示渲染花费的毫秒数,但这个数字不太靠谱。我们通常在"开发模式"下分析,而 React 在"生产模式"下快得多。要真正了解你的应用有多快,应该用"Performance"标签测试部署后的生产应用。这样你看到的才是真实世界的数字,不仅仅是重新渲染,还有布局和绘制变化。
- 在低端设备上测试:我强烈建议在低端硬件上测试你的应用,看看第 90 百分位用户的体验如何。具体设备取决于你构建的产品,但对于这个博客,我经常在小米 Redmi 8 上测试,这是几年前在印度很火的预算手机。
- Lighthouse 分数仅供参考:Lighthouse 的性能分数不是真实用户体验的准确反映。我宁愿相信实际使用应用的感受,也不愿迷信任何自动化工具的数据。
- 关注"加载后"体验:几年前我在 React Europe 做过一个关于 React 性能的演讲!它更关注"加载后"体验,这是很多开发者容易忽视的地方。你可以在 YouTube 上找到它。
- 不要过度优化! 学 React profiler 时,很容易陷入优化狂欢,想着尽可能减少渲染次数...但说实话,React 默认就已经很优化了。这些工具最好用来解决实际的性能问题,而不是为了优化而优化。
💡 画外音
第90百分位(90th percentile)是个统计学概念,意思是90%的用户比这个快,10%的用户比这个慢。在性能测试中,我们关注第90百分位是为了了解普通用户(而不是高端用户)的真实体验。比如测试网页加载时间:第50百分位是1.2秒,第90百分位是2.8秒,这意味着90%的用户都能在2.8秒内加载完成,这个性能水平对大部分用户来说是可以接受的。
🎯 总结:恭喜你成为 React 重新渲染的"专家"!
恭喜你!现在你已经理解了 React 重新渲染的核心机制,就像从"菜鸟"升级到了"高手"!
有了这些知识,你现在可以:
- 理解为什么组件会重新渲染
- 使用
React.memo优化性能 - 避免常见的性能陷阱
- 使用正确的工具来调试性能问题
🚀 画外音
理解 React 的重新渲染机制是成为高级 React 开发者的关键一步。这就像理解了汽车的发动机原理,让你能更好地驾驶和维护它!也像学武功,理解了内功心法,招式才能发挥出真正的威力!
👏 感谢阅读:让我们一起在 React 的江湖中闯荡!
🔖 关键词标签:React、重新渲染、性能优化、React.memo、React Devtools、前端开发