黑帕云前端性能揭秘:React性能调优(下篇)

黑帕云前端性能调优的故事。

黑帕云前端性能揭秘:React性能调优(下篇)

导读

在上一篇文章《React性能调优(上篇)》中,我们已经介绍了如何通过性能优化,让业务表在切换时实现“如丝般顺滑”。在本篇中,我们将进一步介绍:

0. 使用 react profiler 定位性能问题组件

1. 快速判定 react 组件中 hooks 的变化情况

2. react + redux + reselect/re-reselect 的运行原理

3. Redux Store 的设计原则

性能问题又又又出现了

按下了葫芦浮起了瓢

在上次性能优化完成之后没几天,又出现了一些新问题:

1. 在某个应用中从表格 A 切换到表格 B

2. 修改表格 B 中的一条数据

3. 修改完成后切换回表格 A,页面有少许卡顿

也就是说,如果不修改 B 表中的数据,那么切换到 A 表的流畅度可接受的,但如果修改了 B 表中的数据,那么切换到 A 表就有点卡了。这就很奇怪了,我修改的是 B 表的数据,和 A 表没什么关系,为什么切换回 A 表就会卡顿呢?(你们抓捕周树人,和我鲁迅有什么关系?)

React Profiler

React应用慢,无外乎两种原因,要么组件刷新慢,要么组件重复刷新

我先按照上一篇的方法,尝试使用浏览器的 performance 工具找出性能热点,可惜经过一番排查,没有发现什么特别明显的问题。我开始怀疑是 React 组件渲染过程中出现了什么“幺蛾子“,于是尝试在浏览器中使用 React Profiler 来定位问题(需要提前在浏览器安装React Developer Tools)。

React Profile 使用方法和浏览器 performance 工具类似,先启动 profile 的监控,然后在页面上做相应的操作,最后结束监控查看 profile 报告。

图1. React Profile的报告

在图 1 的报告中,主要有四部分值得关注(对应在图1中的1、2、3、4)

1.在报告的右上角显示了在 profile 监控过程中 react 应用总共 commits 的次数一次 commit 可以认为是一次页面变化,每一根纵向的柱子就代表一次变化,柱子的高度表示渲染时间,绿色的柱子代表渲染时间还 ok,黄色的柱子代表渲染时间过长。我们可以通过鼠标在不同的柱子切换,重点关注某一次 react 应用的渲染情况。

2.当选中某一次 commit 时,报告左侧的火焰图 (Flamegraph) 显示了在本次 commit 中应用里所有 react 组件的渲染状态。应用中所有 react 组件成树状结构展现,我们也可以选择某一个节点重点查看,当选中某一个 react 组件后,火焰图会突出从”根组件“到”该组件的叶子组件“的渲染情况。火焰图中的的颜色同样代表着组件渲染的快慢。比如在图1中,第一个黄色的柱子 ClientSideDataGridWrapper (2.2 ms of 5.2ms),表示渲染自己花了 2.2 ms,渲染他的子组件花了 3ms (5.2ms - 2.2ms = 3ms)

3.当在火焰图中选则某组件后,我们还可以在右边栏看到该组件在本次监控中的所有渲染次数。右边栏上半部分列出了组件渲染的原因,下半部分列出了组件在监控过程中渲染的次数。比如图 1 中,可以看到组件 ClientiSideDataGridWrapper 第一次渲染的原因是 hooks 和 props 中的 table/view 发生了改变,而在整个监控过程中,该组件一共渲染了 3 次,分别发生于监控开始之后的 1.2s, 1.2s 和 1.8s。而”1.2s for 21.1ms“后面的 21.1ms,代表着这一次commit整个应用渲染所花费的时间(可以看到在火焰图中根组件“根组件”渲染时间正好是21ms)

4.组件渲染时间队列图 (Ranked) 则列出了某次 commit 中渲染较慢的 react 组件,方便找出性能热点。从下图中可以看到,前两个组件就用了 4.3ms,不要觉得 4.3ms 不慢,如果按照 16ms 的标准衡量,这两个组件就占了 25%,值得关注。

图2. 组件渲染时间队列图

