React 的慢与快:优化 React 应用实战

  • 原文地址:React is Slow, React is Fast: Optimizing React Apps in Practice
  • 原文作者:François Zaninotto
  • 译文出自:掘金翻译计划
  • 译者:Jiang Haichao
  • 校对者:Wneil, Chen Lu

React 是慢的。我的意思是,任何中等规模的 React 应用都是慢的。但是在开始找备选方案之前,你应该明白任何中等规模的 Angular 或 Ember 应用也是慢的。好消息是:如果你在乎性能,使 React 应用变得超级快则相当容易。这篇文章就是案例。

衡量 React 性能

我说的 “慢” 到底是什么意思?举个例子。

我正在为 admin-on-rest 这个开源项目工作,它使用 material-ui 和 Redux 为任一 REST API 提供一个 admin 用户图形界面。这个应用已经有一个数据页,在一个表格中展示一系列记录。当用户改变排列顺序,导航到下一个页面,或者做结果筛选,这个界面的响应式做的我不够满意。接下来的截屏是刷新放慢了 5x 的结果。

React 的慢与快:优化 React 应用实战_第1张图片
Datagrid refresh

来看看发生了什么,我在 URL 里插入一个 ?react_perf。自 React 15.4,可以通过这个属性启用 组件 Profiling。等待初始化数据页加载完毕。在 Chrome 开发者工具打开 Timeline 选项卡,点击 "Record" 按钮,并单击表头更新排列顺序。一旦数据更新,再次点击 "Record" 按钮停止记录,Chrome 会在 "User Timing" 标签下展示一个黄色的火焰图。

React 的慢与快:优化 React 应用实战_第2张图片
Initial flamegraph

如果你从未见过火焰图,看起来会有点吓人,但它其实非常易于使用。这个 "User Timing" 图显示的是每个组件占用的时间。它隐藏了 React 内部花费的时间(这部分时间是你无法优化的),所以这图使你专注优化你的应用。这个 Timeline 显示的是不同阶段的窗口截屏,这就能聚焦到点击表头时对应的时间点情况。

React 的慢与快:优化 React 应用实战_第3张图片
Initial flamegraph zoomed

似乎在点击排序按钮后,甚至在拿到 REST 数据 之前 就已经重新渲染,我的应用就重新渲染了 组件。这个过程花费了超过 500ms。这个应用仅仅更新了表头的排序 icon,和在数据表之上展示灰色遮罩表明数据仍在传输。

另外,这个应用花了半秒钟提供点击的视觉反馈。500ms 绝对是可感知的 - UI 专家如是说,当视觉层改变低于 100ms 时,用户感知才是瞬时的。这一可觉察的变更即是我所说的 ”慢“。

为何而更新?

根据上述火焰图,你会看到许多小的凹陷。那不是一个好标志。这意味着许多组件被重绘了。火焰图显示, 组件更新花费了最多时间。为什么在获取到新数据之前应用会重绘整个数据表呢?让我们来深入探讨。

要理解重绘的原因,通常要借助在 render 函数里添加 console.log() 语句完成。因为函数式的组件,你可以使用如下的单行高阶组件(HOC):

// in src/log.js
const log = BaseComponent => props => {
    console.log(`Rendering ${BaseComponent.name}`);
    return ;
}
export default log;

// in src/MyComponent.js
import log from './log';
export default log(MyComponent);

小提示:另一值得一提的 React 性能工具是 why-did-you-update。这个 npm 包在 React 基础上打了一个补丁,当一个组件基于相同 props 重绘时会打出 console 警告。说明:输出十分冗长,并且在函数式组件中不起作用。

在这个例子中,当用户点击列的标题,应用触发一个 action 来改变 state:此列的排序 [currentSort] 被更新。这个 state 的改变触发了 页的重绘,反过来造成了整个 组件的重绘。在点击排序按钮后,我们希望 datagrid 表头能够立刻被重绘,作为用户行为的反馈。

使得 React 应用迟缓的通常不是单个慢的组件(在火焰图中反映为一个大的区块)。大多数时候,使 React 应用变慢的是许多组件无用的重绘。 你也许曾读到,React 虚拟 DOM 超级快的言论。那是真的,但在一个中等规模的应用中,全量重绘容易造成成百的组件重绘。甚至最快的虚拟 DOM 模板引擎也不能使这一过程低于 16ms。

切割组件即优化

这是 组件的 render() 方法:

// in Datagrid.js
render() {
    const { resource, children, ids, data, currentSort } = this.props;
    return (
        
                    {React.Children.map(children, (field, index) => (
                        
                    ))}
                
                {ids.map(id => (
                    
                        {React.Children.map(children, (field, index) => (
                            
                        ))}
                    
                ))}
            
); }

