React + Redux 性能优化(二)工具篇: Immutablejs

建议在阅读完上一篇React + Redux 性能优化(一):理论篇以后再开始本文的旅程,本文的不少概念和结论,都在上篇作了详细的讲解javascript

这会是一篇长文,咱们首先会讨论使用 Immutable Data 的正当性;而后从功能上和性能上研究使用 Immutablejs 的技术的必要性html

我猜你更关心的是是否值得使用 Immutablejs,这里先放上结论:推荐使用;但不必定必须使用。若是推荐指数最低一分最高十分的话,那么打六分。前端

关于 Pure

不管是在 react 仍是 redux 中,pure 都是很是重要的概念。理解什么是 pure 有助于咱们理解咱们为何须要 Immutablejsjava

首先咱们要介绍什么是Pure function (纯函数), 来自维基百科:react

在程序设计中,若一个函数符合如下要求,则它可能被认为是纯函数:git

  • 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值之外的其余隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
  • 该函数不能有语义上可观察的函数反作用,诸如“触发事件”,使输出设备输出,或更改输出值之外物件的内容等。

简单来讲纯函数的两个特征:1) 对于相同的输入总有相同的输出;2) 函数不依赖外部变量,也不会对外部产生影响(这种影响称之为“反作用(side effects)”)github

Reducer

redux 中规定 reducer 就是纯函数。它接收前一个 state 状态和 action 做为参数,返回下一个状态:redux

(previousState, action) => newState
复制代码

保证 reducer 的“纯粹(pure)”很是重要,你永远不能在 reducer 中作如下三件事:api

  • 修改参数
  • 执行任何具备反作用的操做,好比调用 API
  • 调用任何不纯粹的函数,好比Math.random()或者Date.now()

因此你会看到在 reducer 里返回状态是经过Object.assign({}, state)实现的(注意不要写成Object.assign(state)这样就修改了原状态)。而至于调用 API 等异步或者具备“反作用”的操做,则能够借助于redux-thunk或者redux-saga性能优化

Pure Component

在上一篇中咱们谈到过 Pure Component,准确说那是狭义上的React.PureComponent。广义上的 Pure Compnoent 指的是 Stateless Component,也就是无状态组件,也被称为 Dumb Component、 Presentational Component。从代码上它的特征是 1) 不维护本身的状态,2) 只有render函数:

const HelloUser = ({userName}) => {
  return <div>{`Hello ${userName}`}</div>
}
复制代码

显而易见的是,这种形式的“纯组件”和“纯函数”有殊途同归之妙,即对于相同的属性传入,组件老是输出惟一的结果。

固然这样形式的组件也丧失了一部分的能力,例如再也不拥有生命周期函数。

性能

上篇中咱们得出的一个很重要的结论是,只要组件的状态(props或者state)发生了改变,那么组件就会执行render函数进行从新渲染。除非你重写shouldComponentUpdate周期函数经过返回false来阻止这件事的发生;又或者直接让组件直接继承PureComponent

而继承PureComponent的原理也很简单,它只不过代替你实现了shouldComponentUpdate函数:在函数内对如今和过去的props/state进行“浅对比”(shallow comparision,即仅仅是比较对象的引用而不是比较对象每一个属性的值),若是发现对象先后没有改变则不执行render函数对组件进行从新渲染

其实这样一套类似逻辑在 Redux 中也屡次存在,在 redux 中也会对数据进行“浅对比”

首先是在react-redux

咱们一般会使用react-redux中的connect函数将程序状态注入进组件中,例如:

import {conenct} from 'react-redux'

