React + Redux 性能优化(一):理论篇

本文的叙事线索与代码示例均来自High Performance Redux,特此表示感谢。之因此感谢是由于最近一直想系统的整理在 React + Redux 技术栈下的性能优化方案,但苦于找不到切入点。在查阅资料的过程当中,这份 Presentation 给了我很大的启发,它的不少观点一针见血,也与个人想法不谋而合。因而这篇文章也是参照它的讲解线索来依次展开我想表达的知识点javascript

或许你已经据说过不少的第三方优化方案,好比immutable.jsreselectreact-virtualized等等,有关工具的故事下一篇再详谈。首先咱们须要了解的是为何会出现性能问题,以及解决性能问题的思路是什么。当你了解完这一切以后,你会发现其实不少性能问题不须要用第三方类库解决,只须要在编写代码中稍加注意,或者稍稍调整数据结构就能有很大的改观。前端

性能不是 patch,是 feature

每一个人对性能都有本身的理解。其中有一种观点认为,在程序开发的初期不须要关心性能,当程序规模变大而且出现瓶颈以后再来作性能的优化。我不一样意这种观点。性能不该该是后来居上的补丁,而应该是程序天生的一部分。从项目的第一天起,咱们就应该考虑作一个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

  1. 列表每一项(item)的数据结构是{ id, marked }
  2. 列表(items)的数据结构是数组类型:[{id1, marked}, {id2, marked}, {id3, marked}]
  3. App渲染列表是经过遍历(map)列表数组items实现的
  4. 当用户点击某一项时,把被点击项的id传递给item的 reducer,reducer 经过遍历 items,挨个对比id的方式找到须要被标记的项
  5. 从新标记完以后将新的数组返回
  6. 新的数组返回给AppApp再次进行渲染

若是你无法将以上代码片断和我叙述的事实拼凑在一块儿,能够在 github 上找到完整代码浏览或者运行。web

对于这样的一个需求,相信绝大多数人的代码都是这么写的。chrome

可是上述代码没有告诉你的事实时,这的性能不好。当你尝试点击某个选项时,选项的高亮会延迟至少半秒秒钟,用户会感受到列表响应变慢了。

这样的延迟值并非绝对:

  1. 这样的现象只有在列表项数目众多的状况下出现,好比说 10k。
  2. 在开发环境(ENV === 'development')下运行的代码会比在生产环境(ENV === 'production')下运行较慢
  3. 我我的 PC 的 CPU 配置是 1700x,不一样电脑配置的延迟会有所不一样

诊断

那么问题出在哪里?咱们经过 Chrome 开发者工具一探究竟(还有不少其余的 React 相关的性能工具一样也能洞察性能问题,好比 react-addons-perf, why-did-you-updateReact Developer Tools 等等。但都存在或多或少的存在缺陷,使用 Chrome 开发者工具是最靠谱的)

  • 本地启动项目, 打开 Chrome 浏览器,在地址栏以访问项目地址加上react_perf后缀的方式访问项目页面,好比个人项目地址是: http://localhost:3000/ 的话,实际请访问 http://localhost:8080/?react_perf 。加上react_perf后缀的用意是启用 React 中的性能埋点,这些埋点用于统计 React 中某些操做的耗时,使用User Timing API实现
  • 打开 Chrome 开发者工具,切换到 performance 面板
  • 点击 performance 面板左上角的“录制”按钮,开始录制性能信息

  • 点击列表中的任意一项
  • 等被点击项进入高亮状态时,点击“stop”按钮中止录制性能信息
  • 接下来你就能看到点击阶段的性能大盘信息:

咱们把目光聚焦到 CPU 活动最剧烈的那段时间内,

从图表中能够看出,这部分的时间(712ms)消耗基本是由脚本引发的,准确来讲是由点击事件执行的脚本引发的,而且从函数的调用栈以及从时间排序中能够看出,时间基本上花费在updateComponent函数中。

这已经能猜出一二,若是你还不肯定这个函数究竟干了什么,不如展开User Timing一栏看看更“通俗”的时间消耗

原来时间都花费在App组件的更新上,每一次App组件的更新,意味着每个Item组件也都要更新,意味着每个Item都要被从新渲染(执行render函数)

若是你依然以为对以上说法表示怀疑,或者说不可思议,能够直接在App组件的render函数和Item组件的render函数加上console.log。那么每次点击时,你会看到App里的consoleItem里的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信息都存在数组结构里,数组结构的一个重要特性是保证了访问数据的顺序一致性。如今咱们把数据拆分为两部分

  1. 数组结构ids:只保留 id 用于记录数据顺序,好比:[id1, id2, id3]
  2. 字典(对象)结构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 复制代码

PureComponentComponent不一样在于它已经为你实现了shouldComponentUpdate生命周期函数,而且在函数对改变先后的 props 和 state 作了“浅对比”(shallow comparison),这里的“浅”和“浅拷贝”里的浅是同一个概念,即比较引用,而不比较嵌套对象里更深层次的值。话说回来 React 也没法为你比较嵌套更深的值,一方面这也耗时的操做,违背了shouldComponentUpdate的初衷,另外一方面复杂的状态下决定是否从新渲染组件也会有复杂的规则,简单的比较是否发生了更改并不稳当

反面教材(anti-pattern)

残酷的现实是,即便你理解了以上的知识点,你可能仍然对平常代码中的性能陷阱浑然不知,

好比设置缺省值的时候:

<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)

结尾

下一篇咱们学习如何借助第三方类库,好比immutablejsreselect对项目进行优化

这篇文章同时也发表在个人 知乎前端专栏,欢迎你们关注

参考资料

相关文章
相关标签/搜索