建议在阅读完上一篇React + Redux 性能优化(一):理论篇以后再开始本文的旅程,本文的不少概念和结论,都在上篇作了详细的讲解javascript
这会是一篇长文,咱们首先会讨论使用 Immutable Data 的正当性;而后从功能上和性能上研究使用 Immutablejs 的技术的必要性html
我猜你更关心的是是否值得使用 Immutablejs,这里先放上结论:推荐使用;但不必定必须使用。若是推荐指数最低一分最高十分的话,那么打六分。前端
不管是在 react 仍是 redux 中,pure 都是很是重要的概念。理解什么是 pure 有助于咱们理解咱们为何须要 Immutablejsjava
首先咱们要介绍什么是Pure function (纯函数), 来自维基百科::react
在程序设计中,若一个函数符合如下要求,则它可能被认为是纯函数:git
- 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值之外的其余隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
- 该函数不能有语义上可观察的函数反作用,诸如“触发事件”,使输出设备输出,或更改输出值之外物件的内容等。
简单来讲纯函数的两个特征:1) 对于相同的输入总有相同的输出;2) 函数不依赖外部变量,也不会对外部产生影响(这种影响称之为“反作用(side effects)”)github
redux 中规定 reducer 就是纯函数。它接收前一个 state 状态和 action 做为参数,返回下一个状态:redux
(previousState, action) => newState
复制代码
保证 reducer 的“纯粹(pure)”很是重要,你永远不能在 reducer 中作如下三件事:api
Math.random()
或者Date.now()
因此你会看到在 reducer 里返回状态是经过Object.assign({}, state)
实现的(注意不要写成Object.assign(state)
这样就修改了原状态)。而至于调用 API 等异步或者具备“反作用”的操做,则能够借助于redux-thunk
或者redux-saga
。性能优化
在上一篇中咱们谈到过 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
,即对于惟一的props
和state
有惟一的渲染结果。 因此react-redux
首先会对根状态(即上述代码中mapStateToProps
的第一个形参state
)建立索引,进行浅对比,若是对比结果一致则不对组件进行从新渲染,不然继续调用mapStateToProps
函数;同时继续对mapStateToProps
返回的props
对象里的每个属性的值(即上述代码中的state.todos
值和getVisibleTodos(state)
值,而不是返回的props
整个对象)建立索引。和shouldComponentUpdate
相似,只有当浅对比失败,即索引起生更改时才会从新对封装的组件进行渲染
就上面的代码例子来讲,只要state.todos
和getVisibleTodos(state)
的值不发生更改,那么App
组件就永远不会再一次进行渲染。可是请注意下面的陷阱模式:
function mapStateToProps(state) {
return {
data: {
todos: state.todos,
visibleTodos: getVisibleTodos(state),
}
}
}
复制代码
即便state.todos
和getVisibleTodos(state)
一样再也不发生变化,可是由于每次mapStateToProps
返回结果{ data: {...} }
中的data
都建立新的(字面量)对象,致使浅对比老是失败,App
依然会再次渲染
其次是在 combineReducers
中。
咱们都知道 Redux Store 鼓励咱们把状态对象划分为不一样的碎片(slice)或者领域(domain,也能够理解为业务),而且为这些不一样的领域分别编写 reducer 函数用于管理它们的状态,最后使用官方提供的combineReducers
函数将这些领域以及它们的 reducer 函数关联起来,拼装成一个总体的state
举个例子
combineReducers({ todos: myTodosReducer, counter: myCounterReducer })
复制代码
上述代码中,程序的状态是由{ todos, counter }
两个领域模型组成,同时myTodosReducer
与myCounterReducer
分别为各自领域的 reducer 函数
combineReducers
会遍历每一“对”领域(key是领域名称、value是领域 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
以后都有自增,可是组件却始终不会渲染出来
结合以上两个知识点,不管是从 reducer 的定义上,仍是从 redux 的工做机制上,咱们都走上了同一条Object.assign
的模式,即不修改原状态,只返回新状态。可见 state 天生就是不可被更改的(Immutable)
可是使用Object.assign
的方法却不能算优雅,甚至有 hack 的嫌疑,毕竟Object.assign
的本意是用来复制一个对象的属性到另外一个对象的。因而咱们在这里引入 Immutablejs,它为咱们实现了几类“不可更改”的数据结构,好比Map
,List
,咱们举几个使用的例子。
好比咱们须要建立一个空对象,这里使用 Immutablejs 中的 Map
数据结构:
import {Map} from 'immutable'
const person = Map()
复制代码
好像没有什么特别的。接下来咱们想给这个person
实例添加age
属性,这里须要使用Map
自带的set
方法:
const personWithAge = person.set('age', 20)
复制代码
接下来咱们把person
和personWithAge
打印出来:
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
的使用场景是契合的。那么当咱们须要修改state
而state
是 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,不得不提用于实现它的数据结构,这经常是被认为它性能高于原生对象的论据之一。这一小节的部分直接翻译自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 倍!
可是你有没有看出这个测试的问题:
fromJS
)或者从 Immutablejs 转化为原生对象时(toJS
)也是须要代价的。若是你在fromJS
的先后记录时间,你会发现时间大约是 300ms。你没法避免转化,由于第三方组件或者老旧代码颇有可能不支持 Immutablejs因此综上,使用 Immutablejs 会带来性能上的提高,但性能并不会很是明显,同时还会有兼容性问题
我还有其余的一些关于性能的的测试放在 github 上,测试过程当中也有一些很好玩的发现,就不一一赘述了。有兴趣的朋友能够拿去跑一跑,由于是一次性的之后不会再维护了,因此代码写得比较烂,请见谅
react-router-redux
就不支持 Immutablejs,你须要的不只仅是fromJS
和toJS
,还须要额外的代码去支持它。其实关于 Immutablejs 还有不少的话题能够聊,好比最佳实践注意事项什么的。鉴于篇幅有限就先聊到这里。有机会再继续
这篇文章同时也发表在个人知乎前端专栏,欢迎你们关注