React 是慢的。我的意思是,任何中等规模的 React 应用都是慢的。但是在开始找备选方案之前,你应该明白任何中等规模的 Angular 或 Ember 应用也是慢的。好消息是:如果你在乎性能,使 React 应用变得超级快则相当容易。这篇文章就是案例。
衡量 React 性能
我说的 “慢” 到底是什么意思?举个例子。
我正在为 admin-on-rest 这个开源项目工作,它使用 material-ui 和 Redux 为任一 REST API 提供一个 admin 用户图形界面。这个应用已经有一个数据页,在一个表格中展示一系列记录。当用户改变排列顺序,导航到下一个页面,或者做结果筛选,这个界面的响应式做的我不够满意。接下来的截屏是刷新放慢了 5x 的结果。
来看看发生了什么,我在 URL 里插入一个 ?react_perf
。自 React 15.4,可以通过这个属性启用 组件 Profiling。等待初始化数据页加载完毕。在 Chrome 开发者工具打开 Timeline 选项卡,点击 "Record" 按钮,并单击表头更新排列顺序。一旦数据更新,再次点击 "Record" 按钮停止记录,Chrome 会在 "User Timing" 标签下展示一个黄色的火焰图。
如果你从未见过火焰图,看起来会有点吓人,但它其实非常易于使用。这个 "User Timing" 图显示的是每个组件占用的时间。它隐藏了 React 内部花费的时间(这部分时间是你无法优化的),所以这图使你专注优化你的应用。这个 Timeline 显示的是不同阶段的窗口截屏,这就能聚焦到点击表头时对应的时间点情况。
似乎在点击排序按钮后,甚至在拿到 REST 数据 之前 就已经重新渲染,我的应用就重新渲染了 <List>
组件。这个过程花费了超过 500ms。这个应用仅仅更新了表头的排序 icon,和在数据表之上展示灰色遮罩表明数据仍在传输。
另外,这个应用花了半秒钟提供点击的视觉反馈。500ms 绝对是可感知的 - UI 专家如是说,当视觉层改变低于 100ms 时,用户感知才是瞬时的。这一可觉察的变更即是我所说的 ”慢“。
为何而更新?
根据上述火焰图,你会看到许多小的凹陷。那不是一个好标志。这意味着许多组件被重绘了。火焰图显示,<Datagrid>
组件更新花费了最多时间。为什么在获取到新数据之前应用会重绘整个数据表呢?让我们来深入探讨。
要理解重绘的原因,通常要借助在 render
函数里添加 console.log()
语句完成。因为函数式的组件,你可以使用如下的单行高阶组件(HOC):
// in src/log.js
const log = BaseComponent => props => {
console.log(`Rendering ${BaseComponent.name}`);
return <BaseComponent {...props} />;
}
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 的改变触发了 <List>
页的重绘,反过来造成了整个 <Datagrid>
组件的重绘。在点击排序按钮后,我们希望 datagrid 表头能够立刻被重绘,作为用户行为的反馈。
使得 React 应用迟缓的通常不是单个慢的组件(在火焰图中反映为一个大的区块)。大多数时候,使 React 应用变慢的是许多组件无用的重绘。 你也许曾读到,React 虚拟 DOM 超级快的言论。那是真的,但在一个中等规模的应用中,全量重绘容易造成成百的组件重绘。甚至最快的虚拟 DOM 模板引擎也不能使这一过程低于 16ms。
切割组件即优化
这是 <Datagrid>
组件的 render()
方法:
// in Datagrid.js
render() {
const { resource, children, ids, data, currentSort } = this.props;
return (
<table>
<thead>
<tr>
{React.Children.map(children, (field, index) => (
<DatagridHeaderCell key={index} field={field} currentSort={currentSort} updateSort={this.updateSort}
/>
))}
</tr>
</thead>
<tbody>
{ids.map(id => (
<tr key={id}>
{React.Children.map(children, (field, index) => (
<DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
))}
</tr>
))}
</tbody>
</table>
);
}
这看起来是一个非常简单的 datagrid 的实现,然而这 非常低效。每个 <DatagridCell>
调用会渲染至少两到三个组件。正如你在初次界面截图里看到的,这个表有 7 列,11 行,即 7x11x3 = 231 个组件会重新渲染。仅仅是 currentSort
的改变时,这简直是浪费时间。虽然在虚拟 DOM 没有更新的情况下,React 不会更新真实DOM,所有组件的处理也会耗费 500ms。
为了避免无用的表体渲染,第一步就是把它 抽取 出来:
// in Datagrid.js
render() {
const { resource, children, ids, data, currentSort } = this.props;
return (
<table>
<thead>
<tr>
{React.Children.map(children, (field, index) => (
<DatagridHeaderCell key={index} field={field} currentSort={currentSort} updateSort={this.updateSort}
/>
))}
</tr>
</thead>
<DatagridBody resource={resource} ids={ids} data={data}>
{children}
</DatagridBody>
</table>
);
);
}
通过抽取表体逻辑,我创建了新的 <DatagridBody>
组件:
// in DatagridBody.js
import React from 'react';
const DatagridBody = ({ resource, ids, data, children }) => (
<tbody>
{ids.map(id => (
<tr key={id}>
{React.Children.map(children, (field, index) => (
<DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
))}
</tr>
))}
</tbody>
);
export default DatagridBody;
抽取表体对性能上毫无影响,但它反映了一条优化之路。庞大的,通用的组件优化起来有难度。小的,单一职责的组件更容易处理。
shouldComponentUpdate
React 文档 里对于避免无用的重绘有非常明确的方法:shouldComponentUpdate()
。默认的,React 一直重绘 组件到虚拟 DOM 中。换句话说,作为开发者,在那种情况下,检查 props 没有改变的组件和跳过绘制都是你的工作。
以上述 <DatagridBody>
组件为例,除非 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 (
<tbody>
{ids.map(id => (
<tr key={id}>
{React.Children.map(children, (field, index) => (
<DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
))}
</tr>
))}
</tbody>
);
}
}
export default DatagridBody;
小提示:相比手工实现 shouldComponentUpdate()
方法,我可以继承 React 的 PureComponent
而不是 Component
。这个组件会用严格对等(===
)对比所有的 props,并且仅当 任一 props 变更时重绘。但是我知道在例子的上下文中 resource
和children
不会变更,所以无需检查他们的对等性。
有了这一优化,点击表头后,<Datagrid>
组件的重绘会跳过表体及其全部 231 个组件。这会将 500ms 的更新时间减少到 60ms。网络性能提高超过 400ms!
小提示:别被火焰图的宽度骗了,比前一个火焰图而言,它放大了。这幅火焰图显示的性能绝对是最好的!
shouldComponentUpdate
优化在图中去掉了许多凹坑,并减少了整体渲染时间。我会用同样的方法避免更多的重绘(例如:避免重绘 sidebar,操作按钮,没有变化的表头和页码)。一个小时的工作之后, 点击表头的列后,整个页面的渲染时间仅仅是 100ms。那相当快了 - 即使仍然存在优化空间。
添加一个 shouldComponentUpdate
方法也许似乎很麻烦,但如果你真的在乎性能,你所写的大多数组件都应该加上。
别哪里都加上 shouldComponentUpdate
- 在简单组件上执行 shouldComponentUpdate
方法有时比仅渲染组件要耗时。也别在应用的早期使用 - 这将过早地进行优化。但随着应用的壮大,你会发现组件上的性能瓶颈,此时才添加shouldComponentUpdate
逻辑保持快速地运行。
重组
我不是很满意之前在 <DatagridBody>
上的改造:由于使用了 shouldComponentUpdate
,我不得不改造成简单的基于类的函数式组件。这增加了许多行代码,每一行代码都要耗费精力 - 去写,调试和维护。
幸运的是,得益于 recompose,你能够在高阶组件(HOC)上实现 shouldComponentUpdate
的逻辑。它是一个 React 的函数式工具,提供 pure()
高阶实例。
// in DatagridBody.js
import React from 'react';
import pure from 'recompose/pure';
const DatagridBody = ({ resource, ids, data, children }) => (
<tbody>
{ids.map(id => (
<tr key={id}>
{React.Children.map(children, (field, index) => (
<DatagridCell record={data[id]} key={`${id}-${index}`} field={field} resource={resource} />
))}
</tr>
))}
</tbody>
);
export default pure(DatagridBody);
这段代码与上述的初始实现仅有的差异是:我导出了 pure(DatagridBody)
而非 DatagridBody
。pure
就像PureComponent
,但是没有额外的类模板。
当使用 recompose
的 shouldUpdate()
而不是 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 中的 <List>
组件为例。它用以下代码从 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
执行,它会返回一个新的对象,即使底层对象没有被改变。结果,<List>
组件每次都会重绘,即使只有 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);
现在 <List>
组件仅在 state 的子集改变时重绘。
作为重组问题,reselect selector 是纯函数,易于测试和组合。它是为 Redux connected 组件编写 selector 的最佳方式。
当心 JSX 中的对象字面量
当你的组件变得更 “纯” 时,你开始检测导致无用重绘坏模式。最常见的是 JSX 中对象字面量的使用,我更喜欢称之为 "臭名昭著的 {{"。请允许我举例说明:
import React from 'react';
import MyTableComponent from './MyTableComponent';
const Datagrid = (props) => (
<MyTableComponent style={{ marginTop: 10 }}>
...
</MyTableComponent>
)
每次 <Datagrid>
组件重绘,<MyTableComponent>
组件的 style
属性都会得到一个新值。所以即使 <MyTableComponent>
是纯的,每次 <Datagrid>
重绘时它也会跟着重绘。事实上,每次把对象字面量当做属性值传递到子组件时,你就打破了纯函数。解法很简单:
import React from 'react';
import MyTableComponent from './MyTableComponent';
const tableStyle = { marginTop: 10 };
const Datagrid = (props) => (
<MyTableComponent style={tableStyle}>
...
</MyTableComponent>
)
这看起来很基础,但是我见过太多次这个错误,因而生成了检测臭名昭著的 {{
的敏锐直觉。我把他们一律替换成常量。
另一个常用来劫持纯函数的 suspect 是 React.cloneElement()
。如果你把 prop 值作为第二参数传入方法,每次渲染就会生成一个带新 props 的新 clone 组件。
// bad
const MyComponent = (props) => <div>{React.cloneElement(Foo, { bar: 1 })}</div>;
// good
const additionalProps = { bar: 1 };
const MyComponent = (props) => <div>{React.cloneElement(Foo, additionalProps)}</div>;
material-ui 已经困扰了我一段时间,举例如下:
import { CardActions } from 'material-ui/Card';
import { CreateButton, RefreshButton } from 'admin-on-rest';
const Toolbar = ({ basePath, refresh }) => (
<CardActions>
<CreateButton basePath={basePath} />
<RefreshButton refresh={refresh} />
</CardActions>
);
export default Toolbar;
尽管 <CreateButton>
是纯函数,但每次 <Toolbar>
绘制它也会绘制。那是因为 material-ui 的 <CardActions>
添加了一个特殊 style,为了使第一个子节点适应 margin - 它用了一个对象字面量来做这件事。所以 <CreateButton>
每次都收到不同的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()
调用。别一开始就做优化,别花费过多时间在每个组件的过度优化上 - 除非你是在移动端。记住在不同设备进行测试,让用户对应用的响应式有良好印象。