这看起来是一个非常简单的 datagrid 的实现,然而这 非常低效。每个 调用会渲染至少两到三个组件。正如你在初次界面截图里看到的,这个表有 7 列,11 行,即 7x11x3 = 231 个组件会重新渲染。仅仅是 currentSort 的改变时,这简直是浪费时间。虽然在虚拟 DOM 没有更新的情况下,React 不会更新真实DOM,所有组件的处理也会耗费 500ms。

为了避免无用的表体渲染,第一步就是把它 抽取 出来:

// in Datagrid.js
render() {
    const { resource, children, ids, data, currentSort } = this.props;
    return (
        
                    {React.Children.map(children, (field, index) => (
                        
                    ))}
                
                {children}
            
); ); }

通过抽取表体逻辑,我创建了新的 组件:

// in DatagridBody.js
import React from 'react';

const DatagridBody = ({ resource, ids, data, children }) => (
    
        {ids.map(id => (
            
                {React.Children.map(children, (field, index) => (
                    
                ))}
            
        ))}
    
);

export default DatagridBody;

抽取表体对性能上毫无影响,但它反映了一条优化之路。庞大的,通用的组件优化起来有难度。小的,单一职责的组件更容易处理。

shouldComponentUpdate

React 文档 里对于避免无用的重绘有非常明确的方法:shouldComponentUpdate()。默认的,React 一直重绘 组件到虚拟 DOM 中。换句话说,作为开发者,在那种情况下,检查 props 没有改变的组件和跳过绘制都是你的工作。

以上述 组件为例,除非 props 改变,否则 body 就不应该重绘。

所以组件应该如下:

import React, { Component } from 'react';

class DatagridBody extends Component {
    shouldComponentUpdate(nextProps) {
        return (nextProps.ids !== this.props.ids
             || nextProps.data !== this.props.data);
    }

    render() {
        const { resource, ids, data, children } = this.props;
        return (
            
                {ids.map(id => (
                    
                        {React.Children.map(children, (field, index) => (
                            
                        ))}
                    
                ))}
            
        );
    }
}

export default DatagridBody;

小提示:相比手工实现 shouldComponentUpdate() 方法,我可以继承 React 的 PureComponent 而不是 Component。这个组件会用严格对等(===)对比所有的 props,并且仅当 任一 props 变更时重绘。但是我知道在例子的上下文中 resourcechildren 不会变更,所以无需检查他们的对等性。

有了这一优化,点击表头后, 组件的重绘会跳过表体及其全部 231 个组件。这会将 500ms 的更新时间减少到 60ms。网络性能提高超过 400ms!

React 的慢与快:优化 React 应用实战_第4张图片
Optimized flamegraph

小提示:别被火焰图的宽度骗了,比前一个火焰图而言,它放大了。这幅火焰图显示的性能绝对是最好的!

shouldComponentUpdate 优化在图中去掉了许多凹坑,并减少了整体渲染时间。我会用同样的方法避免更多的重绘(例如:避免重绘 sidebar,操作按钮,没有变化的表头和页码)。一个小时的工作之后, 点击表头的列后,整个页面的渲染时间仅仅是 100ms。那相当快了 - 即使仍然存在优化空间。

添加一个 shouldComponentUpdate 方法也许似乎很麻烦,但如果你真的在乎性能,你所写的大多数组件都应该加上。

别哪里都加上 shouldComponentUpdate - 在简单组件上执行 shouldComponentUpdate 方法有时比仅渲染组件要耗时。也别在应用的早期使用 - 这将过早地进行优化。但随着应用的壮大,你会发现组件上的性能瓶颈,此时才添加 shouldComponentUpdate 逻辑保持快速地运行。

重组

我不是很满意之前在 上的改造:由于使用了 shouldComponentUpdate,我不得不改造成简单的基于类的函数式组件。这增加了许多行代码,每一行代码都要耗费精力 - 去写,调试和维护。

幸运的是,得益于 recompose,你能够在高阶组件(HOC)上实现 shouldComponentUpdate 的逻辑。它是一个 React 的函数式工具,提供 pure() 高阶实例。

// in DatagridBody.js
import React from 'react';
import pure from 'recompose/pure';

const DatagridBody = ({ resource, ids, data, children }) => (
    
        {ids.map(id => (
            
                {React.Children.map(children, (field, index) => (
                    
                ))}
            
        ))}
    
);

export default pure(DatagridBody);

这段代码与上述的初始实现仅有的差异是:我导出了 pure(DatagridBody) 而非 DatagridBodypure 就像 PureComponent,但是没有额外的类模板。

当使用 recomposeshouldUpdate() 而不是 pure() 的时候,我甚至可以更加具体,只瞄准我知道可能改变的 props:

