Immutable

Javascript 中不可变的集合 官网

immutable 引入

在 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 简介

  • Immutable Data 就是一旦创建,就不能再被更改的数据
  • 对 Immutable 对象的任何修改或添加删除操作都会返回一个新的 Immutable 对象;
  • Immutable 实现的原理是 Persistent Data Structure(持久化数据结构): 也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免 deepCopy 把所有节点都复制一遍带来的性能损耗,Immutable 使用了 Structural Sharing(结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。

immutable.js

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 类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 原来的写法
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

API

Map 类型

1
2
3
4
5
6
7
8
9
10
11
// 如果是对象类型,在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 没有改变

不可变类型比较

引用类型的比较是基于引用地址,而不可变对象的比较是基于集合的值是否一一相同

1
2
3
4
5
6
7
8
9
10
11
12
13
const { 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

// 如果一个对象是不可变的,
// 则可以简单地通过对它进行另一个引用而不是复制整个对象来“复制”它。
// 因为这可以节省内存并潜在地提高依赖副本的程序的执行速度(例如撤消堆栈)。

补充: 不可变对象的比较是基于值,算法复杂度 O(N), 而引用类型比较算法复杂度 O(1), 因此使用时要考虑性能权衡。

JavaScript 优先的 API

JavaScript immutable
Map Map
Set Set
Array List
Object fromJS or Map
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const { 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 ]

嵌套结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 嵌套结构
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 可直接应用于持久化数据结构

1
2
3
4
5
6
7
8
const { List } = require("immutable");
const assert = require("assert");
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

惰性序列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const { 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

cursor

由于 Immutable 数据一般嵌套非常深,为了便于访问深层数据,Cursor 提供了可以直接访问这个深层数据的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
import 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

优缺点

优点

  1. 降低了 mutable 带来的复杂性
1
2
3
4
5
6
function touchAndLog(touchFn) {
let data = { key: "value" };
touchFn(data);
console.log(data.key);
// 因为不知道touchFn进行了什么操作,所以无法预料,但使用immutable,肯定是value
}
  1. 节省内存

会尽量复用内存,甚至以前使用的对象也可以再次被复用。没有被引用的对象会被垃圾回收。

1
2
3
4
5
6
7
8
9
import { 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
  1. Undo/Redo,Copy/Paste
    因为每次数据都是不一样的,所有可以存储在数组里,想回退到哪里就拿出对应数据即可

缺点

  1. 需要学习新的 API
  2. 容易与原生对象混淆
    • 虽然 Immutable.js 尽量尝试把 API 设计的原生对象类似,有的时候还是很难区别到底是 Immutable 对象还是原生对象,容易混淆操作。
    • Immutable 中的 Map 和 List 虽对应原生 Object 和 Array,但操作非常不同,比如你要用 map.get(‘key’) 而不是 map.key,array.get(0) 而不是 array[0]。另外 Immutable 每次修改都会返回新对象,也很容易忘记赋值;
    • 当使用外部库的时候,一般需要使用原生对象,也很容易忘记转换。

下面给出一些办法来避免类似问题发生:

  1. 使用 TypeScript 这类有静态类型检查的工具;
  2. 约定变量命名规则: 如所有 Immutable 类型对象以 $$ 开头;
  3. 使用 Immutable.fromJS 而不是 Immutable.Map 或 Immutable.List 来创建对象,这样可以避免 Immutable 和原生对象间的混用;

使用 immutable.js 优化 react

  1. React 可以使用 shouldComponentUpdate()进行性能优化,但它默认返回 true,即始终会执行 render() 方法,然后做 Virtual DOM 比较,并得出是否需要做真实 DOM 更新;
  2. 可以在 shouldComponentUpdate 周期里执行 deepCopy 和 deepCompare 避免无意义的 render,但 deepFn 也很耗时;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { 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;
};

immer

官方地址

对于处理修改引用类型副作用问题,ImmutableJS 有两个较大的不足:

  • 需要使用者学习它的数据结构操作方式,没有 Immer 提供的使用原生对象的操作方式简单、易用;
  • 它的操作结果需要通过toJS方法才能得到原生对象,这使得在操作一个对象的时候,时刻要知道操作的是原生对象还是 ImmutableJS 的返回结果,稍不注意,就会产生问题;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let 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 的函数

使用 immer 优化 react

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 原写法 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++;
})
)
);

reduce 中使用 immer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 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 />);