Logo
Section 1 / 5

性能优化——避免首次渲染时的计算

介绍如何维护 React 应用程序,以及如何确保 React 应用的性能优化。

导入

性能优化——避免首次渲染时的计算

介绍如何维护 React 应用程序,以及如何确保 React 应用的性能优化。

当 React 应用逐渐增长时,维护就成为首要任务。为了应对这一情况,我们将探讨性能优化、类型安全、测试以及项目结构。这些方面都能增强应用的健壮性,使其在增加更多功能的同时不降低质量。

性能优化通过确保高效利用系统资源,防止应用变慢。TypeScript 等类型化编程语言可以在开发反馈循环的早期发现潜在错误。测试则比类型检查提供更明确的反馈,并帮助我们识别哪些操作可能导致应用崩溃。最后,合理的项目结构有助于将资源以文件夹和文件的形式有序管理,这在团队成员负责不同模块时尤为有用。

React 中的性能

本节旨在帮助你理解 React 中的性能优化方法。实际上,在大多数 React 应用中并不需要特别优化,因为 React 本身开箱即用就很高效。尽管存在更高级的 JavaScript 和 React 性能分析工具,但这里我们仅使用简单的 console.log() 和浏览器开发者工具来观察日志输出。

避免在首次渲染时执行

之前我们学习了 React 的 useEffect Hook,它用于处理副作用。该 Hook 在组件首次渲染(挂载)时执行一次,之后每次重新渲染(更新)时也会执行。如果传入一个空的依赖数组作为第二个参数,就可以让这个 Hook 只在首次渲染时运行。然而,默认情况下并没有办法让 useEffect 仅在每次重新渲染(更新)时运行,而不在首次渲染(挂载)时运行。

例如,下面是一个使用 useState 管理状态,并结合 useEffect 利用本地存储实现半持久化状态的自定义 Hook:

1const useSemiPersistentState = (key, initialState) => { 2 const [value, setValue] = React.useState( 3 localStorage.getItem(key) || initialState 4 ); 5 6 React.useEffect(() => { 7 console.log('A'); 8 9 localStorage.setItem(key, value); 10 }, [value, key]); 11 12 return [value, setValue]; 13};

通过查看开发者工具中的日志,我们可以看到组件首次使用该自定义 Hook 渲染时的日志输出。此时执行副作用是没有意义的,因为除了初始值外,并没有新的数据需要存入本地存储。这是一次冗余调用,理想情况下应仅在组件后续更新(重新渲染)时才执行。

如前所述,React 并没有专门在每次重新渲染时触发的 Hook,也无法以符合 React 编程习惯的方式让 useEffect 仅在更新阶段执行。但我们可以通过 useRef Hook 来解决这个问题:useRef 的 ref.current 属性在整个组件生命周期中保持不变,不会因状态变化引发重新渲染。我们可以利用它来模拟一个“自定义状态”,用于追踪组件是否已完成首次渲染:

1const useSemiPersistentState = (key, initialState) => { 2 3 const isMounted = React.useRef(false); 4 5 const [value, setValue] = React.useState( 6 localStorage.getItem(key) || initialState 7 ); 8 9 React.useEffect(() => { 10 11 if (!isMounted.current) { 12 isMounted.current = true; 13 } else { 14 15 console.log('A'); 16 localStorage.setItem(key, value); 17 18 } 19 20 }, [value, key]); 21 22 return [value, setValue]; 23};

我们利用 ref 及其可变的 current 属性进行命令式的状态管理,这种状态不会触发组件重新渲染。当该 Hook 第一次被组件调用时(即首次渲染),isMounted 被初始化为 false。此时副作用函数不会真正执行存储逻辑,只会将 isMounted.current 设置为 true。当下一次组件重新渲染并再次调用此 Hook 时,由于 isMounted.current 已为 true,副作用函数才会真正执行。