使用React Profiler,我一共做了三次同样流程的性能测试,结合三次 Profile 报告,我发现了一个有趣的问题。在整个监控过程中表格组件 ClientSideDataGridWrapper 会发生 3 次渲染

  1. 第一次渲染是修改 B 表数据后发生的,没有问题可以排除。
  2. 第二次渲染发生在切换回 A 表时发生的,渲染时间较慢,不正常,需要后续观察。
  3. 第三次渲染发生在切换回 A 表之后,这就离谱了。按理来说切换 A 表应该只渲染一次表格组件,怎么还重复渲染了?

Profile报告中可以列出组件的渲染原因,可惜在 Profile 报告中对第三次表格组件渲染原因只写了”Hooks changed“,估计是因为目前 React Developer Tools 对 Hooks 的支持还不够好,所以无法显示表格组件中到底是哪些 hooks 变化导致重复渲染。看来要想想办法了。

最终经过一番探索,我用了下面的函数来帮助我定位hooks的变化情况

图3. useLogIfChanged 函数,监听hooks变化

用下面的代码监听关键hooks数据,可以在组件刷新时输出变化的数据,如图 4和图 5

图4. 使用useLogIfChanged函数
图5. useLogIfChanged的输出结果

使用上面的监听函数,发现表格组件中 ClientSideDataGridWrapper records 这个数据在切换表时发生了两次变化,这让我吃了一惊。records 数据代表着表格中的每一条数据,他的生成经过了不少步骤,所以相对比较耗时,估计就是因为计算 records 造成的卡顿。

别慌,我再捋一捋。

首先,我修改了 B 表的数据,然后切换回 A 表,那么 A 表的 records 是没有发生变化的。因为 records 的计算过程相对耗时,我们已经使用了 reselect 和 re-reselect 对数据进行了缓存,所以当切换表时,records 不应该发生重复计算。

其次,就算发生变化,一次也就够了,第二次应该使用上一次计算的数据,不应该发生两次重复计算。

原来从 B 表切换回 A 表格时,生成 A 表的 records 数据的计算会重复两次,难怪页面会卡顿 -_-。要想搞清楚重复计算的原因,就用从 react,redux,react-redux,reselect 和 re-reselect 之间的关系讲起。

React, Redux, React-Redux

道可道非常道,名可名非常名

理解 React,Redux,React-Redux 之间的关系是非常重要的。简单来说,React 只是一个没有感情的渲染工具。Redux 像一个霸道总裁,用全局对象 store 来维护应用的数据。而我们熟知的 connect 方法或者 useSelector 则是由 React-Redux 提供的,作用就是把 React 组件和 redux store 关联起来,让 React 组件监听 Redux store 的变化从而进行渲染更新页面。

那么问题来了,如果我们 dispatch 一个 redux 的 action,store 究竟发生了哪些变化,有哪些 react 组件收到 store 变更通知,又有哪些 react 组件真正渲染呢?

我们来看例子,页面中有两个组件,Users 和 Products,Users 监听 store.users.byId 和 store.users.idList,Products 监听 store.products.byId 和 store.products.idList。搞不清楚为什么这样子设计 Redux Store 的同学,可以看一下这篇文章《如何设计 Redux Store》

图6. react 组件和 redux store 之间的关联

某一时刻用户点击了按钮更新 u002的数据,就会 dispatch UpdateUser action,接着 users reducer 处理这个 action,根据 reducer 的规则,应该生成新的 byId 对象,更新原有 store.users.byId。

那么这个时候,store,store.users,store.users.byId 都变成了新的object,而 store.users.idList, store.products, store.products.byId, store.products.idList 还是原始的object。

如图7所示,对,你没有看错!store这个全局对象也变成了新的object。

图7. redux store 发生变化

接下来,React-Redux 会通知所有调用过 connect 或者 useSelector 的 React 组件,对,你没有看错!所有调用过 connect 的 react 组件,比较组件监听的 store 对象有没有发生变化,比较的方式就是 js 中的 ===(strict equality),简单理解就是指针相等,如果相等则组件不渲染,如果不相等则进行开始重新渲染。

如图8所示,因为 Users 组件监听的 store.users.byId 变成了新的 object,所以 Users 组件会重新渲染。而对于 Products 组件,虽然整体 store 变成了新的object,但是 store.products.byId 和 store.products.idList 依然是原始的object,所以 Products 组件不会渲染

