本文的叙事线索与代码示例均来自High Performance Redux,特此表示感谢。之因此感谢是由于最近一直想系统的整理在 React + Redux 技术栈下的性能优化方案,但苦于找不到切入点。在查阅资料的过程当中,这份 Presentation 给了我很大的启发,它的不少观点一针见血,也与个人想法不谋而合。因而这篇文章也是参照它的讲解线索来依次展开我想表达的知识点javascript
或许你已经据说过不少的第三方优化方案,好比immutable.js
,reselect
,react-virtualized
等等,有关工具的故事下一篇再详谈。首先咱们须要了解的是为何会出现性能问题,以及解决性能问题的思路是什么。当你了解完这一切以后,你会发现其实不少性能问题不须要用第三方类库解决,只须要在编写代码中稍加注意,或者稍稍调整数据结构就能有很大的改观。前端
每一个人对性能都有本身的理解。其中有一种观点认为,在程序开发的初期不须要关心性能,当程序规模变大而且出现瓶颈以后再来作性能的优化。我不一样意这种观点。性能不该该是后来居上的补丁,而应该是程序天生的一部分。从项目的第一天起,咱们就应该考虑作一个10x project:即可以运行 10k 个任务而且拥有 10 年寿命java
退一步说即便你在项目的后期发现了瓶颈问题,公司层面不必定会给你足够的排期解决这个问题,毕竟业务项目依然是优先的(仍是要看这个性能问题有多“痛”);再退一步说,即便容许你展优化工做,通过长时间迭代开发后的项目已经和当初相比面目全非了:模块数量庞大,代码耦合严重,尤为是 Redux 项目牵一发而动全身,再想对代码进行优化的话会很是困难。从这个意义上来讲,从一开始就将性能考虑进产品中去也是一种 future-proof 的体现,提升代码的可维护性node
从另外一个角度看,代码性能也是我的编程技艺的体现,一位优秀的程序员的代码性能应当是有保障的。react
前端框架喜欢把实现 Todo List 做为给新手的教程。咱们这里也拿一个 List 举例。假设你须要实现一个列表,用户点击有高亮效果仅此而已。特别的地方在于这个列表有 10k 的行,是的,你没看错 10k 行(上面不是说好咱们要作 10x project 吗:p)git
首先咱们看一看基本款代码,由App
组件和Item
组件构成,关键代码以下:程序员
function itemsReducer(state = initial_state, action) {
switch (action.type) {
case "MARK":
return state.map(
item =>
action.id === item.id ? { ...item, marked: !item.marked } : item
);
default:
return state;
}
}
class App extends Component {
render() {
const { items, markItem } = this.props;
return (
<div> {items.map(({ id, marked }) => ( <Item key={id} id={id} marked={marked} onClick={markItem} /> ))} </div> ); } } function mapStateToProps(state) { return state; } const markItem = id => ({ type: "MARK", id }); export default connect(mapStateToProps, { markItem })(App); 复制代码
这段关键的代码体现了几个关键的事实:github
item
)的数据结构是{ id, marked }
items
)的数据结构是数组类型:[{id1, marked}, {id2, marked}, {id3, marked}]
App
渲染列表是经过遍历(map
)列表数组items
实现的id
传递给item
的 reducer,reducer 经过遍历 items
,挨个对比id
的方式找到须要被标记的项App
,App
再次进行渲染若是你无法将以上代码片断和我叙述的事实拼凑在一块儿,能够在 github 上找到完整代码浏览或者运行。web
对于这样的一个需求,相信绝大多数人的代码都是这么写的。chrome
可是上述代码没有告诉你的事实时,这的性能不好。当你尝试点击某个选项时,选项的高亮会延迟至少半秒秒钟,用户会感受到列表响应变慢了。
这样的延迟值并非绝对:
ENV === 'development'
)下运行的代码会比在生产环境(ENV === 'production'
)下运行较慢那么问题出在哪里?咱们经过 Chrome 开发者工具一探究竟(还有不少其余的 React 相关的性能工具一样也能洞察性能问题,好比 react-addons-perf, why-did-you-update,React Developer Tools 等等。但都存在或多或少的存在缺陷,使用 Chrome 开发者工具是最靠谱的)
react_perf
后缀的方式访问项目页面,好比个人项目地址是: http://localhost:3000/ 的话,实际请访问 http://localhost:8080/?react_perf 。加上react_perf
后缀的用意是启用 React 中的性能埋点,这些埋点用于统计 React 中某些操做的耗时,使用User Timing API
实现咱们把目光聚焦到 CPU 活动最剧烈的那段时间内,
从图表中能够看出,这部分的时间(712ms)消耗基本是由脚本引发的,准确来讲是由点击事件执行的脚本引发的,而且从函数的调用栈以及从时间排序中能够看出,时间基本上花费在updateComponent
函数中。
这已经能猜出一二,若是你还不肯定这个函数究竟干了什么,不如展开User Timing
一栏看看更“通俗”的时间消耗
原来时间都花费在App
组件的更新上,每一次App
组件的更新,意味着每个Item
组件也都要更新,意味着每个Item
都要被从新渲染(执行render
函数)
若是你依然以为对以上说法表示怀疑,或者说不可思议,能够直接在App
组件的render
函数和Item
组件的render
函数加上console.log
。那么每次点击时,你会看到App
里的console
和Item
里的console
都调用了 10k 次。注意此时页面会响应的更慢了,由于在控制台输出 10k 次console.log
也是须要代价的
更重要的知识点在于,只要组件的状态(props
或者state
)发生了更改,那么组件就会默认执行render
函数从新进行渲染(你也能够经过重写shouldComponentUpdate
手动阻止这件事的发生,这是后面会提到的优化点)。同时要注意的事情是,执行render
函数并不意味着浏览器中的真实 DOM 树须要修改。浏览器中的真实 DOM 是否须要发生修改,是由 React 最后比较 Virtual Tree 决定的。 咱们都知道修改浏览器中的真实 DOM 是很是耗费性能的一件事,因而 React 为咱们作出了优化。可是执行render
的代价仍然须要咱们本身承担
因此在这个例子中,每一次点击列表项时,都会引发 store 中items
状态的更改,而且返回的items
状态老是新的数组,也就形成了每次点击事后传递给App
组件的属性都是新的
请记住下面这个公式
UI = f(state)
你在页面上所见的,都是对状态的映射。反过来讲,只要组件状态或者传递给组件的属性没有发生改变,那么组件也不会从新进行渲染。咱们能够利用这一点阻止App
的渲染,只要保证转递给App
组件的属性不会发生改变便可。毕竟只修改一条列表项的数据却结果形成了其余 9999 条数据的从新渲染是不合理的。
可是应该如何作才能保证修改数据的同时传递给App
的数据不发生变化?
经过更改数据结构
本来全部的items
信息都存在数组结构里,数组结构的一个重要特性是保证了访问数据的顺序一致性。如今咱们把数据拆分为两部分
ids
:只保留 id 用于记录数据顺序,好比:[id1, id2, id3]
items
:以key-value
的形式记录每一个数据项的具体信息:{id1: {marked: false}, id2: {marked: false}}
关键代码以下:
function ids(state = [], action) {
return state;
}
function items(state = {}, action) {
switch (action.type) {
case "MARK":
const item = state[action.id];
return {
...state,
[action.id]: { ...item, marked: !item.marked }
};
default:
return state;
}
}
function itemsReducer(state = {}, action) {
return {
ids: ids(state.ids, action),
items: items(state.items, action)
};
}
const store = createStore(itemsReducer);
class App extends Component {
render() {
const { ids } = this.props;
return (
<div> {ids.map(id => { return <Item key={id} id={id} />; })} </div> ); } } // App.js: function mapStateToProps(state) { return { ids: state.ids }; } // Item.js function mapStateToProps(state, props) { const { id } = props; const { items } = state; return { item: items[id] }; } const markItem = id => ({ type: "MARK", id }); export default connect(mapStateToProps, { markItem })(Item); 复制代码
在这种思惟模式下,Item
组件直接与 Store 相连,每次点击时经过 id 直接找到items
状态字典中的信息进行修改。由于App
只关心ids
状态,而在这个需求中不涉及增删改,因此ids
状态永远不会发生改变,在Mounted
以后,App
不再会更新了。因此如今不管你如何点击列表项,只有被点击的列表项会更新。
不少年前我写过一篇文章:《在 Node.js 中搭建缓存管理模块》,里面提到过相同的解决思路,有更详细的叙述
在这一小节的结尾我要告诉你们一个坏消息:虽然咱们能够精心设计状态的数据结构,但在实际工做中用来展现数据的控件,好比表格或者列表,都有各自独立的数据结构的要求,因此最终的优化效果并不是是理想状态
让咱们回到最初发生事故的代码,它的问题在于每次在渲染须要高亮的代码时,无需高亮的代码也被渲染了一遍。若是能避免这些无辜代码的渲染,那么一样也是一种性能上的提高。
你确定已经知道在 React 组件生命周期就存在这样一个函数 shoudlComponentUpdate
能够决定是否继续渲染,默认状况下它返回true
,即始终要从新渲染,你也能够重写它让它返回false
,阻止渲染。
利用这个生命周期函数,咱们限定只容许marked
属性发生先后发生变动的组件进行从新渲染:
class Item extends Component {
constructor() {
//...
}
shouldComponentUpdate(nextProps) {
if (this.props["marked"] === nextProps["marked"]) {
return false;
}
return true;
}
复制代码
虽然每次点击时App
组件仍然会从新渲染,可是成功阻止了其余 9999 个Item
组件的渲染
事实上 React 已经为咱们实现了相似的机制。你能够不重写shouldComponentUpdate
, 而是选择继承React.PureComponent
:
class Item extends React.PureComponent 复制代码
PureComponent
与Component
不一样在于它已经为你实现了shouldComponentUpdate
生命周期函数,而且在函数对改变先后的 props 和 state 作了“浅对比”(shallow comparison),这里的“浅”和“浅拷贝”里的浅是同一个概念,即比较引用,而不比较嵌套对象里更深层次的值。话说回来 React 也没法为你比较嵌套更深的值,一方面这也耗时的操做,违背了shouldComponentUpdate
的初衷,另外一方面复杂的状态下决定是否从新渲染组件也会有复杂的规则,简单的比较是否发生了更改并不稳当
残酷的现实是,即便你理解了以上的知识点,你可能仍然对平常代码中的性能陷阱浑然不知,
好比设置缺省值的时候:
<RadioGroup options={this.props.options || []} />
复制代码
若是每次 this.props.options
值都是 null
的话,意味着每次传递给<RadioGroup />
都是字面量数组[]
,但字面量数组和new Array()
效果是同样的,始终生成新的实例,因此表面上看虽然每次传递给组件的都是相同的空数组,其实对组件来讲每次都是新的属性,都会引发渲染。因此正确的方式应该将一些经常使用值以变量的形式保存下来:
const DEFAULT_OPTIONS = []
<RadioGroup options={this.props.options || DEFAULT_OPTIONS} />
复制代码
又好比给事件绑定函数的时候
<Button onClick={this.update.bind(this)} />
复制代码
或者
<Button
onClick={() => {
console.log("Click");
}}
/>
复制代码
在这两种状况下,对于组件来讲每次绑定的都是新的函数,因此也会形成从新渲染。关于如何在eslint
中加入对.bind
方法和箭头函数的检测,以及解决之道请参考No .bind() or Arrow Functions in JSX Props (react/jsx-no-bind)
下一篇咱们学习如何借助第三方类库,好比immutablejs
和reselect
对项目进行优化
这篇文章同时也发表在个人 知乎前端专栏,欢迎你们关注