性能优化可以说是衡量一个前端程序员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改变,与组件重新渲染相关的几个生命周期,如componentWillReceivePropsshouldComponentUpdate 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);
    ...
}

优化结果如下图

image.png

与第一张图对比,我们发现,运动过程中出现的红帧没有了。二是窗格中黄色区域大量减少,表示js的计算量减少很多。三是内存占用大幅降低,从最高的71M减少到了33M。内存的增长也更加平滑。

后续的优化大致目的都是一样。不再赘述。

最后总结一下:

  1. 尽量避免生命周期方法的使用,特别是与状态更新相关的生命周期,使用时一定要慎重。
  2. 能通过props重新渲染组件,就不要在额外添加state来增加渲染压力。
  3. 一切的优化方向就是在实现功能的前提下减少重新渲染的发生。

这其中涉及到的技巧就需要大家在实战中慢慢掌握了。

clipboard.png


这波能反杀
12.6k 声望2.7k 粉丝