图8. Users 组件重新渲染

回顾一下:

1. 当 user reducer 处理 action 之后,store 中的部分 object 会变为新的 object,同时全局 store 也会变成新的 object,但 store 中其他 object 依然是原始object。

2.store 变化后会通知所有监听 store 的 react 组件,通过强等判定是否需要是否渲染。

这下你是不是理解了,为什么在 reducer 里面不能对 store 直接修改,而总要创建一个新的 object 返回。因为你修改原始 object 不会使”强等判断“失败,从而无法让 react 组件重新渲染。同时你也不用担心生成新的全局 store object 会有性能问题,因为 store 中除了 reducer 更新的 object 之外,大部分 object 依然是原始的 object,不会有大规模的数据拷贝,所以性能杠杠滴。

reselect,re-reselect

绝大多数情况下,cache就代表着cash

有时候,react 组件渲染时会运行一些计算逻辑,如果逻辑相对复杂,则会导致渲染性能下降。

假设 Users 组件除了 store.users 数据之外,还监听了 store.company 的数据,同时 Users 组件需要展示用户的平均年龄,如图 9和图 10。

图9. Users 组件监听 store.company 和 store.users
图10. Users 组件中计算某一个公司用户的平均年龄

如图10所示,Users 组件的会监听 store.company 和 store.users 中的数据。但如果只是 store.company 的数据变化了,Users 组件必然会重新渲染,那么计算用户平均年龄的逻辑会重新运行。但因为 store.users 数据没有变化,平均年龄当然不会改变,重复计算是可以避免的,所以我们可以使用 reselect 缓存这一计算结果。

如图11所示,我们定义了 getAverageAge 这个 selector,第一部分数组中的三个函数的返回值,store.users.byId,store.users.ids 和 company 是依赖项,而第二部分是计算平均年龄的逻辑。在第一次运行之后,计算的结果会缓存起来,在重新运行时,只要依赖项没有发生变化,计算逻辑就不会重复运行,直接返回上一次计算结果。

图11. 定义reselect,并使用reselect改写的Users组件

在 Users 组件用了 reselect 之后,当 store.company.byId 发生变化时,因为 store.users.byId, store.users.idList 和 company 没有发生变化,所以 reselect 会直接返回上一次计算的平均年龄 。在真实的项目中,存在更加复杂的计算或拼装逻辑,都可以使用 re-select 进行缓存。

但要注意的是,不能定义类似图12的 reselect,因为 Array.filter 方法会返回一个全新的数组,reselect 默认使用强等比较依赖项,导致依赖项比较失效,第二部分的计算逻辑每次都会执行,造成reselect cache 失效。

图12. 错误的reselect定义

reselect很好用,但还是有其他的问题。假设在一个页面中包含多个 company 组件,每个 company 组件展示对应 company 下的 Users 和 Product。

图13. 页面上有多个 company 组件,每个组件下有 Users 组件
图14. App 中有多个 company 组件

可惜在这种组件结构下,reselect 缓存是不能工作的,因为 reselect 创建出来的 selector 是全局共享的,所以当第一个  company 组件渲染时,company="HIPA" 是依赖项之一,HIPA 的平均年龄被缓存下来。而当第二个 company 组件渲染时,company="WG" 作为依赖项传入, reselect 发现依赖项变化,会丢弃 ”HIPA“ 的平均年龄,重新计算 WG 相关 user 的平均年龄。

同时当 company HIPA 再次渲染时,由于上一次是缓存了 WG 的平均年龄,所以会再次重复计算 HIPA 的平均年龄,这显然不是我们想要的结果。我们需要对同一类型的计算逻辑,输入不同参数,生成出不同的 reselect 分别进行缓存,这就是 re-reselect 出现的原因。使用 re-relsect 修改之后如图14。

图15.定义re-reselect

简单来讲,代码中最后面的函数就是 reselect 的生成器,它能根据函数结果(在例子中就是 company 的名字) 生成了多个 reselect,每个 reselect 只关心的自己依赖项,两个 Company 组件使用不同的 reselect 缓存计算结果,不会相互影响,非常好用!

