上一讲讲到,React 的性能问题通常有两类:一类是长列表,一类是重复渲染。这里再提一下长列表,它指的是你的页面渲染了很长的列表,通常有上百甚至上千行数据。长列表本身不是 React 特有的问题,无论是什么技术栈,都可能遇到。它的通用解决方案是采用虚拟滚动,业界做得比较好的解决方案有react-virtualized和react-window,已经非常成熟了。
那重复渲染的解决方案有没有这么成熟呢?不仅没有,甚至还存在一些误区。所以这一讲我们就这个主题来讲解下。
在前面的生命周期与渲染流程等章节中都有提过避免重复渲染的具体方案,但为什么还是会发生重复渲染呢?因为当业务复杂度与项目规模还没有上升到引发页面性能下降时,这个问题不值得探讨,因为即使处理了也会白费功夫。这就让我想起了这句话:
过早的优化是万恶之源。
—— Donald Knuth 《计算机编程艺术》
互联网时代,是一个追求快速交付的时代。保证业务快速上线远比代码质量更为重要。只要业务能跑,性能往往是相对靠后的要求。这就需要我们明确优化时机的问题,即什么时候该做,什么时候不该做。
其次需要讨论如何排查问题页面,借助什么工具去定位?
然后就是一些常见忽略的点,即有哪些我们以为不会引发重渲染的写法,实际上会有问题?又该如何避免?
最后就是如何设计优化方案,有什么好的实践,以及有什么反面的做法。
那么基于这样一个思路,可以整理下答题方式:
优化时机,说明应该在什么时候做优化,这样做的理由是什么;
定位方式,用什么方式可以快速地定位相关问题;
常见的坑,明确哪些常见的问题会被我们忽略,从而导致重渲染;
处理方案,有哪些方案可以帮助我们解决这个问题。
这里需要强调一个问题,通常我们认为制定处理方案是整个过程的重点,但对于大厂而言,更重要的是先证明这件事是否有做的必要性。这就要求我们具备洞察力,能辨别矛盾的主次关系,合理安排人力与资源,往要害处下手。所以这就需要我们先想清楚为什么要优化,以及什么时候做优化。
正如开篇所讲,重新渲染可以是一个问题,也可以不是。在厘清问题之前,不妨先回顾下重新渲染时会发生什么。
原理
React 会构建并维护一套内部的虚拟 DOM 树,因为操作 DOM 相对操作 JavaScript 对象更慢,所以根据虚拟 DOM 树生成的差异更新真实 DOM。那么每当一个组件的 props 或者 state 发生变更时,React 都会将最新返回的元素与之前渲染的元素进行对比,以此决定是否有必要更新真实的 DOM。当它们不相同时,React 会更新该 DOM。这个过程被称为协调。
协调的成本非常昂贵,如果一次性引发的重新渲染层级足够多、足够深,就会阻塞 UI 主线程的执行,造成卡顿,引起页面帧率下降。
时机
虽然重新渲染会带来额外的性能负担,但这并不意味着我们就需要立刻优化它。正如上一讲所说,任何结论应该建立在业务标准与数据基础上分析。数据基础很好理解,上一讲提过,那业务标准是什么呢?
比如,当前的前端页面有 10 个,其中有 9 个页面经过数据采集后 TP99 在 52 FPS 左右,其中有 1 个页面 TP99 在 29 FPS 左右。
既然 52 FPS 没到 60,那有必要优化吗?
一般 50 ~ 60 FPS,就相当流畅了;
在 30 ~ 50 FPS 之间就因人而异了,通常属于尚可接受的范畴;
在 30 FPS 以下属于有明显卡顿,会令人不适。
依照上面的标准,其中的 9 个页面都没有优化的必要,完全可以放任自流,只需要聚焦 29 FPS 的页面即可。但在开展优化工作之前,还需要调查客观运行环境的情况,比如浏览器与运行设备等。
如果该用户将页面运行在 IE 中,而你的业务不需要支持 IE,低帧率需要优化吗?显然也是不需要的。
如果该用户的手机是 5 年前的旧机型,配置相当低,运行内存只有 512 MB,那还需要优化吗?你的业务如果需要兼容这部分用户的机型,那就要去做。
所以你的业务在目标群体中的运行环境标准就是业务标准。
有数据有标准才能分析是否有做的必要,是否能产生业务价值。
通过数据采集,确认页面在 TP99 帧率不足 30 FPS,然后就需要开始定位该页面的问题。定位的第一步应该是还原场景、完整复现。
复现
如果你能直接在设备上成功复现该问题,那是最好的,这个问题就没有什么探讨的价值了。而在实际工作中常常会出现一种截然相反的情况,就是无法复现。那首要采取的行动就是寻找运行该页面的设备机型与浏览器版本,确保能在相同环境下复现。如果还是不能,就需要确认影响范围,是否只是在特定的设备或者特定的浏览器版本才会出现该问题,这样就需要转入长期作战,增加埋点日志,采集更多的数据进行复现方式的分析。
工具
成功复现后,就需要通过工具定位问题点。通常通过两个工具去处理:
通过 Chrome 自带的 Performance 分析,主要用于查询 JavaScript 执行栈中的耗时,确认函数卡顿点,由于和重复渲染关联度不高,你可以自行查阅使用文档;
通过 React Developer Tools 中的 Profiler 分析组件渲染次数、开始时间及耗时。
如果需要查看页面上的组件是否有重新渲染,可以在配置项里直接开启Highlight updates when components render。此时,有组件渲染了,就会直接高亮。
打开录制功能,在操作一段时间后暂停,就能看见具体的渲染情况:
不渲染的内容,会直接标记为Did not render;
重复渲染的内容可直接查看渲染耗时等消息。
如下图所示:
React Profiler 的详细使用方式建议阅读官方文档,在排查重复渲染上没有比这更好的工具了。
在 React Profiler 的运行结果中,我们可以看出,避免重复渲染并不是不让它去渲染。
如果页面有显示信息变化的需求,那就要重新渲染;
但如果仅仅是更新单个组件,却触发了大量无关组件更新,那就有问题了。
所以我们避免的是无效的重复渲染,毕竟协调成本很昂贵。
比如有一个这样的列表,内部元素的顺序可以上移下移。代码如下所示:
const initListData = []
for (let i = 0; i < 10; i++) {
initListData.push({ text: i, id: i });
}
const LisItem = ({ text, id, onMoveUp, onMoveDown }) => (
<div>
{text}
<button onClick={() => onMoveUp(id)}>
上移
button>
<button onClick={() => onMoveDown(id)}>
下移
button>
div>
);
class List extends React.Component {
state = {
listData: initListData,
}
handleMoveUp = (id) => {
// ...
}
handleMoveDown = (id) => {
// …
}
render() {
const {
listData
} = this.state
return (
<div>
{
list.map(({ text, id }, index) => (
<ListItem
key={id}
id={id}
text={text}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
))
}
div>
)
}
}
这段代码分为两个部分:
List组件用于展示列表,执行上下移动的逻辑;
ListItem,也就是列表中展示的行,渲染每行的内容。
执行这段代码后,如果你点击某行的 ListItem 进行上下移动,在 React Profile 中你会发现其他行也会重新渲染。
如果应用我们前面所学的知识,为 ListItem 添加 React.memo 就可以阻止每行内容重新渲染。如下代码所示:
const LisItem = React.memo(({ text, onMoveUp, onMoveDown }) => (
<div>
{text}
<button onClick={() => onMoveUp(item)}>
上移
button>
<button onClick={() => onMoveDown(item)}>
下移
button>
div>
))
要知道无论是 React.memo 还是 PureComponent 都是通过浅比较的方式对比变化前后的 props 与 state,对比过程就是下面这段摘抄于 React 源码的代码。
if (type.prototype && type.prototype.isPureReactComponent) {
return (
!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
);
}
那是否存在失效的情况呢?
最常见的情况莫过于使用箭头函数,比如像下面这样的写法,通过箭头函数取代原有的 handleMoveUp 函数。 那么此时再打开 React Profile,你会发现每次移动某行时,其他无关行又开始重复渲染了。
{ //... }}
onMoveDown={this.handleMoveDown}
/>
这是因为箭头函数在 List 每次调用 render 时都会动态生成一个新的函数,函数的引用变化了,这时即便使用 React.memo 也是无效的。
JSX 的问题比较好解决,将整个函数提取为一个类属性的函数就可以了,但还有一类问题并不好解决,比如在 React Native 中,有个错误的使用案例是这样的:FlatList 是一个 PureComponent,但每次调用 render 函数都会生成一个新的 data 对象,与上面同理,PureComponent 就破防了,如果下层的子组件没有设置防护,那就层层击穿,开始昂贵的协调了。如下代码所示:
render() {
const data = this.props.list.map((item) => { /*... */ })
return (
<FlatList
data={data}
renderItem={this.renderItem}
/>
)
}
所以在使用组件缓存的 API 时,一定要避开这些问题。
那怎么解决呢?React 在设计上是通过数据的变化引发视图层的更新。
缓存
性能不够,缓存来凑,第一类方案是添加缓存来处理,常见的解决方案有 Facebook 自研的 reselect。让我们回到 FlatList 的案例,虽然 this.props.list 每次必须经过转换后才能使用,但我们只要保证 list 不变时转换后的 data 不变,就可以避免重复渲染。
reselect 会将输入与输出建立映射,缓存函数产出结果。只要输入一致,那么会直接吐出对应的输出结果,从而保证计算结果不变,以此来保证 pureComponent 不会被破防。如以下案例所示:
import { createSelector } fr om 'reselect'
const listSelector = props => props.list || []
const dataSelector = createSelector(
listSelector,
list => list.map((item) => { /*... */ })
)
render() {
return (
<FlatList
data={dataSelector(this.props)}
renderItem={this.renderItem}
/>
)
}
不可变数据
第二类方案的心智成本相对比较高,是使用不可变数据,最早的方案是使用ImmutableJS。如果我们无法将 props 或者 state 扁平化,存在多级嵌套且足够深,那么每次修改指定节点时,可能会导致其他节点被更新为新的引用,而ImmutableJS 可以保证修改操作返回一个新引用,并且只修改需要修改的节点。
ImmutableJS 常见的一个错误使用方式就是下面这样的,即在传参时,使用 toJS 函数生成新的对象,那就又破防了。
this.renderItem}
/>
这样的错误写法太常见了,存在于大量的 ImmutableJS 项目中。造成的原因是 ImmutableJS 本身的数据遍历 API 使用麻烦,且不符合直觉,所以如今 immerjs 更为流行。
手动控制
最后一种解决方案就是自己手动控制,通过使用 shouldComponentUpdate API 来处理,在生命周期一讲中有详细介绍过,这里就不赘述了。
需要注意,使用 shouldComponentUpdate 可能会带来意想不到的 Bug,所以这个方案应该放到最后考虑。
如何避免重复渲染分为三个步骤:选择优化时机、定位重复渲染的问题、引入解决方案。
优化时机需要根据当前业务标准与页面性能数据分析,来决定是否有必要。如果卡顿的情况在业务要求范围外,那确实没有必要做;如果有需要,那就进入下一步——定位。
定位问题首先需要复现问题,通常采用还原用户使用环境的方式进行复现,然后使用 Performance 与 React Profiler 工具进行分析,对照卡顿点与组件重复渲染次数及耗时排查性能问题。
通常的解决方案是加 PureComponent 或者使用 React.memo 等组件缓存 API,减少重新渲染。但错误的使用方式会使其完全无效,比如在 JSX 的属性中使用箭头函数,或者每次都生成新的对象,那基本就破防了。
针对这样的情况有三个解决方案:
缓存,通常使用 reselect 缓存函数执行结果,来避免产生新的对象;
不可变数据,使用数据 ImmutableJS 或者 immerjs 转换数据结构;
手动控制,自己实现 shouldComponentUpdate 函数,但这类方案一般不推荐,因为容易带来意想不到的 Bug,可以作为保底手段使用。
通过以上的手段就可以避免无效渲染带来的性能问题了。
本讲中并没有出现实际的案例,但如果你在项目中有重复渲染案例,不妨根据第 13 讲“如何分析和调优性能瓶颈?”中的流程,自己再总结一份答案,看看是否会有新的收获。
在介绍完 React 的性能相关问题后,下一讲我会为你介绍如何提升 React 代码的可维护性。
《大前端高薪训练营》
觉悟了,过早的优化是万恶之源。
老师可以讲个例子,使用 shouldComponentUpdate 可能会带来意想不到的 Bug 是什么吗?
在业务场景中重写 shouldComponentUpdate 的风险点在于可能会写成判断逻辑,导致在意想不到的地方始终返回 true,使得组件没有正常渲染。
老师,请问为什么list某个item上下移,所以的listitem会重新渲染呢?
是这样的,因为 LisItem 是个 Pure Function Component,那么根据前面所讲的,只要 props 传递进去,他就会重新处理,它并没有 shouldComponentUpdate 去阻渲染,所以得加上 React.memo
.tojs()不是生成新的对象吧,只是一层映射吧
映射是什么?在 JavaScript 中没有这样的定义,如果是指引用,也就是 reference 的话,那显然每次都会生成新的对象。详细内容可以看一下这个 issue(https://github.com/immutable-js/immutable-js/issues/395),issue 的提问者希望 toJS 能保存引用,但被作者拒绝了,因为可能存在内存泄露的风险。
另外也可以看一下 toJS 的源码(https://github.com/immutable-js/immutable-js/blob/91c7c1e82ec616804768f968cc585565e855c8fd/src/toJS.js)