原文链接:https://medium.com/interview-series/i-was-asked-about-react-re-renders-in-a-frontend-interview-heres-what-they-actually-wanted-433cf331d656
翻译:谢杰
审校:谢杰
React 渲染与重新渲染的清晰心智模型:为什么大多数性能“优化”都抓错了重点
在一次前端面试中,面试官让我解释 React 的重渲染机制。我自信地回答:“当 state 发生变化时,组件会重新渲染。”
面试官礼貌地点了点头,随即抛出一个让我措手不及的问题:“那么,为什么我只在父组件中修改了 state,它所有的子组件也都会重新渲染?”
那一刻我才意识到,自己对重渲染的理解流于表面。带着这个问题,我深入研究了 React 的底层工作机制,这段经历彻底改变了我编写 React 代码的方式。本文正是我希望在初学时就有人能告诉我的全部关键知识。
什么是重新渲染?
在深入探讨“何时”与“为何”会发生重渲染之前,我们必须先厘清“重新渲染”的确切含义。这一点至关重要,因为很多开发者会将重渲染与 DOM 更新混为一谈,而它们实际上是两件不同的事。
当 React 重新渲染一个组件时,它所做的仅仅是重新调用你的组件函数。就这么简单。React 执行你的函数,获取返回的 JSX,然后将其与上一次渲染的结果进行比较。你可以把这个过程想象成:React 给你的组件拍了一张新的“快照”,然后拿这张新快照和旧快照对比,找出差异。
下面这个简单的例子清晰地展示了这个概念:
function Counter() {
const [count, setCount] = React.useState(0);
// 每次组件重新渲染时,都会执行这个 console.log
console.log('Counter component rendered, count is:', count);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
每当你点击按钮,都会在控制台看到一条新的日志。整个函数会重新执行一遍,useState 返回最新的 count 值,React 会拿到一份全新的 JSX 进行处理。这就是一次重新渲染。
关键在于:组件发生重新渲染,绝不等于真实的 DOM 会被更新。React 足够智能,它会判断 JSX 的输出是否有实际变化。如果没有,它根本不会去操作真实 DOM。重渲染过程完全发生在 JavaScript 的内存中,只有当 React 检测到确实存在差异时,才会对 DOM 进行必要的更新。正是这种“渲染与更新分离”的机制,赋予了 React 卓越的性能。
重新渲染的三个根本触发因素
经过长期实践和研读官方文档,我发现 React 组件发生重新渲染,归根结底只有三个根本原因。所有其他情况,都是这三条规则的延伸或结果。
触发因素一:State 变化
这是大多数开发者都熟悉的情况。当你调用一个 state 的设置函数(如 setCount 或 setState),你是在告知 React 某些重要数据已变,组件需要重新渲染以反映此变化。
有趣的是 React 内部的处理方式。当你调用设置函数时,React 并不会立即重渲染组件,而是调度(schedule)一次重渲染。为了提升性能,React 会对多个 state 更新进行批处理(batching)。这意味着,如果你在同一个函数中连续调用了三个不同的 state 设置函数,React 足够智能,只会触发一次重渲染,而非三次。
下面的例子展示了 React 的批处理行为:
function MultiStateComponent() {
const [name, setName] = React.useState('');
const [age, setAge] = React.useState(0);
const [email, setEmail] = React.useState('');
console.log('Component rendered');
const handleSubmit = () => {
// 以下三个 state 更新会被批处理
setName('John');
setAge(30);
setEmail('john@example.com');
// 所以组件只会在所有更新之后重新渲染一次
};
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={age} onChange={e => setAge(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
<button type="button" onClick={handleSubmit}>Submit</button>
</form>
);
}
当你点击提交按钮时,控制台只会输出一条“Component rendered”日志,而非三条。这正是因为 React 将这些更新批处理在了一起。
触发因素二:父组件重新渲染
这正是那场面试中让我愣住的关键点,也是许多 React 应用性能问题的根源:当父组件重新渲染时,它的所有子组件默认也会重新渲染。
即使传递给子组件的 props 没有任何变化,子组件依然会执行其渲染逻辑。
来看一个清晰的例子:
function ParentComponent() {
const [parentCount, setParentCount] = React.useState(0);
console.log('Parent rendered');
return (
<div>
<h1>Parent Count: {parentCount}</h1>
<button onClick={() => setParentCount(parentCount + 1)}>
Increment Parent
</button>
<ChildComponent />
</div>
);
}
function ChildComponent() {
// 这个子组件没有接收任何 props,也没有自己的 state
console.log('Child rendered');
return <p>I am a child component</p>;
}
当你点击按钮增加父组件的计数时,控制台会同时输出“Parent rendered”和“Child rendered”。即便子组件的内容纹丝未动,它依然被重新渲染了。这是 React 的默认行为,其背后有合理的设计考量。
React 采取一种保守策略:它默认认为,当父组件重新渲染时,其子组件可能依赖于父组件的数据或 context,因此也需要重新渲染。与其耗费资源去“聪明地”判断子组件是否真的需要更新,React 选择统一重渲染,然后通过高效的 diff 算法来决定是否需要更新实际 DOM。
这看似有些浪费,但请记住:JavaScript 层面的重新渲染实际上非常快。只有当组件树极其庞大、或渲染逻辑本身开销巨大时,这种默认行为才可能成为性能瓶颈,值得我们去优化。
触发因素三:Context 变化
第三个触发因素是 context 值的变化。如果一个组件通过 useContext 或 Context.Consumer 消费了某个 context,那么只要该 context 的值发生改变,无论该组件的 props 或 state 是否变化,它都会重新渲染。
这一点尤其需要注意,因为 context 的变化可能导致组件树中完全不同位置的组件重新渲染。例如,一个应用顶层的 context provider 更新了值,即使中间层组件都未消费此 context,但只要某个深层组件使用了它,该深层组件就会重新渲染。
下面的例子展示了 context 变化引发的连锁反应:
const ThemeContext = React.createContext();
function App() {
const [theme, setTheme] = React.useState('light');
return (
<ThemeContext.Provider value={theme}>
<div>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
<ParentComponent />
</div>
</ThemeContext.Provider>
);
}
function ParentComponent() {
// 这个组件没有使用 context
console.log('Parent rendered');
return <ChildComponent />;
}
function ChildComponent() {
// 这个组件消费了 context
const theme = React.useContext(ThemeContext);
console.log('Child rendered with theme:', theme);
return <div className={theme}>Child content</div>;
}
点击按钮切换主题时,子组件会重新渲染(因为它消费了 context)。但有趣的是,父组件也会重新渲染。为什么?因为 App 组件(作为 provider)修改了自己的 state,触发了它自身的重渲染——根据触发因素二的规则,父组件重渲染会导致其所有子组件默认也重渲染。
常见误区:很多人都会踩的坑
理解了基本原理后,我们来澄清几个我曾深陷其中、颇具迷惑性的常见误区。
误区一:props 变化会直接触发重新渲染
许多开发者认为,只要 props 改变,组件就会重新渲染。这个理解在技术层面上并不准确,尽管表面现象常常如此。
真实情况是:父组件由于自身 state 变化或接收到新 props 而重新渲染,在它重新渲染的过程中,它向子组件传入了新的 props,子组件也因此被重新渲染。换言之,props 变化并非直接触发重渲染的“原因”,而是父组件重渲染这一事件所导致的“结果”。
来看一个能说明问题的例子:
function Parent() {
const [count, setCount] = React.useState(0);
// 每次渲染都会创建一个新的对象
const userData = { name: 'John', id: count };
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<Child user={userData} />
</div>
);
}
function Child({ user }) {
console.log('Child rendered with user:', user);
return <div>User: {user.name}</div>;
}
每次点击按钮,子组件都会重新渲染。你可能会想:“因为 props 变了,所以子组件重渲染了。”但实际发生的链条是:父组件 state 改变 → 父组件重渲染 → 父组件创建新的 userData 对象 → 将新对象作为 props 传给子组件 → 子组件因父组件重渲染而被动重渲染。理解这个区别,对于后面理解 React.memo 等优化手段至关重要。
误区二:重新渲染一定是坏事
这是最具破坏性的误解之一。很多开发者视重渲染为洪水猛兽,动辄使用 React.memo、useMemo、useCallback 等优化手段,进行“预防式优化”。这常常导致代码可读性下降、维护困难,甚至可能比未优化的版本更慢。
但真相是:重新渲染是 React 的核心工作机制,不是 bug,而是设计特性。React 本身就是为高效处理大量重渲染而设计的。其虚拟 DOM diff 算法非常高效,大多数重渲染并不会导致任何实际的 DOM 更新。
只有在明确存在性能问题时(例如通过 Chrome DevTools 的 Profiler 工具定位到具体渲染缓慢的组件),才应该考虑针对性优化。过早的优化(Premature optimization) 往往会带来比它解决的问题更多的麻烦。
误区三:将 state 设置为相同的值可以避免重新渲染
这是一个比较隐晦的误区。许多人认为,如果把 state 设置为当前已经存储的相同值,React 就不会重新渲染。这种说法部分正确,但关键细节不容忽视。
function StateComparisonExample() {
const [count, setCount] = React.useState(0);
console.log('Component rendered');
const handleClick = () => {
// 将 state 设置为相同的值
setCount(0);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Set to Zero</button>
</div>
);
}
点击按钮时,组件会在第一次点击时重新渲染,即便你设置的 count 值(0)与当前值相同。如果继续点击,React 会跳过后续的重渲染,但第一次点击仍会触发一次。
这是因为 React 内部使用 Object.is 来比较状态值是否相等。对于数字、字符串等原始值,Object.is 判断可靠;但对于对象、数组等引用类型,则需要格外小心。即使结构完全相同,只要引用不同,React 就会认为值已变化,从而触发重渲染。
理解 React.memo 及其真正的使用场景
掌握了重渲染原理后,我们可以更清晰地理解 React.memo 的作用——它是 React 中用于阻止子组件不必要重新渲染的主要工具。
React.memo 是一个高阶组件,用于记忆化你的组件。它的作用是:当父组件重新渲染时,如果传给子组件的 props 经过浅比较后没有发生变化,则跳过对该子组件的重新渲染。这打破了 React“父组件渲染必导致子组件渲染”的默认规则。
下面是一个 React.memo 真正发挥作用的场景:
function ExpensiveComponent({ data }) {
console.log('ExpensiveComponent rendered');
// 模拟耗时的计算过程
const processedData = React.useMemo(() => {
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += data.value * i;
}
return result;
}, [data]);
return <div>Processed: {processedData}</div>;
}
// 使用 React.memo 包裹,避免在 props 没变时重新渲染
const MemoizedExpensiveComponent = React.memo(ExpensiveComponent);
function ParentComponent() {
const [count, setCount] = React.useState(0);
const [data, setData] = React.useState({ value: 10 });
return (
<div>
<button onClick={() => setCount(count + 1)}>
Increment Count (doesn‘t affect data)
</button>
<button onClick={() => setData({ value: data.value + 1 })}>
Update Data
</button>
<p>Count: {count}</p>
<MemoizedExpensiveComponent data={data} />
</div>
);
}
在这个例子中,点击第一个按钮增加 count 时,MemoizedExpensiveComponent 不会重新渲染,因为它接收的 data props 没有变化。这正是 React.memo 的典型应用场景:当某个子组件渲染开销大,但其 props 并不随父组件的每次更新而变化时,使用它可以有效避免不必要的性能损耗。
然而,React.memo 有一个关键“陷阱”:对于对象和函数,它判断“相同”的标准是引用相等性(reference equality)。如果你每次渲染都传入新的对象或函数引用,React.memo 将完全失效。
function Parent() {
const [count, setCount] = React.useState(0);
// 每次渲染都会创建一个新的对象
const config = { theme: 'dark', size: 'large' };
// 每次渲染都会创建一个新的函数
const handleClick = () => console.log('clicked');
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<MemoizedChild config={config} onClick={handleClick} />
</div>
);
}
const MemoizedChild = React.memo(function Child({ config, onClick }) {
console.log('Child rendered');
return <div>Child component</div>;
});
尽管使用了 React.memo,但只要父组件重渲染,子组件依然会跟着渲染。原因在于:config 和 handleClick 每次都是全新的引用,React.memo 的浅比较会认为 props 已变。这正是 useMemo 和 useCallback 需要出场与 React.memo 搭配使用的原因。
控制重新渲染的 Hooks:useMemo 和 useCallback
这两个 Hook 与 React.memo 协同,能让你对重渲染行为实现更精细的控制。它们常被误解和误用,因此有必要明确其作用和适用场景。
useMemo:缓存计算结果
useMemo 用于在组件重新渲染之间缓存某个计算结果。只要其依赖项数组中的值未变,它就会返回上一次缓存的值,避免重复执行开销大的计算。
function DataVisualization({ rawData }) {
// 这个耗时的计算只会在 rawData 变化时执行
const processedData = React.useMemo(() => {
console.log('Processing data...');
return rawData.map(item => ({
...item,
computed: item.value * 2 + item.offset,
formatted: `$${item.value.toFixed(2)}`
}));
}, [rawData]);
return (
<div>
{processedData.map(item => (
<div key={item.id}>{item.formatted}</div>
))}
</div>
);
}
关键点:useMemo 并不会阻止组件重新渲染,它只是避免在依赖项未变时重复执行高开销的计算。当父组件重渲染导致当前组件也被渲染时,只要 rawData 引用没变,useMemo 内的计算逻辑就不会再次执行。
useMemo 另一个重要用途是保持传递给被记忆化子组件的对象引用稳定:
function Parent() {
const [count, setCount] = React.useState(0);
// 只要依赖不变(这里是空数组),config 对象的引用就不会变
const config = React.useMemo(() => ({
theme: 'dark',
size: 'large'
}), []); // 空依赖数组表示永远不会变化
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<MemoizedChild config={config} />
</div>
);
}
这里,config 对象的引用在每次重渲染时保持不变,因此配合 React.memo 包裹的 MemoizedChild,就能有效阻止不必要的子组件重渲染。
useCallback:缓存函数引用
useCallback 本质上是专门为函数设计的 useMemo。只要依赖项不变,它就返回相同的函数引用,避免因函数引用变化导致依赖它的子组件重新渲染。
function SearchComponent() {
const [query, setQuery] = React.useState('');
const [filter, setFilter] = React.useState('all');
// 只要 filter 不变,handleSearch 的引用就不会变
const handleSearch = React.useCallback((searchTerm) => {
console.log('Searching with filter:', filter);
// 使用当前 filter 执行搜索
return performSearch(searchTerm, filter);
}, [filter]); // 仅当 filter 改变时才重新创建函数
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<MemoizedSearchResults onSearch={handleSearch} />
</div>
);
}
如果不使用 useCallback,每次输入框更新(query 变化)引发重渲染时,都会创建一个全新的 handleSearch 函数。这会导致即使 MemoizedSearchResults 使用了 React.memo,也会因为 onSearch props 的引用变化而重新渲染。通过 useCallback 保持函数引用稳定,可以避免这种不必要的子组件重渲染。
核心原则:不要滥用这些 Hooks
这是我踩过坑后才深刻领悟的一点:useMemo 和 useCallback 本身是有代价的。它们会增加代码复杂度,并占用内存来存储缓存值。如果“以防万一”地到处使用,反而可能让代码更慢、更难维护。
仅在以下特定场景下,才值得使用这些优化 Hook:
- 你需要将 props 传递给使用了
React.memo 的子组件,并且需要保持引用稳定(对象或函数)。
- 你确实有开销巨大的计算逻辑,并且经过性能分析工具(如 Profiler)确认其是性能瓶颈。
- 你需要将函数作为依赖项传递给子组件的 Hook(如
useEffect),且希望子组件仅在函数逻辑变化时执行该 Hook。
对于绝大多数组件而言,React 的默认行为已足够高效。过早地、无差别地使用这些优化手段,往往只会引入不必要的复杂性,而不会带来可感知的性能提升。
一个值得坚持的健康心智模型
在思考和设计组件时,秉持以下心智模型会非常有帮助:
- 渲染(Rendering)是廉价的(发生在 JavaScript 内存中)。
- DOM 更新才是昂贵的(涉及浏览器重排重绘)。
- React 的核心目标是最小化对真实 DOM 的操作。
- 你的职责是避免在渲染过程中执行不必要的、高开销的计算。
只要你围绕这个思路来设计和优化组件,大多数性能问题都会迎刃而解。
结语
React 的重新渲染机制并非需要恐惧或对抗的敌人。恰恰是它,使得 React 能够实现声明式、可预测且具备良好扩展性的开发体验。
深入理解重渲染的原理——知道它何时发生、为何发生以及如何恰当地控制它——是你真正掌握 React,并能够自信应对前端面试中相关问题的关键一步。希望这篇文章能帮助你建立清晰的心智模型,写出更高效、更易维护的 React 代码。
技术成长离不开交流与实践,欢迎到技术社区探讨更多前端框架与性能优化的话题。