在黑帕云中,已经使用 re-reselect 对每个表格的 records 数据进行了缓存,也就是说,B表的数据进行修改后,虽然破坏了 B 表 reselect 的依赖项,但不应该破坏 A 表的 reselect。所以在切换回 A 表时,应该直接返回 A 表的 records 数据,不应该重复计算才对。但 useLogIfChanged 函数却告诉我们,A 表的 records 数据重复计算了两次,到底是为什么呢?

第一次重复计算

小丑竟是我自己...

在第一次加载表 A 的时候,getRecords 就已经计算了表 A 的 records 并缓存了起来。当用户从 B 表切换回 A 表时,A 表的 reselect 发生了重复计算,那只有一种可能—— A 表的 reselect 的依赖项发生了变化,导致 cache 失效了。检查一下 getRecords 的相关代码:

图16. 表格组件对应的 re-reselect

重点关注一下上面的依赖项,分别是 table, state.records.byIdstate.records.idList

这三项在切换回 A 表之前发生变化了吗?好像没有吧。我想想啊,“我在 B 表修改了数据,然后切换到A表”,等等!我修改了 B 表中的数据之后,store.records.byId 就发生变化了,所以切换到 A 表时,A 表的re-reselect 中发现依赖项 store.records.byId 变化,所以会发生第一次重复计算。

也就是说,图15的 re-reselect 是相当脆弱的,只要任何一个表的 records 数据发生变化,会让所有表的 re-reselect 集体失效。这么简单的一个问题,我竟然没看出来。这愚蠢的 re-reselect 是谁写的,一看提交记录发现是我自己写的……

那怎么解决这个问题呢?其实很简单,只要想办法让 A 表的 re-reselect 中的依赖项不发生改变就好,具体的做法就是将 store.record.byId 按照 tableId 分拆成不同的对象, 变成了 store.record.byTableIdById,这样store.record.byTableIdById['table001'] 就管理了 table001 的所有数据, 而 store.record.byTableIdById['table002'] 管理 table002 的数据。图16是 store.records 分拆之后的结构。

图17. 拆分后的store.records结构

这种结构避免了在一个 object 下管理所有表格数据,也就避免了一个表数据的改动导致所有 re-reselect 失效的情况。这样当 B 表修改数据之后,只会改变 store.record.byTableIdById['tableB'] 中 B 表的数据,而不会改变 store.record.byTableIdById['tableA'] 的数据。

同样,getRecords 的 re-reselect 也要同样做出修改。新的 re-reselect 只会关注自己 table 数据的变化,最大限度的发挥 cache 的作用。

图18. 修改后的re-reselect

修改代码之后再次测试,果然发现 records 的计算少了一次,离成功不远了~~打铁趁热,赶紧看看最后一次重复计算的问题

第二次重复计算

你以为你以为的就是你以为的吗?

再次进行测试 getRecords 依然存在着一次多余的计算,说明 re-reselect 的依赖项还是发生了变化,但从代码来看找不出哪里有变化,无奈只能打开 re-reselect 的源码,定位到依赖项判定的相关逻辑,然后通过浏览器在相关逻辑处设置断点,抽丝剥茧的找出是哪一个依赖项发生变化。

经过一番 debug 后,最终发现是 store.records.byTableIdIdList发生了变化。这不科学!store.records.byTableIdIdList 是一个数组,记录了某一张表下面所有 record id,就算修改 record 的内容,id 也不应该发生变化,更何况我修改的是 B 表的数据,对于 A 表的 record id 数组肯定没有影响。那到底哪里修改了这个数组呢?

又仔细看了一下代码,发现之前的分析遗漏了一点。就是当切换回 A 表时,表格组件会发送 api 请求向服务器端获取最新的 A 表数据,records reducer 会把 response 的数据和 store 里的数据做一次merge,因为 A 表的数据没有改,所以 merge 之后 store 不应该发生变化。额,应该不会发生变化吧……

翻出 reducer 的代码,对 store 里面的数据有如下修改:

图19. record reducer 修改 state

可以看到代码直接对 state.byTableIdIdListstate.byTableIdById 进行了修改,而不是返回新的 object,这并不是写错了,而是因为黑帕云使用了 redux 官方推荐的 redux-tooltik 来创建 reducer。redux-toolkit 使用了 immer.js,immer.js 会对 store 进行监听,当 store 被修改时,immer.js 会根据修改内容生成新的 object,避免了之前麻烦的 reducer 写法,非常好用!

