layout: post title: React + Redux 性能优化(二)工具篇: Immutablejs modified: 2018-03-21 tags: [javascript, React, Redux, performance] image: feature: abstract-3.jpg credit: dargadgetz creditlink: http://www.dargadgetz.com/ios-7-abstract-wallpaper-pack-for-iphone-5-and-ipod-touch-retina/ comments: true
建议在阅读完上一篇React + Redux 性能优化(一):理论篇之后再开始本文的旅程,本文的很多概念和结论,都在上篇做了详细的讲解
这会是一篇长文,我们首先会讨论使用 Immutable Data 的正当性;然后从功能上和性能上研究使用 Immutablejs 的技术的必要性
我猜你更关心的是是否值得使用 Immutablejs,这里先放上结论:推荐使用;但不一定必须使用。如果推荐指数最低一分最高十分的话,那么打六分。
无论是在 react 还是 redux 中,pure 都是非常重要的概念。理解什么是 pure 有助于我们理解我们为什么需要 Immutablejs
首先我们要介绍什么是Pure function (纯函数), 来自维基百科::
在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:
- 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
- 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
简单来说纯函数的两个特征:1) 对于相同的输入总有相同的输出;2) 函数不依赖外部变量,也不会对外部产生影响(这种影响称之为“副作用(side effects)”)
redux 中规定 reducer 就是纯函数。它接收前一个 state 状态和 action 作为参数,返回下一个状态:
(previousState, action) => newState
保证 reducer 的“纯粹(pure)”非常重要,你永远不能在 reducer 中做以下三件事:
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 还有很多的话题可以聊,比如最佳实践注意事项什么的。鉴于篇幅有限就先聊到这里。有机会再继续
https://www.site2share.com/folder/20020529
你可能会喜欢