《code秘密花园》送书活动第 24 期,文末查看抽奖方式(送 5 本)
大家好,我是 ssh。
太长不看版:
在构建大型 React 应用程序时,性能问题常常困扰开发者,主要原因是重复渲染。React Scan
是一个自动检测并高亮显示导致性能问题的渲染工具,帮助开发者精准定位需要修复的组件。文章介绍了 React 重复渲染的几种情况,包括引用类型导致的重新渲染、组件不必要的更新和组件内部状态的频繁变动,并提供了 React Scan 的安装和使用方法。还介绍了一些常见的优化性能的方法,如使用 React.memo
、useCallback
和 useMemo
、以及合理使用 shouldComponentUpdate
和 PureComponent
。
学习要点
重复渲染问题:了解 React 重复渲染的几种情况及其对性能的影响。 React Scan 工具:掌握 React Scan 的安装和使用方法,包括通过 script 标签和 npm 安装。 API 使用:熟悉 React Scan 的主要 API,如 scan
、withScan
、getReport
和setOptions
。性能优化技巧:学习使用 React.memo
、useCallback
、useMemo
以及shouldComponentUpdate
和PureComponent
来优化 React 应用的性能。实际应用:通过示例代码了解如何在实际项目中应用 React Scan 进行性能检测和优化。
以后的文章我都会加入这个部分,基于 AI 总结,方便没空了解细节的同学快速阅读。
正文如下:
在使用 React 构建大型应用程序时,性能问题通常是困扰开发者的一个重要方面。重复渲染是性能瓶颈的主要原因之一,特别是在 React Compiler
还没出现之前, React Compiler
在一定程度上就是希望解决这种问题,但是还没有得到广泛应用。所以目前对于大部分 React
项目,在没有得到良好的性能优化重构的前提下,都会有各种性能问题,因此 React Scan
应运而生了。
React Scan
是一个能够自动检测并高亮显示导致性能问题的渲染的工具。这意味着开发者可以精准地知道哪些组件需要修复。
React 重复渲染
在开始介绍之前,我们先简单聊聊 React 重复渲染引发的性能问题。
React
通过对组件的状态(state
)和属性(props
)进行监控,决定组件何时需要重新渲染。当组件的状态或属性发生变化时,React 会重新渲染该组件以及与其相关的子组件。尽管这种机制确保了页面内容的实时更新,但也可能带来不必要的重复渲染,进而造成性能问题。
1. 引用类型导致的重新渲染
在 React 中,props
和 state
的变化会触发重新渲染,但对于引用类型(如对象和数组)的比较,React 使用的是浅比较方式。这意味着即便对象的内容没有变化,只要对象的引用发生了改变,React 仍会触发重新渲染。
示例:
// 示例导致 `ExpensiveComponent` 频繁重新渲染
<ExpensiveComponent onClick={() => alert('hi')} style={{ color: 'purple' }} />
上述代码中,每次渲染时 onClick
和 style
对象都会重新创建,导致 ExpensiveComponent
被迫重新渲染,尽管其内容可能没有变化。
2. 组件不必要的更新
有时候,父组件的状态或属性变化会导致子组件的重新渲染。如果子组件不依赖于这些变化,但依旧被重新渲染,这些更新就是不必要的。
示例:
function ParentComponent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increase Count</button>
<ChildComponent />
</div>
);
}
function ChildComponent() {
// 如果 `ChildComponent` 不依赖 `count`,它每次仍然会被重新渲染
return <div>Child Component</div>;
}
上述例子中,每次 count
发生变化时,ChildComponent
也会不必要地重新渲染。
3. 组件内部状态的频繁变动
当组件有内部管理的状态且状态频繁变动时,它会导致组件自身的频繁重新渲染。如果状态变化得过于频繁,可能会显著影响性能。
示例:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>Count: {count}</div>;
}
上述代码中,每秒钟 count
的变化都会触发组件的重新渲染。
安装
你可以通过两种方式快速安装 React Scan
:
通过 Script 标签
在你的应用中引入以下 script 标签(确保在引入任何其他脚本之前引用):
<!doctype html>
<html lang="en">
<head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>
<!-- rest of your scripts go under -->
</head>
<body>
<!-- ... -->
</body>
</html>
Next.js(页面)引入示例:将脚本标签添加到 pages/_document.tsx
中:
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>
{/* 其余的脚本都在这里下面添加 */}
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
通过 npm 安装
如果你习惯通过 npm 管理依赖,可以执行以下命令安装 React Scan
:
npm install react-scan
然后在应用中,在导入 React
之前,引入以下代码:
import { scan } from 'react-scan'; // 在 react 之前引入
import React from 'react';
scan({
enabled: true,
log: true, // 将渲染信息记录到控制台(默认: false)
});
API 参考
scan(options)
自动扫描应用中的渲染。
scan({
enabled: true, // 启用/禁用扫描
includeChildren: true, // 包括应用了 withScan 的组件的子组件
playSound: true, // 启用/禁用戈伊格计数器音效
log: false, // 将渲染记录到控制台
showToolbar: true, // 显示工具栏
renderCountThreshold: 0, // 渲染次数阈值,仅显示渲染多于此值的组件
report: false, // 向 getReport() 报告数据
onCommitStart: () => {}, // 提交开始时的回调
onRender: (fiber, render) => {}, // 渲染时的回调
onCommitFinish: () => {}, // 提交完成时的回调
onPaintStart: (outline) => {}, // 绘制开始时的回调
onPaintFinish: (outline) => {}, // 绘制完成时的回调
});
withScan(Component, options)
扫描特定组件的渲染。
function Component(props) {
// ...
}
withScan(Component, {
enabled: true, // 启用/禁用扫描
includeChildren: true, // 包括应用了 withScan 的组件的子组件
playSound: true, // 启用/禁用戈伊格计数器音效
log: false, // 将渲染记录到控制台
showToolbar: true, // 显示工具栏
renderCountThreshold: 0, // 渲染次数阈值,仅显示渲染多于此值的组件
report: false, // 向 getReport() 报告数据
onCommitStart: () => {}, // 提交开始时的回调
onRender: (fiber, render) => {}, // 渲染时的回调
onCommitFinish: () => {}, // 提交完成时的回调
onPaintStart: (outline) => {}, // 绘制开始时的回调
onPaintFinish: (outline) => {}, // 绘制完成时的回调
});
getReport()
获取所有组件和渲染的汇总报告。
scan({ report: true });
const report = getReport();
for (const component in report) {
const { count, time } = report[component];
console.log(`${component} rendered ${count} times, took ${time}ms`);
}
setOptions(options)
设置扫描选项。
function Component(props) {
// ...
}
setOptions({
enabled: true, // 启用/禁用扫描
includeChildren: true, // 包括应用了 withScan 的组件的子组件
playSound: true, // 启用/禁用戈伊格计数器音效
log: false, // 将渲染记录到控制台
showToolbar: true, // 显示工具栏
renderCountThreshold: 0, // 渲染次数阈值,仅显示渲染多于此值的组件
report: false, // 向 getReport() 报告数据
onCommitStart: () => {}, // 提交开始时的回调
onRender: (fiber, render) => {}, // 渲染时的回调
onCommitFinish: () => {}, // 提交完成时的回调
onPaintStart: (outline) => {}, // 绘制开始时的回调
onPaintFinish: (outline) => {}, // 绘制完成时的回调
});
getOptions()
获取当前扫描选项。
const options = getOptions();
console.log(options);
测试一下
下面我们来实现一个 Demo 试一下:
import React, { useState } from 'react';
import type { FC } from 'react';
import { scan, getReport } from 'react-scan';
import ExpensiveComponent from './components/ExpensiveComponent';
import Counter from './components/Counter';
import ParentComponent from './components/ParentComponent';
// 初始化 React Scan
scan({
enabled: true,
log: true,
playSound: true,
showToolbar: true,
report: true,
});
setInterval(() => {
const report = getReport();
for (const component in report) {
const { count, time } = report[component];
console.log(`${component} rendered ${count} times, took ${time}ms`);
}
}, 1000);
const App: FC = () => {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<div className="App" style={{ padding: '20px' }}>
<h1>React Scan Demo</h1>
<button
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
style={{ marginBottom: '20px' }}
>
切换主题
</button>
<div style={{
display: 'grid',
gap: '20px',
backgroundColor: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#000' : '#fff',
padding: '20px'
}}>
<ExpensiveComponent
onClick={() => alert('clicked')}
style={{ color: 'purple' }}
/>
<Counter />
<ParentComponent />
</div>
</div>
);
};
export default App;
当我们点击切换主题时,可以精准查看子组件重新渲染的情况和耗时,另外我们也可以打印完整的性能报告:
常见问题解答
为什么选择这个而不是 React Devtools:虽然
React Devtools
旨在作为通用工具,但它没有区别必要和不必要渲染的明显区分,并且缺乏编程 API。如果你每天都在处理 React 性能问题,那么React Scan
可能更适合你。除此之外,React Devtools
存在一些实际使用中的问题,例如:渲染速度过快的组件滞后、滚动或调整框大小时位置不更新、没有渲染次数统计等。React Native 支持吗?很快就会支持!
Chrome 插件支持吗?也即将推出!
解决 React 性能问题
下面是一些常见的解决 React 性能问题的思路:
1. 使用 React.memo
React.memo
是一个高阶组件,用于缓存函数组件的渲染结果,从而避免不必要的更新。如果传递给组件的 props
没有变化,React.memo
会使用上一次渲染的结果而不是重新渲染组件。
示例:
const ChildComponent = React.memo(function ChildComponent() {
return <div>Child Component</div>;
});
2. 使用 useCallback 和 useMemo
useCallback
和 useMemo
可以帮助缓存创建的函数和计算结果,从而避免因为函数引用变化导致的重复渲染。
示例:
function ParentComponent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
alert('hi');
}, []);
const style = useMemo(() => ({ color: 'purple' }), []);
return <ExpensiveComponent onClick={handleClick} style={style} />;
}
上述代码使用 useCallback
缓存 handleClick
函数,使用 useMemo
缓存 style
对象,避免它们每次重新创建导致的重复渲染。
3. 合理使用 shouldComponentUpdate 和 PureComponent
对于类组件,可以使用 shouldComponentUpdate
来控制组件的更新逻辑。此外,React.PureComponent
会自动实现浅比较,避免不必要的重新渲染。
class ChildComponent extends React.PureComponent {
render() {
return <div>Child Component</div>;
}
}