请看图19的例子,使用 immer.js 中的 produce 方法,对 todo 数据中的 object 进行修改,对于 todo[0]来说,虽然有赋值发生,但因为内容和原来的一样,所以生成的 nextTodos[0] 和之前的 todo[0] 是同一个对象,nextTodos[0] === todo[0]  为 true。而 todo[1].done 发生了变化,所以 nextTodos[1] !== todo[1] , 进而导致 nextTodos !== todo

图20. immer.js的实例

按道理 immer.js 会监听对象属性的变化,发生变化才会生成新对象。可是 A 表的 record Ids 数组没有发生变化呀,原来是[1,2,3],现在还是[1,2,3],immer.js不应该创建新的 array 才对呀。怎么也想不通,索性把 immer.js 的例子改一下,模拟一下黑帕云的场景看看。

图21. 模拟黑帕云 immer.js的例子

正所谓“你待 immer 如神,immer 教你做人”。从图20看出,对字符串赋相同的值,immerjs 不会生成新的 object,但对数组,就算数组内容是一模一样的,immerjs 依然认为发生了变化,创建了新的 array。这就解释了为什么record id 数组变化了,导致 re-reselect 检测依赖项变化,缓存失效重新计算,这就是第二次重复计算的原因。

既然根因找到了,解决的思路也就有了:

1. 要么修改 reducer 代码,避免 immerjs 生成新的 record id 数组

2. 要么修改 re-reselect 对依赖项的判定算法,re-reselect 默认用的是强等(===),可以换成 lodash 中的 isEqual,比较数组内容是否相等

最终我选择了第二种解决思路,修改完毕后的 re-reselect 如图21。

图22. 使用自定义比较函数的re-reselect

修改完之后再进行测试,第二次重复计算也消除了。在切换表格时,表格组件的整体渲染时间从 47ms 下降到 3 ms 左右,恢复了往日的顺滑。

结尾

万事开头难,然后中间难,最后结尾难

代码上线后,我回顾了一下解决性能问题的过程,有几点思考

1. 解决性能问题的关键还是定位性能热点

通过使用 React Profiler 和 useLogIfChanged 帮助我快速找到了问题的症结,这说明“一件趁手的兵器”还是非常重要的。另外,不要盲目相信网上所谓的性能优化技巧,一上来就是React.memo,useMemo,React code-split那一套,先搞清楚问题在哪里才是关键。

2. 不要盲目的使用“业界最佳实践”

对于 redux store 来说,“byId” 和 “idList” 这种模式几乎出现在所有 redux 的例子中,可惜这种看似“万能“的模式偏偏就在黑帕云中翻了车。这说明任何“最佳实践”依然是有使用场景的,不假思索的无脑抄也许并不能真的帮到你。从另一方面来说,redux store 的设计即需要参考业务知识,同时也会受到用户交互方式和 UI 组件的影响,比如 records.byId 拆分成 records.byTableIdById 。redux store 作为应用中核心数据模型,也许真的无法完全屏蔽 UI 层的影响,毕竟前端真的不如后端稳定,懂得都懂。

3. 要理解每个技术的底层原理

作为汽车驾驶员,你不需要了解汽车的运作原理也能开车,可汽车一旦出了问题,你唯一能做的就是打电话救援。同样的道理也发生在程序员的日常工作中,照着网上的例子写代码,不问其所以然,出了问题就懵逼了。就拿这次遇到的问题来说,通过对 react,redux 和 reselect 底层原理的了解,排查出 reselect重复计算的问题,又通过 reselect, redux-toolkit 中 immer.js 的研究,最终找到了突破口。面对任何技术,搞清楚底层原理是重要的步骤,也是迈向顶级程序员的必经之路。

4. 浏览器 devtool 的使用

在排查前端问题时,浏览器 devtool 工具算是入门的基础了,设置代码断点,查看元素样式,全局搜索关键变量等,都能快速定位问题所在。在这里我推荐大家看 chrome devtool 的视频合集,里面介绍了非常多好用的工具,我相信一定会帮助到各位。

天下武功,无坚不破,唯快不破。希望通过这两篇内容的分享,帮助大家解决性能问题,写出性能良好的代码 :)