在整个组件生命周期中,isMounted 将一直保持为 true,它的作用就是避免在首次渲染时执行不必要的副作用逻辑。

上述做法看似只是省略了一次简单的函数调用,但如果副作用中包含昂贵的计算,或者该自定义 Hook 在应用中被频繁使用,这种优化就显得尤为重要。

📜 注意:这项技术不仅用于性能优化,也适用于那些只希望在组件重新渲染时才触发副作用的场景。我在多个项目中都使用过这种方法,相信你在实际开发中也会遇到类似需求。

无需更新时避免重新渲染

此前我们讨论过 React 的重新渲染机制。现在我们对 App 和 List 组件进行进一步观察。为这两个组件分别添加日志语句:

1const App = () => { 2 ... 3 4 console.log('B:App'); 5 return ( ... ); 6}; 7 8const List = ({ list, onRemoveItem }) => 9 10 console.log('B:List') || 11 12 list.map(item => ( 13 <Item 14 key={item.objectID} 15 item={item} 16 onRemoveItem={onRemoveItem} 17 /> 18 ));

由于 List 组件没有显式的函数体,而开发者通常倾向于避免为一句日志去重构代码,因此这里使用了 || 运算符技巧。这是一种在无函数体的函数组件中插入日志的巧妙方式:console.log() 表达式始终返回 undefined(视为 false),所以 || 右侧的表达式总会被执行。

1function getTheTruth() { 2 if (console.log('B:List')) { 3 return true; 4 } else { 5 return false; 6 } 7} 8 9console.log(getTheTruth()); 10// B:List 11// false

关注浏览器开发者工具中的实际日志输出,你应该会看到类似如下内容。首先是 App 组件渲染,接着是其子组件(如 List):

1B:App 2B:List 3B:App 4B:App 5B:List

因为在首次渲染后副作用触发了数据请求,此时 List 组件被条件渲染替换为加载提示,所以只有 App 组件渲染。当数据到达后,两个组件再次渲染。

下图展示了上述过程:

目前这种行为是可以接受的,所有渲染都按预期发生。接下来我们进一步实验:在 SearchForm 组件的输入框中输入文字。每输入一个字符,你都会看到以下日志重复出现:

1B:App 2B:List

但注意,List 组件其实不应重新渲染——搜索功能尚未通过按钮触发,传给 List 的 list 数据并未改变。这是 React 的默认行为,却常常令开发者感到意外。

只要父组件重新渲染,其所有子组件也会随之重新渲染。React 默认如此,因为若自动跳过子组件渲染,可能引入难以察觉的 bug;而且 React 的渲染机制本身足够快,多数情况下影响不大。

但在某些场景下,我们确实希望避免不必要的重新渲染。例如,展示大量数据的表格组件,若其数据未受影响,则不应重新渲染。此时更高效的做法是对组件的 props 进行相等性检查。为此,我们可以使用 React 的 memo API 来实现这一检查:

1const List = React.memo( 2 ({ list, onRemoveItem }) => 3 console.log('B:List') || 4 list.map(item => ( 5 <Item 6 key={item.objectID} 7 item={item} 8 onRemoveItem={onRemoveItem} 9 /> 10 )) 11);

然而,当你在 SearchForm 输入框中继续输入时,输出仍然相同:

1B:App 2B:List

这说明仅仅使用 React.memo 并不能完全阻止重新渲染。原因在于,父组件传递给 List 的 onRemoveItem 是一个内联函数或每次渲染都重新创建的函数,导致 memo 的浅比较失败。要真正实现优化,还需配合 useCallback 对回调函数进行缓存。

练习:

  • 了解更多关于 React 的 memo API 的内容。
  • 了解更多关于 React 的 useCallback Hook 的内容。
  • 下载 React Developer Tools 作为浏览器扩展。在浏览器开发者工具中打开它,探索其各项功能,例如可视化 React 组件树以及监控组件的更新情况。

React 中的样式

性能 - 避免繁重的计算