本文介绍不可变数据类型ImmutableJS
和ImmerJS
的基本用法,以及它们在React项目中的使用。
在JavaScript中有引用类型和基本类型。
- 如果 变量a是基本类型,将a赋值给变量b,再修改b的值,则变量a不受影响
- 如果 变量a是引用类型,将a赋值给变量b, 则b与a是同一个对象的引用,若修改b对象的属性,a对象的该属性也会跟着变化。
解决方案就是深拷贝,比如最简单的方式JSON.parse(JSON.stringify(obj))
。- 但是深拷贝这种方式比较耗费性能(空间和时间),有没有折中的方案呢? 即修改b的同时不影响a,且比深拷贝节省性能。
- 所以有了immutable.js,简单说就是按需深拷贝。
- 此外还有一个重要原因,开发中经常遇到函数带有副作用,副作用的函数修改了一个引用类型某个属性的值或者浅copy的问题,immutable为此类问题提供了一种优雅的解决方案。
Immutable Data
就是一旦创建,就不能再被更改的数据Immutable
对象的任何修改或添加删除操作都会返回一个新的 Immutable
对象;Immutable
实现的原理是 Persistent Data Structure
(持久化数据结构):也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免 deepCopy
把所有节点都复制一遍带来的性能损耗,Immutable
使用了 Structural Sharing
(结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。immutable
是Javascript中不可变的集合
官网
Facebook 工程师 Lee Byron
花费 3 年时间打造,与 React 同期出现,但没有被默认放到 React 工具集里(React 提供了简化的 Helper)。它内部实现了一套完整的 Persistent Data Structure
,还有很多易用的数据类型。像 Collection、List
、Map
、Set
、Record
、Seq
。有非常全面的map
、filter
、groupBy
、reduce
、find
函数式操作方法。同时 API
也尽量与 Object
或 Array
类似。
javascript// 原来的写法
let foo = {a: {b: 1}};
let bar = foo;
bar.a.b = 2;
console.log(foo.a.b); // 打印 2
console.log(foo === bar); // 打印 true
// 使用 immutable.js 后
import Immutable from 'immutable';
var foo = Immutable.fromJS({a: {b: 1}});
var bar = foo.setIn(['a', 'b'], 2); // 使用 setIn 赋值
console.log(foo.getIn(['a', 'b'])); // 使用 getIn 取值,打印 1
// 等价于 foo.getIn(['a']).getIn(['b'])
console.log(foo === bar); // 打印 false
笔者没有找关于immutable相关能够通俗易懂快速上手的中文文档,无奈只能自己阅读官方文档,以下内容是笔者基于官方文档整理的自己的总结
javascript// 前面提到为了消除引用类型及浅拷贝的副作用,引入了immutable概念
// 但是深拷贝这种方式比较耗费性能
// 因此寻找一种既能避免副作用又节省开销的方式--结构共享
// 没有immutable.js我们可能采用下面的方式避免副作用
var a = {b:'B',c:{d:1}};
var b = Object.assign({}, a);
b.b = 'new val bbb';
console.log(b.b) // 'new val bbb'
console.log(a.b); // 仍旧为 'B'
console.log(a.c === b.c); // true
// 显然 7,8行代码书写起来不方面,
// 如果a.b也是引用类型,还对a.b 重复进行上述两部操作
然而 immutable.js
的使用有一定的上手成本,它自定义了一些数据结构
javascript// 如果是对象类型,在immutable中使用 Map
const { Map } = require('immutable');
const map1 = Map({ a: 1, b: 2, c: [1,2] });
// 修改原对象的属性会产生一个新对象, 原对象保持不变
const map2 = map1.set('b', 50);
// 使用 .equals 方法比较
console.log(map1.equals(map2)); // false
// 取值 .get
console.log(map1.get('b'), map2.get('b')); // 2, 50
console.log(map1.get('c') === map2.get('c')); // true, c没有改变
引用类型的比较是基于引用地址,而不可变对象的比较是基于集合的值是否一一相同
javascriptconst { Map } = require('immutable');
const map1 = Map({ a: 1, b: 2, c: 3 });
const map2 = Map({ a: 1, b: 2, c: 3 });
map1.equals(map2); // true
// Immutable.is(map1, map2); // true
map1 === map2; // false
const map3 = map2.set('b', 2);
map3 === map2; // true
// 如果一个对象是不可变的,
// 则可以简单地通过对它进行另一个引用而不是复制整个对象来“复制”它。
// 因为这可以节省内存并潜在地提高依赖副本的程序的执行速度(例如撤消堆栈)。
补充: 不可变对象的比较是基于值,算法复杂度, 而引用类型比较算法复杂度 , 因此使用时要考虑性能权衡。
Immutable.js 具有一个与 ES2015 Array、Map 和 Set 非常相似的面向对象的 API。
JavaScript | immutable |
---|---|
Map | Map |
Set | Set |
Array | List |
Object | fromJS or Map |
javascriptconst { Map, List } = require('immutable');
const map1 = Map({ a: 1, b: 2, c: 3, d: 4 });
const map2 = Map({ c: 10, a: 20, t: 30 });
const obj = { d: 100, o: 200, g: 300 };
const map3 = map1.merge(map2, obj);
// Map { a: 20, b: 2, c: 10, d: 100, t: 30, o: 200, g: 300 }
const list1 = List([1, 2, 3]);
const list2 = List([4, 5, 6]);
const array = [7, 8, 9];
const list3 = list1.concat(list2, array);
// 从上面的示例可以看出
// 1. 与JS API靠拢 如 Array.prototype.concat
// 2. api可以与JS对象互相操作
// 所有 Immutable.js 集合都是可迭代的
const { List } = require('immutable');
const aList = List([1, 2, 3]);
const anArray = [0, ...aList, 4, 5]; // [ 0, 1, 2, 3, 4, 5 ]
javascript// 嵌套结构
const { fromJS } = require('immutable');
const nested = fromJS({ a: { b: { c: [3, 4, 5] } } });
console.log(typeof nested.toJS()); // 'object'
const nested2 = nested.mergeDeep({ a: { b: { d: 6 } } });
// nested2 { a: { b: { c: [3, 4, 5] }, d: 6 } }
console.log(nested2.getIn(['a', 'b', 'd'])); // 6
console.log(nested2.getIn(['a']).getIn(['b', 'd'])); // 6
const nested3 = nested2.updateIn(['a', 'b', 'd'], value => value + 1);
console.log(nested3);
// Map { a: Map { b: Map { c: List [ 3, 4, 5 ], d: 7 } } }
const nested4 = nested3.updateIn(['a', 'b', 'c'], list => list.push(6));
// Map { a: Map { b: Map { c: List [ 3, 4, 5, 6 ], d: 7 } } }
默认情况下immutable.js
中的api
每执行一次就会返回一个新的immutable
对象
那么如果我只需要最后生成的immutable
对象,中间的对象都不要,可不可以只返回新一个新对象呢?
这就用到了 withMutations
,批量处理节省开销,目前只有少数方法 set
、push
、pop
可直接应用于持久化数据结构
javascriptconst { List } = require('immutable');
const list1 = List([1, 2, 3]);
const list2 = list1.withMutations(function (list) {
list.push(4).push(5).push(6);
});
console.log(list1.size); // 3
console.log(list2.size); // 6
javascriptconst { Seq } = require('immutable');
const oddSquares = Seq([1, 2, 3, 4, 5, 6, 7, 8])
.filter(x => {
console.log('filter', x)
return x % 2 !== 0
})
.map(x => x * x);
// 因为oddSquares是一个immutable对象,是惰性执行的,上述代码不会做任何操作
// 当把immutable对象转换成 JavaScript 对象是 才会执行
console.log(oddSquares.toJS()); // 控制台打印了 ‘filter’
// Range 是一种特殊的 Lazy 序列。
const { Range } = require('immutable');
const a = Range(990, 1010)
// .skip(100)
.map(n => {
console.log(n)
return -n;
})
.reduce((r, n) => r * n, 1);
console.log('---', a) // --- 9.897178826145609e+59
由于 Immutable 数据一般嵌套非常深,为了便于访问深层数据,Cursor 提供了可以直接访问这个深层数据的引用。
javascriptimport Immutable from 'immutable';
import Cursor from 'immutable/contrib/cursor';
let data = Immutable.fromJS({ a: { b: { c: 1 } } });
// 让 cursor 指向 { c: 1 }
let cursor = Cursor.from(data, ['a', 'b'], newData => {
// 当 cursor 或其子 cursor 执行 update 时调用
console.log(newData);
});
cursor.get('c'); // 1
cursor = cursor.update('c', x => x + 1);
cursor.get('c'); // 2
javascriptfunction touchAndLog(touchFn) {
let data = { key: 'value' };
touchFn(data);
console.log(data.key);
// 因为不知道touchFn进行了什么操作,所以无法预料,但使用immutable,肯定是value
}
javascriptimport { Map} from 'immutable';
let a = Map({
select: 'users',
filter: Map({ name: 'Cam' })
})
let b = a.set('select', 'people');
a === b; // false
a.get('filter') === b.get('filter'); // true
Undo/Redo
,Copy/Paste
Immutable.js
尽量尝试把 API
设计的原生对象类似,有的时候还是很难区别到底是 Immutable
对象还是原生对象,容易混淆操作。Immutable
中的 Map
和 List
虽对应原生 Object
和 Array
,但操作非常不同,比如你要用 map.get('key')
而不是 map.key
,array.get(0)
而不是 array[0]
。另外 Immutable
每次修改都会返回新对象,也很容易忘记赋值;下面给出一些办法来避免类似问题发生:
TypeScript
这类有静态类型检查的工具;Immutable
类型对象以 $$
开头;Immutable.fromJS
而不是 Immutable.Map
或 Immutable.List
来创建对象,这样可以避免 Immutable
和原生对象间的混用;shouldComponentUpdate()
进行性能优化,但它默认返回 true
,即始终会执行 render()
方法,然后做 Virtual DOM
比较,并得出是否需要做真实 DOM
更新;shouldComponentUpdate
周期里执行deepCopy
和 deepCompare
避免无意义的render
,但deepFn
也很耗时;javascriptimport { is } from 'immutable';
shouldComponentUpdate: (nextProps = {}, nextState = {}) => {
const thisProps = this.props || {}, thisState = this.state || {};
if (Object.keys(thisProps).length !== Object.keys(nextProps).length ||
Object.keys(thisState).length !== Object.keys(nextState).length) {
return true;
}
for (const key in nextProps) {
if (!is(thisProps[key], nextProps[key])) {
return true;
}
}
for (const key in nextState) {
if (thisState[key] !== nextState[key] && !is(thisState[key], nextState[key])) {
return true;
}
}
return false;
}
对于处理修改引用类型副作用问题,ImmutableJS 有两个较大的不足:
javascriptlet currentState = {
x:[2]
}
let o1 = currentState;
o1.a = 1;
let o2 = {
...currentState
};
o2.x.push(3);
console.log(currentState) // { x: [ 2, 3 ], a: 1 }
/* 使用immer解决上述问题 */
import produce from 'immer';
let state = {
x:[2]
}
let obj1 = produce(state, draft => {
draft.a = 1;
});
let obj2 = produce(state, draft => {
draft.x.push(3);
});
// { x: [ 2 ] } { x: [ 2 ], a: 1 } { x: [ 2, 3 ] }
console.log(state, obj1, obj2);
// produce方法做了哪些事情?
// 遍历原始对象,依次冻各个属性。
produce(currentState, recipe: (draftState) => void | draftState, ?PatchListener): nextState
currentState
:被操作对象的最初状态draftState
:根据 currentState
生成的草稿状态,它是 currentState
的代理,对 draftState
所做的任何修改都将被记录并用于生成 nextState
。在此过程中,currentState
将不受影响nextState
:根据 draftState
生成的最终状态produce
:用来生成 nextState
或 producer
的函数producer
:通过 produce
生成,用来生产 nextState
,每次执行相同的操作recipe
:用来操作 draftState
的函数javascript// 原写法 setState
const { members } = this.state;
this.setState({
members: [
{
...members[0],
age: members[0].age + 1,
},
...members.slice(1),
]
})
// 现在写法
const { members } = this.state;
this.setState(this.setState(produce(members, draft => {
draft.members[0].age++;
})))
javascript// produce 内的 recipe 回调函数的第2个参数与obj对象是指向同一块内存
function reducer (state = {name: '章三', age: 12}, action) {
return immer.produce(state, (draft) => {
switch(action.type) {
case 'ADD':
draft.age++;
return
default:
return;
}
})
}
const store = Redux.createStore(reducer);
class App extends React.Component {
componentDidMount() {
store.subscribe(() => {
this.forceUpdate();
})
}
render() {
const state = store.getState();
return <div>
<p>{state.name}今年{state.age}周岁</p>
<button onClick={() => store.dispatch({type: 'ADD'})}>过生日</button>
</div>
}
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>);
完整案例 redux-immer.html
本文作者:郭敬文
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!