在 React 中,重渲染是指组件函数被再次执行、生成新的 React 元素树的过程。React 的协调机制会自动将变化的部分更新到真实 DOM,但频繁无意义的重渲染本身也会消耗 CPU 资源,当组件树庞大或计算密集时,可能导致界面卡顿。因此,性能优化的第一要务是阻止那些“结果不会变”的重渲染。
什么触发了重渲染?
一个组件(函数组件)会在以下三种情况下重新执行:
- 组件的 state 发生变化(通过
setState或useReducerdispatch)。 - 组件接收的 props 发生变化(引用地址变化,哪怕内容一样也算变化)。
- 父组件重渲染时,该子组件默认也会跟着重渲染(即使传给它的 props 完全没变)。
第三种情况是最常见的不必要重渲染根源:父组件更新了自己的状态,导致大批子组件无意义重渲染,而这些子组件的输出其实和上一次渲染完全一致。
优化手段一:React.memo —— 对组件结果进行缓存
React.memo 是一个高阶组件,它会浅比较前后两次 props,如果 props 没有变化,就会跳过组件的渲染过程,直接复用上一次的结果。
// 普通子组件:只要父组件重渲染它就重渲染
function Child({ name }) {
console.log('Child 渲染了');
return <div>{name}</div>;
}
// 使用 React.memo 包裹
const MemoizedChild = React.memo(function Child({ name }) {
console.log('MemoizedChild 渲染了');
return <div>{name}</div>;
});
使用场景:组件经常接收“不变的 props”但父组件频繁更新。比如一个展示列表项的组件,列表项数据一旦加载就不再变化,就可以用 React.memo 包裹。
注意:React.memo 只对 props 做浅比较。如果 props 中包含对象、数组或函数引用(由父组件每次渲染都新创建),浅比较会判定为“变化”,导致缓存失效。此时需要配合 useMemo 和 useCallback 确保 props 引用稳定。
优化手段二:缓存值与函数引用 —— useMemo 和 useCallback
父组件重渲染时,内部定义的所有普通变量和函数都会被重新创建,这会导致传给子组件的 props 引用地址每次都在变,破坏 React.memo 的缓存效果。
function Parent() {
const [count, setCount] = useState(0);
// 每次渲染都会生成新的对象和函数引用
const config = { color: 'blue' };
const handleClick = () => setCount(c => c + 1);
return (
<div>
<button onClick={handleClick}>Count: {count}</button>
{/* 即使 Child 被 memo 包裹,config 和 handleClick 引用每次都变 */}
<MemoizedChild config={config} onClick={handleClick} />
</div>
);
}
解决方案:
useMemo:缓存一个值的计算结果。只有依赖项变化时,才会重新计算并返回新引用。useCallback:缓存一个函数的引用。只有依赖项变化时,才会返回新的函数。
function Parent() {
const [count, setCount] = useState(0);
// config 引用只有 count 变化时才更新(此处依赖为 [],所以从不变化)
const config = useMemo(() => ({ color: 'blue' }), []);
// handleClick 引用永远不变(除非添加依赖项)
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<div>
<button onClick={handleClick}>Count: {count}</button>
<MemoizedChild config={config} onClick={handleClick} />
</div>
);
}
使用原则:
- 不要无脑包裹:
useMemo和useCallback本身也有性能开销(存储依赖、比较),在轻量级的纯 UI 组件上可能得不偿失。只在传递给子组件且子组件使用了React.memo,或者计算成本极高时才使用。 - 确保依赖项正确:ESLint 的
react-hooks/exhaustive-deps规则能帮助你避免遗漏依赖。
优化手段三:状态下放与内容提升
很多不必要的重渲染可以通过调整状态的位置来避免。核心思想是:状态应该离使用它的组件尽可能近,而不是都堆放在顶层。
状态下放(State Down)
如果一个状态只被某一个子组件及其后代使用,就不要放在父组件,而是下沉到那个子组件内部管理。
问题代码:
function App() {
const [inputValue, setInputValue] = useState('');
const [items, setItems] = useState([...]);
return (
<div>
<SearchInput value={inputValue} onChange={setInputValue} />
<ExpensiveList items={items} /> {/* 每次输入都会重渲染! */}
</div>
);
}
inputValue 变化导致 App 重渲染,进而导致 ExpensiveList 也不必要地重渲染。
优化后:
function App() {
const [items, setItems] = useState([...]);
return (
<div>
<SearchInput /> {/* 内部自行管理输入状态 */}
<ExpensiveList items={items} />
</div>
);
}
function SearchInput() {
const [value, setValue] = useState('');
// ...
}
ExpensiveList 不再受输入状态的影响,完美避开了重渲染。
内容提升(Lifting Content Up / Children as Prop)
当组件内部有一部分内容不会随状态变化时,可以把那部分内容作为 children(或其他 prop)从外部传入。这样即使该组件频繁重渲染(因为内部状态变化),那些不变的 children 也不会受到影响。
典型场景: 一个带有动画效果或定时器更新的容器,但内部的内容是静态的。
function FrequentUpdater({ children }) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return (
<div>
<p>每秒更新: {count}</p>
{children} {/* 这些 children 在 FrequentUpdater 外层创建,不会因 state 变化而重新创建 */}
</div>
);
}
function App() {
return (
<FrequentUpdater>
<VeryExpensiveComponent /> {/* 永远不会因为 FrequentUpdater 内部状态变化而重渲染 */}
</FrequentUpdater>
);
}
VeryExpensiveComponent 作为 children 传入,它的创建发生在 App 渲染阶段,而不是 FrequentUpdater 内部。因此 FrequentUpdater 每秒更新 count 时,children 的引用没有变化,VeryExpensiveComponent 得以跳过重渲染。
总结与优先级
避免不必要重渲染的三板斧,建议按以下顺序考虑:
- 调整状态位置(状态下放、内容提升)——零成本,效果最好。
React.memo包裹“纯净的展示组件”。useMemo/useCallback稳定传递给子组件的引用类型 props。
切记:不要过早优化。先用 React DevTools Profiler 确认哪里存在性能瓶颈,再针对性应用这些手段。大部分中小型应用中,React 的默认行为已经足够快。