function mapStateToProps(state) {
  return {
    todos: state.todos,
    visibleTodos: getVisibleTodos(state),
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(App)
复制代码

代码中组件App是被 react-redux 封装的组件,react-redux会假设App是一个Pure Component,即对于惟一的propsstate有惟一的渲染结果。 因此react-redux首先会对根状态(即上述代码中mapStateToProps的第一个形参state)建立索引,进行浅对比,若是对比结果一致则不对组件进行从新渲染,不然继续调用mapStateToProps函数;同时继续对mapStateToProps返回的props对象里的每个属性的值(即上述代码中的state.todos值和getVisibleTodos(state)值,而不是返回的props整个对象)建立索引。和shouldComponentUpdate相似,只有当浅对比失败,即索引起生更改时才会从新对封装的组件进行渲染

就上面的代码例子来讲,只要state.todosgetVisibleTodos(state)的值不发生更改,那么App组件就永远不会再一次进行渲染。可是请注意下面的陷阱模式:

function mapStateToProps(state) {
  return {
    data: {
      todos: state.todos,
      visibleTodos: getVisibleTodos(state),
    }
  }
}
复制代码

即便state.todosgetVisibleTodos(state)一样再也不发生变化,可是由于每次mapStateToProps返回结果{ data: {...} }中的data都建立新的(字面量)对象,致使浅对比老是失败,App依然会再次渲染

其次是在 combineReducers 中。

咱们都知道 Redux Store 鼓励咱们把状态对象划分为不一样的碎片(slice)或者领域(domain,也能够理解为业务),而且为这些不一样的领域分别编写 reducer 函数用于管理它们的状态,最后使用官方提供的combineReducers函数将这些领域以及它们的 reducer 函数关联起来,拼装成一个总体的state

举个例子

combineReducers({ todos: myTodosReducer, counter: myCounterReducer })
复制代码

上述代码中,程序的状态是由{ todos, counter }两个领域模型组成,同时myTodosReducermyCounterReducer分别为各自领域的 reducer 函数

combineReducers会遍历每一“对”领域(key是领域名称、value是领域 reducer 函数),对于每一次遍历:

  • 它会建立一个对当前碎片数据的引用
  • 调用 reducer 函数计算碎片数据新的状态,而且返回
  • 为 reducer 函数返回的新的碎片数据建立新的引用,将新的引用和当前数据引用进行浅对比,若是对比失败了(同时意味着两次引用不一致,意味着 reducer 返回的是一个新的对象),那么将标识位hasChanged设置为true

在通过一轮(这里的一轮指的是把每个领域都遍历了一遍)遍历以后,combineReducer就获得了一个新的状态对象,经过hasChanged标识位咱们就能判断出总体状态是否发生了更改,若是为true,新的状态就会被返回给下游,若是是false,旧的当前状态就会被返回给下游。这里的下游指的是react-redux以及更下游的界面组件。

咱们已经知道了react-redux会对根状态进行浅对比,若是引用发生了改变,才从新渲染组件。因此当状态须要发生更改时,务必让相应的 reducer 函数始终返回新的对象!修改原有对象的属性值而后返回不会触发组件的从新渲染!

因此咱们常看到的 reducer 函数写法是最终经过 Object.assign 复制原状态对象而且返回一个新的对象:

function myCounterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case "add":
      return Object.assign({}, state, { count: state.count + 1 });
    default:
      return state;
  }
}
复制代码

错误的作法是仅仅修改原对象:

function myCounterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case "add":
      state.count++
      return state
    default:
      return state;
  }
}
复制代码

有趣的事情是若是你此时在state.count++以后打印 state 的结果,你会发现state.count确实在每次add以后都有自增,可是组件却始终不会渲染出来

Immutable Data 和 Immutablejs

结合以上两个知识点,不管是从 reducer 的定义上,仍是从 redux 的工做机制上,咱们都走上了同一条Object.assign的模式,即不修改原状态,只返回新状态。可见 state 天生就是不可被更改的(Immutable)

可是使用Object.assign的方法却不能算优雅,甚至有 hack 的嫌疑,毕竟Object.assign的本意是用来复制一个对象的属性到另外一个对象的。因而咱们在这里引入 Immutablejs,它为咱们实现了几类“不可更改”的数据结构,好比MapList,咱们举几个使用的例子。

好比咱们须要建立一个空对象,这里使用 Immutablejs 中的 Map数据结构:

import {Map} from 'immutable'
const person = Map()
复制代码

好像没有什么特别的。接下来咱们想给这个person实例添加age属性,这里须要使用Map自带的set方法:

const personWithAge = person.set('age', 20)
复制代码

接下来咱们把personpersonWithAge打印出来:

console.log(person.toJS())
console.log(personWithAge.toJS())
复制代码

注意这里不能直接打印person,不然你会获得一个封装以后的数据结构;而是要先调用toJS方法,将Map数据结构转化为普通的原生对象。 此时你获得的结果是:

console.log(person.toJS()) // {}
console.log(personWithAge.toJS()) // { age: 20 }
复制代码

看出问题了吗?咱们想更改person的属性,但person的属性却没有更改,而set方法返回的结果personWithAge倒是咱们想获得的。

也就是说,在 Immutabejs 的数据结构中,当你想更改某个对象属性时,你获得的永远是一个新的对象,而原对象永远也不会发生更改。这与咱们Object.assign的使用场景是契合的。那么当咱们须要修改statestate是 Immutablejs 数据结构时,修改而且返回便可:

function myCounterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case "add":
      return state.set('count', state.get('count') + 1);
    default:
      return state;
  }
}
复制代码

这只是 Immutablejs 的核心功能。基于它本身的封装的数据结构,它还给咱们提供了其余好用的功能,好比.getIn方法或者.setIn方法,又或者能够约束数据结构的Record类型。Immutablejs 的使用技巧能够另说

Immutablejs 实现内幕

提到 Immutablejs,不得不提用于实现它的数据结构,这经常是被认为它性能高于原生对象的论据之一。这一小节的部分直接翻译自Immutable.js, persistent data structures and structural sharing,作了简化和删减

假设你有这样的一个 Javascript 结构对象:

const data = {
  to: 7,
  tea: 3,
  ted: 4,
  ten: 12,
  A: 15,
  i: 11,
  in: 5,
  inn: 9
}
复制代码

