最近写 React 遇到一个性能问题,调试了一段时间都没什么思路。后来以尝试的心态玩了玩 React Developer Tools (opens new window) 的 React Profiler,得到了一点线索,最终找到了问题是自己写屎了


编写的页面结构是这样的:页面上有一个表单 Form 和一个地图 Map 组件,地图组件里除了用到百度地图 React-BMapGL (opens new window),还有一个组件负责在地图上渲染 70 万个点,还写了一个工具箱组件用于控制点的颜色等(其实工具箱也是一个表单)。

1111

70 万个点对于 Web 前端可不是一个小数目。React-BMapGL 提供的在地图上渲染点的 API,其实现是在地图上直接添加 DOM 元素,实测一秒只能渲染 2 万左右个点(相当于一秒钟添加 2 万个 DOM 元素,DOM 的性能本身也很烂)。要想在 Web 前端渲染 70 万个点,肯定不能基于 DOM 来做。常见的思路是基于 WebGL。百度地图 JS API 没有直接提供在地图上渲染海量点的 API,但提供了 MapVGL (opens new window) 库来间接实现渲染海量点。测试了一下,可以使用 WebGL 在地图上渲染 2000 万个点(再多就超过 4G 内存、浏览器 OOM 了)。

回到正题,70 万个点数量很大,在加载、处理、渲染的时候都需要注意性能和内存使用问题,如果优化不到位,很容易出现掉帧、卡顿等性能问题。

本次的主角是一个 Switch 开关组件,它放在 Toolbox 里,负责修改 PointCollection 的颜色等配置,所以需要在 Map 组件里创建了一个 state 名为 pointCollectionConfigState,将这个 state 传给 ToolboxPointCollectionToolbox 负责修改 state(也会读取 state 用于展示 Switch 状态),PointCollection 负责读取 state。这样写下来没有什么问题,切换 Switch 状态没有出现卡顿的情况。

picture 2

后来业务上需要把这个 Switch 放到 Form 里,于是把这个 state 提升到 Page 里,这样就可以传给 FormMapMap 再传给 PointCollection如此操作以后,发现切换 Switch 状态时会出现明显的卡顿

picture 3

Page 里面有一个从后端获取数据的 hook,但是数据加载完成后把结果做了一层简单的缓存。Switch 状态变化后,这个 hook 不会再次从后端获取数据,所以这个 hook 不会消耗太多时间。除此之外 Page 没有其它逻辑,所以 Page 这一层不像会有瓶颈。

上面有提到,70 万个点的场景下,优化不到位就很容易可能出现卡顿问题,所以我花了一些精力优化 MapPointCollection 的实现。尝试了一些方案,诸如:

  1. Toolbox 从百度地图 DOM 的 children 移动到和百度地图同级
  2. 每次 PointCollection 的 Props 变化时,从销毁、重建 MapVGL 图层刷新改为修改图层选项
  3. PointCollection 实现了 hide 的 Props,当上层需要隐藏 PointCollection 时,不需要销毁 PointCollection,只需要设置 hide=truePointCollection 调用 hideLayer 隐藏图层。

这些方案都能减少重新渲染图层的次数,在不需要重新渲染的场景下,不再做无意义的重复渲染数十万个点。

然而 Switch 切换卡顿的问题仍然存在。我甚至差点考虑给组件加 React.memo() (opens new window) 来跳过一些重复渲染。

后来我打开了 React Profiler,尝试从 profile 中找到一些线索。Profiler 的使用很简单,打开 Chrome Devtools,切换到 React Profiler 的 tab,点击开始录制,然后复现一遍卡顿的情形(这里就是切换一下 Switch 状态),点击结束录制。这样就能看到录制的这段时间里,React 框架在哪些操作上耗时较多。

picture 5

从录制结果里可以看到,这段时间里 PointCollection 的 passive effect 占了大头,大约 700ms。我也做了一下对照实验,使用之前 Switch 放在 Toolbox 的代码进行 profile,测试下来同样是 PointCollection 的 passive effect 占大头,但只有 150ms。虽然并不清楚具体是哪个 passive effect 耗时,但是至少有一点方向了:耗时的代码不在 Page 或者 Map,而是在 PointCollection

PointCollection 的代码是一堆 useEffect,只有当 Props 变化时才会触发 useEffect 函数执行,而这些函数都很耗时。不过,在代码修改中,我只是挪动了一下 state 的位置,为什么会影响到 useEffect 的执行时间?

所以这次,我给 PointCollection 例的每个 useEffect 都加了一个 console.log(),来观察切换 Switch 后哪些属性发生变化了(或许有直接通过 React Devtools 查看的方法,不过我习惯 console.log 了),结果发现切换 Switch 以后,points 变化了。要知道这个 Switch 不会影响到 points 数据,这些 points 数据完全来自于 Page 中负责加载数据的 hook。难道说?

发现这一点后,我从 PointCollection 开始往上检查了 points 的数据流,发现 MapPage 都是直接传递数据,中间并没有修改过,而 Page 中负责加载数据的那个 hook,创建 points 数组时并没有加 useMemo也就是说,每次 Page 重新渲染时调用这个 hook,而 hook 返回的 points 都是一个重新创建的、全新的数组,导致 PointCollection 认为 points 每次都发生了变化、重新渲染一遍所有点。在之前的代码版本里,由于 state 是在 Map 里定义的,修改 state 只会触发 Map 重渲染,Page 不会重渲染,从而不会触发这个 bug。

  export function useLoadData(...) {
    const result = useMemo(() => ..., [...]);
-   const points = result.filter(...);
+   const points = useMemo(() => result.filter(...), [result]);
    return {
      points,
      ...
    };
  }

所以真的就是代码写屎了。解决方法也很简单,给 hook 里的 points(和 points 的依赖)套一层 useMemo,保证以相同参数重复调用 hook 时不会创建新的 points 数组即可。


在这段调试过程中,说实话并没有很深入地使用到 React Profiler。但这个例子中可以体会到 React Profiler 有什么能力,在什么场景下很有帮助。具体来说,出现性能问题时,React Profiler 能够帮助定位到是哪个组件的 passive effect(以及渲染哪个组件时)消耗了大量时间,帮助开发者将调试范围从整个页面、整个组件树缩小到具体的某一个组件