⚡️单帧渲染耗时减少392ms——React 性能优化实践

3,198 阅读7分钟

你好,我是本文作者南一。如果有发现错误或者可完善的地方,恳请斧正,万分感谢!!

前言

使用 React 开发复杂的需求,时常会碰到性能问题。近期做业务过程就遇到一次,页面十分卡顿。此文将笔者排查问题的过程以及性能优化解决方案记录下来跟大家分享。

一般情况下,我们会用单帧渲染耗时来判断页面是否卡顿。常见的显示器帧率是60Hz,浏览器为了给用户流畅的视觉体验,通常会与显示器帧率保持一致,每秒60帧,单帧渲染耗时约为16.6ms。如果单帧的渲染耗时超过16.6ms,我们就说是卡顿的。

问题背景

本次优化对象是如下表格。采用 antd table 组件实现,出现了选中一行发生卡顿的现象,点击左侧选择框选中一行,平均需要等待1点几秒页面才有响应。

Snipaste_2024-09-24_11-34-10.png

使用 performance 工具录制选中动作前后的性能数据,更直观的反映耗时,下方火焰图展示了2个长任务总耗时1048ms

image.png

image.png

排查思路

切入点

根据对React的了解及过往开发经验,引发React性能问题,可能有如下几个原因:

  1. state、props、context 变化,导致组件进行无意义的更新。这里更新不一定是真正更新DOM节点,而是react重新对组件进行render的过程。

React 渲染组件的原理,调用组件render方法生成新的JSXElement与旧的Fiber节点进行比较,也就是diff的过程,可能是复用旧Fiber节点,也可能创建新节点。当前节点处理完,会继续深度遍历子节点,继续比较的过程。所以当组件层级比较深的时候,函数调用和diff的过程会是一笔不小的开销,即使每次都是复用。

  1. 页面数据量大,DOM节点多,造成 React 调和工作耗时太长。

React reconciliation 调和:指 React 从根节点向下深度遍历构建新Fiber树的过程。

先从上面两个方向入手,逐一排查。

props 变化造成不必要更新

image.png

React 开发者工具勾选上 Highlight updates when components render 可以实时看到哪些组件发生更新。选中一行时出现大片的绿色方框,表示每次选中会有大量组件发生更新。

React Developer Tools 在给定的时间点高亮正在重新渲染的组件。根据更新的频率,使用不同的颜色。蓝色显示罕见更新,经过绿色、黄色的过渡,一直到红色用来显示更新频繁的组件。

推测可能是因为 props 改变导致表格更新。antd 大部分组件都会使用memo包裹,避免父组件更新导致子组件发生不必要的更新。但是当子组件入参为函数、对象、数组时,每次父组件重新渲染,都会生成新的参数,即使子组件使用memo包裹,仍然会发生更新。

React 默认使用 Object.is 函数浅比较传入组件的每一个 props。不一致才会更新。

解决办法就是使用 useMemo,useCallback 对传入组件的 props 进行缓存。

image.png

再次使用 performance 记录性能数据,发现长任务时间缩短到531.71ms。可见,优化措施发挥作用了。

image.png

但是体感还是卡顿,继续分析原因。

页面数据量大、DOM节点多

通常的表格卡顿,比较容易想到的原因是大数据量导致的表格行列过多,然后就可以采用虚拟列表解决。本次的表格数据量级是 18列 * 50行 = 900格。

我们尝试通过开启antd-table提供的 virtual 属性给表格开启虚拟列表,结果是选中行卡顿的情况的确得以缓解,但是滚动过程异常卡顿。

分析原因:虚拟列表是通过滚动时动态渲染可见区来减少页面上的DOM节点,避免大数据量导致页面上节点过多造成卡顿。我们的表格开启虚拟列表之后反而出现滚动卡顿,证明动态渲染一行数据的过程是计算量比较大的,才会造成卡顿。也就是说,我们的表格不是因为太多行导致卡顿,而是一行中计算量太大导致卡顿。

组件层级太深导致调和时间长

至此我们原先想到的两个思路已经验证结束,但是性能问题还没完全解决。难道只能就此放弃了吗?不,真男人不言放弃,继续分析原因!!!

分析原因

上面使用 React 开发者工具观察表格更新范围,他还有另一个功能 Profiler 可以分析一帧中每个组件的耗时,可以看到在我们勾选的这一帧中共花费456.6ms

image.png

火焰图的深度代表组件层级,可以看到有些组件层级是非常深的,将鼠标移动到柱子上,发现这些很长的柱子都有一个共同的组件CopyText

image.png

CopyText组件实现的功能是:长文字截断省略,hover显示tooltip,hover显示复制按钮。(下图红框的内容就是CopyText组件)

Snipaste_2024-09-24_11-34-10.png

页面上高频使用了CopyText的组件,基本每个格子会用一到两次,至少用了18列 * 50行 = 900次。该组件底层是antd 的 Typography.Paragraph组件,组件层级非常深,可达30层(这里指Fiber树层级)。

image.png

react每次diff都是按层级比较新旧两棵Fiber树,虽然state、props都没有变化,不需要更新组件,但仍需要每一层比较一次,单单该组件就比较了 900 * 30 = 27000次。(原理解释在切入点第1点)

解决方案

方案1: 重新实现一个CopyText组件,不使用 antd 的Typography.Paragraph组件。有尝试实现,发现要解决文字太长省略并hover显示tooltip这个很难,难点在于如何获取文字的长度。

方案2: CopyText组件的复制和tooltip效果都是hover才出现,那在没有hover的时候用简单组件替代。(只渲染用户需要看见的节点,看不到的暂时不渲染)

最终实现如下:

image.png

优化效果

CopyText 组件层级锐减到1层

image.png

长任务缩短到108ms

image.png

单帧渲染耗时64.6ms

image.png

总结

本次实践,通过开发者工具分析原因,采用非可见不渲染的策略减少组件层级,有效缓解了页面卡顿的问题。但是,单帧渲染耗时64.6ms,这个效果还没达到 16.6ms 的流畅级别。只能是说勉强够用。

问题拓展

antd表格选中一行所有单元格更新

优化之后我们再观察一下组件更新,并没有发生好转,选中一行时仍然出现大片的绿色方框,证明所有的单元格仍然发生更新。测试一下 antd 官方的table demo,也是同样的表现,证明选中一行所有单元格更新是由antd-table内部实现决定的。

CPT2409241720-2360x732.gif

简单的功能尽量不使用Antd组件性能更好

测试发现只要把 antd 的组件替换为基础标签组件(比如Flex组件,换成div+ css 布局),就能够避免更新。观察火焰图发现,antd的组件都会包裹在Context.Provider组件内,推测触发一些操作时,antd会通知到所有组件进行更新。这也给我们一些启发,实际开发过程,一些简单的功能尽量不使用Antd组件,改用基础的标签组件 + css实现,性能会更好一点。