能够想象它在 Javscript 内存里的存储结构是这样的:

但咱们还能够根据 key 使用到的字母做为索引,组织成字典查找树的结构:

在这种数据结构中,不管你想访问对象任意属性的值,从根节点出发都可以访问到

当你想修改值时,只须要建立一棵新的字典查找树,而且最大限度的利用已有节点便可

假设此时你想修改 tea 属性的值为14,首先须要找到访问到tea节点的关键路径:

而后将这些节点复制出来,构建一棵一摸同样结构的树,只不过新树的其余的节点均是对原树的引用:

最后将新构建的树的根节点返回

这就是 Immutablejs 中 Map 的基本实现原理,这也固然只是 Immutablejs 的黑科技之一

实战测试

这样的数据结构可以带来多大性能上的提高?咱们实际测试一下:

假设咱们有十万个todos数据,用原生的 Javascript 对象进行存储:

const todos = {
  '1': { title: `Task 1`, completed: false };
  '2': { title: `Task 2`, completed: false };
  '3': { title: `Task 3`, completed: false };
  //...
  '100000': { title: `Task 1`, completed: false };
}
复制代码

或者使用函数生成十万个todos:

function generateTodos() {
  let count = 100000;
  const todos = {};
  while (count) {
    todos[count.toString()] = { title: `Task ${count}`, completed: false };
    count--;
  }
  return todos;
}
复制代码

接下来咱们准备一个 reducer 用于根据 id 切换单个 todo 的 completed 状态:

function toggleTodo(todos, id) {
  return Object.assign({}, todos, {
    [id]: Object.assign({}, todos[id], {
      completed: !todos[id].completed
    })
  });
}
复制代码

接下里咱们测试一下修改单个todo所耗费的时间是多少:

const startTime = performance.now();
const nextState = toggleTodo(todos, String(100000 / 2));
console.log(performance.now() - startTime);
复制代码

在个人PC(配置 1700x ,32GB, Chrome 64.0.3282.186)上执行的时间是 33ms

接下来咱们把toggleTodo换成 Immutablejs 版本(固然数据也要是 Immutablejs 中的Map数据类型,Immutablejs 提供了方法fromJS可以很方便的将原生 Javacript 数据类型转化为 Immutablejs 数据类型)再试试看:

function toggleTodo(todos, id) {
  return todos.set(id, !todos.getIn([id, "completed"]));
}
const startTime = performance.now();
const nextState = toggleTodo(state, String(100000 / 2));
console.log(performance.now() - startTime);
复制代码

执行时间不超过 1ms,快了 30 倍!

可是你有没有看出这个测试的问题:

  • 虽然二者之间相差了30倍,可是最慢也就是 33ms 而已,用户是感受不到的。若是这也算是瓶颈的话,这个瓶颈不会形成太大的问题
  • 1ms vs 33ms 的成绩是在十万个 todo 的状况下测试出来的,但在实际的过程当中,不多的场景会用到这么大的数据量。那若是在一千条数据下原生表现的状况如何呢?原生方法一样不会超过 1ms
  • 咱们只观察到了 Immutablejs 在更改属性时高效,却忘了在原生数据转化为 Immutablejs 时(fromJS)或者从 Immutablejs 转化为原生对象时(toJS)也是须要代价的。若是你在fromJS的先后记录时间,你会发现时间大约是 300ms。你没法避免转化,由于第三方组件或者老旧代码颇有可能不支持 Immutablejs

因此综上,使用 Immutablejs 会带来性能上的提高,但性能并不会很是明显,同时还会有兼容性问题

我还有其余的一些关于性能的的测试放在 github 上,测试过程当中也有一些很好玩的发现,就不一一赘述了。有兴趣的朋友能够拿去跑一跑,由于是一次性的之后不会再维护了,因此代码写得比较烂,请见谅

说一说使用 Immutablejs 可能带来的问题

  • 学习成本。不只仅是你我的的学习成本,整个团队都须要学习如何使用它。最可怕的是在你们都不熟悉可是又不得不使用它的状况下, 很容易的就会引入一些错误实践。这会给代码埋下隐患
  • 兼容性问题,绝大部分第三方代码都不支持这种数据结构,你也没法改造当前项目的每个组件去适应它,因此务必要进行数据格式间的兼容和转化。若是只是在单个组件中使用 Immutablejs 还好,若是你想贯穿于整个应用使用,从 reducer 的 initialState 就开始使用它,那么可能会有更多的问题等着你处理,好比经常使用的react-router-redux就不支持 Immutablejs,你须要的不只仅是fromJStoJS,还须要额外的代码去支持它。

最后

其实关于 Immutablejs 还有不少的话题能够聊,好比最佳实践注意事项什么的。鉴于篇幅有限就先聊到这里。有机会再继续

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

参考文章

相关文章
相关标签/搜索