// in DatagridBody.js
import React from 'react';
import shouldUpdate from 'recompose/shouldUpdate';

const DatagridBody = ({ resource, ids, data, children }) => (
    ...
);

const checkPropsChange = (props, nextProps) =>
    (nextProps.ids !== this.props.ids
  || nextProps.data !== this.props.data);

export default shouldUpdate(checkPropsChange)(DatagridBody);

checkPropsChange 是纯函数,我甚至可以导出做单元测试。

recompose 库提供了更多 HOC 的性能优化方案,例如 onlyUpdateForKeys(),这个方法所做的检查,与我自己写的 checkPropsChange 那类检查完全相同。

// in DatagridBody.js
import React from 'react';
import onlyUpdateForKeys from 'recompose/onlyUpdateForKeys';

const DatagridBody = ({ resource, ids, data, children }) => (
    ...
);

export default onlyUpdateForKeys(['ids', 'data'])(DatagridBody);

强烈推荐 recompose 库,除了能优化性能,它能帮助你以函数和可测的方式抽取数据获取逻辑,HOC 组合和进行 props 操作。

Redux

如果你正在使用 Redux 管理应用的 state (我也推荐这一方式),那么 connected 组件已经是纯组件了。不需要添加 HOC。只要记住一旦其中一个 props 改变了,connected 组件就会重绘 - 这也包括了所有子组件。因此即使你在页面组件上使用 Redux,你也应该在渲染树的深层用 pure()shouldUpdate()

并且,当心 Redux 用严格模式对比 props。因为 Redux 将 state 绑定到组件的 props 上,如果你修改 state 上的一个对象,Redux 的 props 对比会错过它。这也是为什么你必须在 reducer 中用 不可变性原则

举个栗子,在 admin-on-rest 中,点击表头 dispatch 一个 SET_SORT action。监听这个 action 的 reducer 必须 替换 state 中的 object,而不是 更新 他们。

// in listReducer.js
export const SORT_ASC = 'ASC';
export const SORT_DESC = 'DESC';

const initialState = {
    sort: 'id',
    order: SORT_DESC,
    page: 1,
    perPage: 25,
    filter: {},
};

export default (previousState = initialState, { type, payload }) => {
    switch (type) {
    case SET_SORT:
        if (payload === previousState.sort) {
            // inverse sort order
            return {
                ...previousState,
                order: oppositeOrder(previousState.order),
                page: 1,
            };
        }
        // replace sort field
        return {
            ...previousState,
            sort: payload,
            order: SORT_ASC,
            page: 1,
        };

    // ...

    default:
        return previousState;
    }
};

还是这个 reducer,当 Redux 用 '===' 检查到变化时,它发现 state 对象的不同,然后重绘 datagrid。但是我们修改 state 的话,Redux 将会忽略 state 的改变并错误地跳过重绘:

// don't do this at home
export default (previousState = initialState, { type, payload }) => {
    switch (type) {
    case SET_SORT:
        if (payload === previousState.sort) {
            // never do this
            previousState.order = oppositeOrder(previousState.order);
            return previousState;
        }
        // never do that either
        previousState.sort = payload;
        previousState.order = SORT_ASC;
        previousState.page = 1;
        return previousState;

    // ...

    default:
        return previousState;
    }
};

为了不可变的 reducer,其他开发者喜欢用同样来自 Facebook 的 immutable.js。我觉得这没必要,因为 ES6 解构赋值使得有选择地替换组件属性十分容易。另外,Immutable 也很笨重(60kB),所以在你的项目中添加它之前请三思。

重新选择

为了防止(Redux 中)无用的绘制 connected 组件,你必须确保 mapStateToProps 方法每次调用不会返回新的对象。

以 admin-on-rest 中的 组件为例。它用以下代码从 state 中为当前 resource 获取一系列记录(如:帖子,评论等):

// in List.js
import React from 'react';
import { connect } from 'react-redux';

const List = (props) => ...

const mapStateToProps = (state, props) => {
    const resourceState = state.admin[props.resource];
    return {
        ids: resourceState.list.ids,
        data: Object.keys(resourceState.data)
            .filter(id => resourceState.list.ids.includes(id))
            .map(id => resourceState.data[id])
            .reduce((data, record) => {
                data[record.id] = record;
                return data;
            }, {}),
    };
};

export default connect(mapStateToProps)(List);

state 包含了一个数组,是以前获取的记录,以 resource 做索引。举例,state.admin.posts.data 包含了一系列帖子:

{
    23: { id: 23, title: "Hello, World", /* ... */ },
    45: { id: 45, title: "Lorem Ipsum", /* ... */ },
    67: { id: 67, title: "Sic dolor amet", /* ... */ },
}

mapStateToProps 方法筛选 state 对象,只返回在 list 中展示的部分。如下所示:

{
    23: { id: 23, title: "Hello, World", /* ... */ },
    67: { id: 67, title: "Sic dolor amet", /* ... */ },
}

问题是每次 mapStateToProps 执行,它会返回一个新的对象,即使底层对象没有被改变。结果, 组件每次都会重绘,即使只有 state 的一部分改变了 - date 或 ids 改变造成 id 改变。

Reselect 通过备忘录模式解决这个问题。相比在 mapStateToProps 中直接计算 props,从 reselect 中用 selector 如果输入没有变化,则返回相同的输出。

import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect'

const List = (props) => ...

const idsSelector = (state, props) => state.admin[props.resource].ids
const dataSelector = (state, props) => state.admin[props.resource].data

const filteredDataSelector = createSelector(
  idsSelector,
  dataSelector
  (ids, data) => Object.keys(data)
      .filter(id => ids.includes(id))
      .map(id => data[id])
      .reduce((data, record) => {
          data[record.id] = record;
          return data;
      }, {})
)

const mapStateToProps = (state, props) => {
    const resourceState = state.admin[props.resource];
    return {
        ids: idsSelector(state, props),
        data: filteredDataSelector(state, props),
    };
};

export default connect(mapStateToProps)(List);

现在 组件仅在 state 的子集改变时重绘。

作为重组问题,reselect selector 是纯函数,易于测试和组合。它是为 Redux connected 组件编写 selector 的最佳方式。

当心 JSX 中的对象字面量

当你的组件变得更 “纯” 时,你开始检测导致无用重绘坏模式。最常见的是 JSX 中对象字面量的使用,我更喜欢称之为 "臭名昭著的 {{"。请允许我举例说明:

import React from 'react';
import MyTableComponent from './MyTableComponent';

const Datagrid = (props) => (
    
        ...
    
)

每次 组件重绘, 组件的 style 属性都会得到一个新值。所以即使 是纯的,每次 重绘时它也会跟着重绘。事实上,每次把对象字面量当做属性值传递到子组件时,你就打破了纯函数。解法很简单:

import React from 'react';
import MyTableComponent from './MyTableComponent';

const tableStyle = { marginTop: 10 };
const Datagrid = (props) => (
    
        ...
    
)

这看起来很基础,但是我见过太多次这个错误,因而生成了检测臭名昭著的 {{ 的敏锐直觉。我把他们一律替换成常量。

另一个常用来劫持纯函数的 suspect 是 React.cloneElement()。如果你把 prop 值作为第二参数传入方法,每次渲染就会生成一个带新 props 的新 clone 组件。

// bad
const MyComponent = (props) => 
{React.cloneElement(Foo, { bar: 1 })}
; // good const additionalProps = { bar: 1 }; const MyComponent = (props) =>
{React.cloneElement(Foo, additionalProps)}
;

material-ui 已经困扰了我一段时间,举例如下:

import { CardActions } from 'material-ui/Card';
import { CreateButton, RefreshButton } from 'admin-on-rest';

const Toolbar = ({ basePath, refresh }) => (
    
        
        
    
);

export default Toolbar;

尽管 是纯函数,但每次 绘制它也会绘制。那是因为 material-ui 的 添加了一个特殊 style,为了使第一个子节点适应 margin - 它用了一个对象字面量来做这件事。所以 每次都收到不同的 style 属性。我用 recompose 的 onlyUpdateForKeys() HOC 解决了这个问题。

// in Toolbar.js
import onlyUpdateForKeys from 'recompose/onlyUpdateForKeys';

const Toolbar = ({ basePath, refresh }) => (
    ...
);

export default onlyUpdateForKeys(['basePath', 'refresh'])(Toolbar);

结论

还有许多可以使 React 应用更快的方法(使用 keys、懒加载重路由、react-addons-perf 包、使用 ServiceWorkers 缓存应用状态、使用同构等等),但正确实现 shouldComponentUpdate 是第一步 - 也是最有用的。

React 默认是不快的,但是无论是什么规模的应用,它都提供了许多工具来加速。这也许是违反直觉的,尤其自从许多框架提供了 React 的替代品,它们声称比 React 快 n 倍。但 React 把开发者的体验放在了性能之前。这也是为什么用 React 开发大型应用是个愉快的体验,没有惊吓,只有不变的实现速度。

只要记住,每隔一段时间 profile 你的应用,让出一些时间在必要的地方添加一些 pure() 调用。别一开始就做优化,别花费过多时间在每个组件的过度优化上 - 除非你是在移动端。记住在不同设备进行测试,让用户对应用的响应式有良好印象。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、React、前端、后端、产品、设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划。

你可能感兴趣的:(React 的慢与快:优化 React 应用实战)