记录一次利用Timeline Performance工具进行 React性能优化的真实案例
性能优化可以说是衡量一个前端程序员react使用水平的重要标准。
在学习react之初的时候,由于对react不够了解,写的项目虽然功能都实现了,但是性能优化方面的考虑却做得很少,因此回过头来发现自己以前写的react代码确实有点糟糕。
为了提高自己的react水平,闲暇之余就把以前的老项目拿出来分析优化,看看都有哪些问题。这里就以我以前做过的一个《投资日历》为例做一次优化记录。
项目线上地址:https://www.itiger.com/activi...
优化工具timeline/performance基础使用教程:
https://developers.google.com...
chrome在版本57还是58的时候,将Timeline更名为performance
该项目主要的难点与性能瓶颈在于日历的左右滑动与切换。由于需求定制程度非常高,没有合适的第三方日历插件,所以就自己实现了一个。支持周日历与月日历的切换,支持左右滑动切换日期。
滑动效果仅支持移动端
问题出现在公司一款老的android测试机,发现动画效果非常卡顿。因此有了优化的必要。
利用工具定位问题
首先利用performance工具的的录制功能录制一段操作过程。
点击左上角的黑色原点开始录制。录制过程中,多次滑动周日历即可。然后大约5~10秒点击stop按钮停止录制。
录制结果如图。
从上图中我们可以发现以下问题:
1、 窗格中出现了红帧。出现红帧表示页面已经超负荷,会出现卡顿,响应缓慢等现象。
2、 大量的黄色区域,黄色区域越大,表示JavaScript的运行过程中的压力也越大。
3、 高额的内存占用,以及不正常的波动曲线(蓝色)。详细信息可以在上图中的JS Heap
中查看。26.6 ~ 71.6M
。
我们可以在Main中观察到当前时刻的函数调用栈详情。当出现红帧,选中红帧区域,Main区域发现变化,变为当前选择时段的函数调用栈详情。我们会发现函数调用栈最上层有一个红色三角形。点击会在下面的Summary里发现对应的信息以及警告。如下图中的Warning: Recuring handler took 86.69 ms
。
4、 层级很高的函数调用栈。查看红色区域的函数调用栈,我们会发现大量的react组件方法被重复调用。
一步一步开始优化
从上面的分析就可以简单看出,虽然实现了非常复杂的功能,看上去很厉害的样子,其实内部非常糟糕。几乎可以作为react用法的反面教材了。
优化分析1
在上面的函数调用栈中,我们发现有一个方法出现的次数非常多,那就是receiveComponent
。因此可以预想到某个组件里肯定使用了receiveComponent
相关的生命周期的方法。检查代码,确实发现了几处componentWillReceiveProps
的使用。
// 每一次更新状态都会刷新一次,导致了大量的计算 componentWillReceiveProps(nextProps) { this.setState({ navProcess: getNavigation(nextProps.currentData) }) }
刚开始学习react时可能会认为生命周期是一个学习难点,我们不知道什么情况下去使用它们。慢慢的随着经验的增加,才发现,生命周期方法是万万不能轻易使用的。特别是与props/state改变,与组件重新渲染相关的几个生命周期,如componentWillReceiveProps
, shouldComponentUpdate
,componentWillUpdate
等。这个实际案例告诉我们,他们的使用,会造成高额的性能消耗。所以不到万不得已,不要轻易使用他们。
曾经看到过一篇英文博文,分析的是宁愿多几次render,也不要使用shouldComponentUpdate来优化代码。但是文章地址找不到,如果有其他看过的朋友请在评论里留言分享一下,感谢
而只有componentDidMount
是非常常用的。
上面几行简单的代码,暴露了一个非常恐怖的问题。一个是使用了生命周期componentWillReceiveProps
。而另一个则是在props改变的同时,还修改了组件的state。我们知道当props在父级被改变时会造成组件的重新渲染,而组件内部的state的改变同样也会造成组件的重新渲染,因此这几句简单的代码,让组件发生了很多次冗余的渲染。
因此优化的方向就朝这两个方向努力。首先不能使用componentWillReceiveProps
,其次我发现navProcess
其实可以在父级组件中计算,并通过props传递下来。所以优化后的代码如下:
function Index(props) { const { currentD, currentM, selectD, setDate, loading, error, process, navProcess } = props; return ( <div className="main"> <Calendar selectDate={selectD} curDate={currentD} curMonth={currentM} setDate={setDate} /> { loading ? null : error ? <ErrorMessage queryData={process.bind(null, selectD)} /> : <Classification navProcess={navProcess} selectDate={selectD} /> } {loading ? <Loading isLoading={ loading } /> : null} </div> ) } export default withWrapped(Index);
意外的惊喜是发现该组件最终优化成为了一个无状态组件,轻装上阵,完美。
这样优化之后,重新渲染的发生少了好几倍,运行压力自然减少很多。因此当滑动周日历时已经不会有红帧发生了。但是月日历由于DOM节点更多,仍然存在问题,因此核心的问题还不在这里。我们还得继续观察。
优化分析2
在函数调用栈中我们可以很明显的看到一个名为ani
的方法。而这个方法是我自己写的运动实现。因此我得重点关注它的实现中是不是存在什么问题。仔细浏览一遍,果然有问题。
发现在ani方法的回调中,调用了2次setDate方法。
// 导致顶层高阶组件多一次渲染,下层多很多次渲染 setDate(newCur, 0); setDate({ year: newCur.year, month: newCur.month }, 1)
该setDate方法是在父级中定义用来修改父级state的方法。他的每一次调用都会引发由上自下的重新渲染,因此多次调用的代价是非常大的。所以我将要面临的优化就是想办法将这两次调用合并为一次。
先看看优化以前setDate方法的定义是如何实现的。我想要通过不同的number来修改不同的state属性。但是没有考虑如果需要修改多个呢?
setDate = (date, number) => { if (number == 0) { this.setState({ currentD: date, currentM: { year: date.year, month: date.month } }) } if (number == 1) { this.setState({ currentM: date }) } if (number == 2) { _date = date; _month = { year: date.year, month: date.month }; this.setState({ currentD: _date, currentM: _month, selectD: _date }) this.process(date); } }
修改该方法为,传递一个对象字面量进去进行修改
setDate = (options) => { const state = { ...this.state, ...options }; if (options.selectD) { _date = options.selectD; _month = { year: _date.year, month: _date.month } state.currentD = _date; state.currentM = _month; this.process(_date, state); } else { this.setState(state); } }
该方法有两处优化,第一处优化是传入的参数调整,想要修改那一个就直接传入,用法类似setState。第二处优化是在this.process
方法中只调用一次this.setState
,总之这样处理的目的都是统一的,当想要数据修改时只发生一次渲染。而之前的方法会导致3次甚至多次渲染。这样优化之后,性能自然会提升很多。
优化分析3
但是优化并没有结束,因为再录制一段查看,仍然会发现红帧出现。
进一步查看Calendar组件,发现每一次滑动切换,都会发生4次渲染。肯定有问题。
我的目的是最多发生两次无法避免的渲染。多余的肯定是因为代码的问题导致的冗余渲染。因此继续查看代码。
发现在递归调用ani方法时,this.timer
并没有被及时取消。
// 我的目的是每一次递归会调用一次requestAnimationFrame与cancelAnimationFrame // 但是这样写只会在递归结束时调用一次cancelAnimationFrame if (offset == duration) { callback && callback(); cancelAnimationFrame(this.timer); } else { this.timer = requestAnimationFrame(ani); }
因此修改如下:
ani = () => { .... if (offset == duration) { callback && callback(); } else { this.timer = requestAnimationFrame(ani); } cancelAnimationFrame(this.timer); }
这样优化之后,发现内存占用下降一些,但是红帧仍然存在。看来计算量并没有下降。继续优化。
优化分析4
发现Calendar组件中,根据props中的curDate,curMonth计算而来的weekInfo与monthInfo被写在了该组件的state中。由于state中数据的变化都会导致重新渲染,而我发现在代码中有多处对他们进行修改。
componentDidMount() { const { curDate, curMonth } = this.props this.setState({ weekInfo: calendar.get3WeekInfo(curDate), monthInfo: calendar.get3MonthInfo(curMonth) }) this.setMessageType(curDate, 0); this.setMessageType(curMonth, 1); }
其实这种根据props中的参数计算而来的数据是万万不能写在state中的,因为props数据的变化也会导致组件刷新重新渲染,因此一个数据变化就会导致不可控制的多次渲染。这个时候更好的方式是直接在render中计算。因此优化如下:
render() { ... let info = type == 0 ? c.get3WeekInfo(curDate) : c.get3MonthInfo(curMonth); ... }
优化结果如下图
与第一张图对比,我们发现,运动过程中出现的红帧没有了。二是窗格中黄色区域大量减少,表示js的计算量减少很多。三是内存占用大幅降低,从最高的71M减少到了33M。内存的增长也更加平滑。
后续的优化大致目的都是一样。不再赘述。
最后总结一下:
- 尽量避免生命周期方法的使用,特别是与状态更新相关的生命周期,使用时一定要慎重。
- 能通过props重新渲染组件,就不要在额外添加state来增加渲染压力。
- 一切的优化方向就是在实现功能的前提下减少重新渲染的发生。
这其中涉及到的技巧就需要大家在实战中慢慢掌握了。