什么是immutable.js
immutable.js是一个用于产生不可变数据结构的工具库,js原生的的map,set等结构属于可变结构,对函数式编程不够友好,immutable提供的数据结构可以保证新的操作不会对原有的数结构产生影响(类似于immer.js),文字描述比较抽象,下面直接看一下immutable js的用法
基本使用
以map为例简单看一下到底什么是不可变
const originMap = new Map()
const mp1 = originMap.set('k1', 'v1')
const mp2 =mp1.set('k2', 'v2')
console.log(mp1)
/**
Map {
size: 1,
_root: ArrayMapNode { ownerID: undefined, entries: [ [Array] ] },
__ownerID: undefined,
__hash: undefined,
__altered: false
}
* */
console.log(mp2)
/**
Map {
size: 2,
_root: ArrayMapNode { ownerID: undefined, entries: [ [Array], [Array] ] },
__ownerID: undefined,
__hash: undefined,
__altered: false
}
**/
console.log(mp1 === mp2) // false
对比原生js的map可以发现mp1.set并没有对mp1本身产生影响,而是产生了新的mp2对象,这样就保证了原有结构的不变。
除此之外immutable js还实现了对空间的高效利用,在保证不变的同时最大程度上提高空间的利用率。
源码分析(以map为例)
下面以map的实现为例看一下immutable如何保证不变以及如何不浪费空间。
基础概念
看源码之前先了解一下immutable中的重要概念
属性
从之前的log内容可以看出map除了保存数据的核心结构外还有ownerId这样的属性,下面看一下这些属性的作用。
- _root下面的结构用于保存数据,前面测试用的key-value保存在root下
- __ownerID 用于标识该结构是否被其他immutable结构共享,一旦被共享该结构就不能被改变(后面会解释“共享”的含义)
- __altered标识该结构是否被改变
Node
这里只介绍map相关的node
-
ArrayMapNode 元素数量较少时使用的存储结构,内部使用二维数组保存key-value,前面的k1 v1会保存为['k1', 'v1']。查询ArrayMapNode的时间复杂度是O(n)的,不过因为元素数量少,整体速度依然比较快。
-
ValueNode 复杂结构保存数据的基本单位,除了ArrayMapNode外其他结构均使用ValueNode保存数据
-
BitmapIndexedNode 比ArrayMapNode更复杂的结构,使用hash + bitmap(32位二进制数)标识元素存储位置,内部使用ValueNode保存元素。BitmapIndexedNode空间利用率高,也具有不错的查询速度
-
HashArrayMapNode 使用key-hash查询元素位置,查询效率高于BitmapIndexedNode,不过空间利用率相对更低一点。
以上就是Map相关的基础知识,看下来可能会有一点晕,结合源码看会更清晰一点。
初始化流程
先看一下Map的构造函数的核心逻辑
constructor(value) {
// some code
return emptyMap().withMutations(map => {
const iter = KeyedCollection(value);
assertNotInfinite(iter.size);
iter.forEach((v, k) => map.set(k, v));
});
}
构造函数首先创建一个空map,然后通过withMutations返回带有初始化key-value的map
export function withMutations(fn) {
const mutable = this.asMutable();
fn(mutable);
return mutable.wasAltered() ? mutable.__ensureOwner(this.__ownerID) : this;
}
withMutations内部通过asMutable创建一个可变的map(要记住immutable中的结构时不可变的,这里的可变是指根据前面的空map创建一个可变的空map,不会对已创建的map产生影响),添加key value后返回map
immutable中的update流程基本都是根据现有结构创建一个可变结构 -> 完成修改 -> 返回新的不可变结构,这样可以避免对原有对象的影响
Node类型转换
前面提到了map中使用的各种类型的Node,在元素数量不同时Map会选用合适的结构保证查询效率和空间利用率
ArrayMapNode -> BitmapIndexedNode
当元素数数量少于8个时使用ArrayMapNode保存数据,下面看一下ArrayMapNode的结构
// ArrayMapNode
Map {
size: 8,
_root: ArrayMapNode {
ownerID: undefined,
entries: [
[ 'k0', 'v0' ],
[ 'k1', 'v1' ],
[ 'k2', 'v2' ],
[ 'k3', 'v3' ],
[ 'k4', 'v4' ],
[ 'k5', 'v5' ],
[ 'k6', 'v6' ],
[ 'k7', 'v7' ]
]
},
__ownerID: undefined,
__hash: undefined,
__altered: false
}
虽然ArrayMapNode的查询效率时O(n),但是因为元素最多只有8个,所以整体效率还不错。
当元素数量超过8个时在执行update后ArrayMapNode会转变为BitmapIndexedNode,下面时给刚才的ArrayMapNode添加了一个元素后的变化
Map {
size: 9,
_root: BitmapIndexedNode {
ownerID: OwnerID {},
bitmap: 16352,
nodes: [
[ValueNode], [ValueNode],
[ValueNode], [ValueNode],
[ValueNode], [ValueNode],
[ValueNode], [ValueNode],
[ValueNode]
]
},
__ownerID: undefined,
__hash: undefined,
__altered: false
}
节点转换的过程发生在ArrayMapNode.update下,当元素数量超过MAX_ARRAY_MAP_SIZE(8个)时通过createNodes(最后调用mergeIntoNodes)创建新的Node
// ArrayMapNode.update
update(ownerID, shift, keyHash, key, value, didChangeSize, didAlter) {
// some code
if (!exists && !removed && entries.length >= MAX_ARRAY_MAP_SIZE) {
return createNodes(ownerID, entries, key, value);
}
// some code
return new ArrayMapNode(ownerID, newEntries);
}
function mergeIntoNode(node, ownerID, shift, keyHash, entry) {
// some code
const idx1 = (shift === 0 ? node.keyHash : node.keyHash >>> shift) & MASK;
const idx2 = (shift === 0 ? keyHash : keyHash >>> shift) & MASK;
let newNode;
const nodes =
idx1 === idx2
? [mergeIntoNode(node, ownerID, shift + SHIFT, keyHash, entry)]
: ((newNode = new ValueNode(ownerID, keyHash, entry)),
idx1 < idx2 ? [node, newNode] : [newNode, node]);
return new BitmapIndexedNode(ownerID, (1 << idx1) | (1 << idx2), nodes);
}
至于BitmapIndexedNode如何存储数据后面会写,这里只看一下转换过程就可以。
BitmapIndexedNode -> HashArrayMapNode
当BitmapIndexedNode元素继续增多时会转换为HashArrayMapNode,下面是一个HashArrayMapNode的结构。 HashArrayMapNode查询效率高于BitmapIndexedNode,但是可以看到有很多的空间浪费(因为hash不连续)
Map {
size: 90,
_root: HashArrayMapNode {
ownerID: undefined,
count: 17,
nodes: [
undefined, undefined,
undefined, [ValueNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [ValueNode],
undefined, undefined,
undefined, undefined,
undefined, undefined,
undefined, undefined,
undefined, undefined,
undefined, undefined
]
},
__ownerID: undefined,
__hash: undefined,
__altered: false
}
Node的转换过程发生在BitmapIndexedNode.update下,当第一层元素数量超过MAX_BITMAP_INDEXED_SIZE(32)时通过调用expandNodes完成节点转换
update(ownerID, shift, keyHash, key, value, didChangeSize, didAlter) {
// some code
if (!exists && newNode && nodes.length >= MAX_BITMAP_INDEXED_SIZE) {
return expandNodes(ownerID, nodes, bitmap, keyHashFrag, newNode);
}
// some code
return new BitmapIndexedNode(ownerID, newBitmap, newNodes);
}
如何保存数据
下面分析一下三种前面提到的Node如何保存数据
ArrayMapNode
ArrayMapNode {
ownerID: undefined,
entries: [
[ 'k0', 'v0' ],
[ 'k1', 'v1' ],
[ 'k2', 'v2' ],
[ 'k3', 'v3' ],
[ 'k4', 'v4' ],
[ 'k5', 'v5' ],
[ 'k6', 'v6' ]
]
}
ArrayMapNode.entries通过二维数组的形式保存key-value,查询key时通过复杂度为O(n)的遍历完成查找,没有很复杂的细节。
BitmapIndexedNode
BitmapIndexedNode使用bitmap记录数据的位置,bitmap是一个32位二进制数0代表该位置没有数据,1代表保存了数据。看一下取数据的过程会更清晰一点
// BitmapIndexedNode
get(shift, keyHash, key, notSetValue) {
if (keyHash === undefined) {
keyHash = hash(key);
}
const bit = 1 << ((shift === 0 ? keyHash : keyHash >>> shift) & MASK);
const bitmap = this.bitmap;
return (bitmap & bit) === 0
? notSetValue
: this.nodes[popCount(bitmap & (bit - 1))].get(
shift + SHIFT,
keyHash,
key,
notSetValue
);
}
keyHash & MASK可以计算出传入的key在bitmap中的保存位置(也就是bit),&MASK相当于求余数,BitmapIndexedNode每层最多保存32个元素,因此通过&MASK保证结果小于32。
key在bitmap中的位置是不连续的,但是转换到nodes中是连保存的(前面提到了BitmapIndexedNode空间利用率高),因此需要把bitmap映射到nodes
popCount的作用是计算bitmap最低为到bit一共有多少个”1“,换句话说就是中间保存了多少个元素,这样根据”1“的数量就可以确定该元素在nodes中的具体位置。
补充一下:假设我们有一个bitmap:11011 nodes: [1, 2, 3, 4],传入的key经过hash计算后的bit为1000,说明我们要获取bitmap中的第四位数字,然后计算该数字到最低为中间一共有两个”1“,也就是说在nodes中我们的目标元素前面还有两个元素,最后获取到的元素为nodes[2]
BitmapIndexedNode牺牲了一定的查询速度但是节省空间,每次查询过程中的性能损失主要来自于bitmap的计算过程。
HashArrayMapNode
HashArrayMapNode直接通过hash(key)的方式保存元素,省去了计算bitmap的计算时间,但是因为hash不连续,所以会造成空间浪费。
下面放一个普通的HashArrayMapNode结构看一下就可以了
HashArrayMapNode {
ownerID: undefined,
count: 17,
nodes: [
undefined, undefined,
undefined, [ValueNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [BitmapIndexedNode],
[BitmapIndexedNode], [ValueNode],
undefined, undefined,
undefined, undefined,
undefined, undefined,
undefined, undefined,
undefined, undefined,
undefined, undefined
]
}
前面提到“共享”是什么意思
immutable除了保证数据不可变以外,另一个优势就是空间利用率高,避免无意义的复制和浪费。
这里简单画一个图解释一下共享的实现(实际流程会更加复杂),假设我们有一个BitmapIndexedNode里面保存了两个元素(只是假设),第三个元素时并不会修改原有的BitmapIndexedNode,而是创建一个新的node并修改,此时就会发生“共享”
当set newValue过程中原有的ValueNode并没有发生改变,因此可以在新的map中复用,新的map只要增加k3-v3即可,这些被复用的ValueNode就处于被共享状态,因此也会具有OwnerID
let originMap = new Map()
for(let i = 0; i < 9; ++i){
originMap = originMap.set(`k${i}`, `v${i}`)
}
let newMap = originMap.set('k9', 'v9')
console.error(newMap._root.nodes[0] === originMap._root.nodes[0]) // true
上面的case可以复现这种情况,当添加新的key-value时原有的valueNode得到了复用,并没有重新生成。
加餐
上面对于map以及immutable的基本工作原理已经讨论的差不多了,下面再补充一下其他两个数据结构的工作原理。
set如何工作
immutable中的set本质上是一个map,只是这个map的key-value是相同的,下面看一下一个set的基本结构
Set {
size: 3,
_map: Map {
size: 3,
_root: ArrayMapNode { ownerID: OwnerID {}, entries: [Array] },
__ownerID: undefined,
__hash: undefined,
__altered: false
},
__ownerID: undefined
}
里面依然是熟悉的map,没啥神秘的
简单分析一下list
list和map的差异比较大,不过本身也是使用tree来保存数据。
List {
size: 1024,
_origin: 0,
_capacity: 1024,
_level: 5,
_root: VNode {
array: [
[VNode], [VNode], [VNode], [VNode],
[VNode], [VNode], [VNode], [VNode],
[VNode], [VNode], [VNode], [VNode],
[VNode], [VNode], [VNode], [VNode],
[VNode], [VNode], [VNode], [VNode],
[VNode], [VNode], [VNode], [VNode],
[VNode], [VNode], [VNode], [VNode],
[VNode], [VNode], [VNode]
],
ownerID: OwnerID {}
},
_tail: VNode {
array: [
992, 993, 994, 995, 996, 997,
998, 999, 1000, 1001, 1002, 1003,
1004, 1005, 1006, 1007, 1008, 1009,
1010, 1011, 1012, 1013, 1014, 1015,
1016, 1017, 1018, 1019, 1020, 1021,
1022, 1023
],
ownerID: OwnerID {}
},
__ownerID: undefined,
__hash: undefined,
__altered: false
}
这是一个具有1024个元素的list,list每一层最多可以保存32个VNode,当该层元素超过32个时会嵌套在下面的VNode中,通过增加层数的方式来增加容量。
list在查找元素时通过逐层查找的方式定位元素的位置
// List.get
get(index, notSetValue) {
index = wrapIndex(this, index);
if (index >= 0 && index < this.size) {
index += this._origin;
const node = listNodeFor(this, index);
return node && node.array[index & MASK];
}
return notSetValue;
}
// ListNodeFor用于定位子数组
function listNodeFor(list, rawIndex) {
if (rawIndex >= getTailOffset(list._capacity)) {
return list._tail;
}
if (rawIndex < 1 << (list._level + SHIFT)) {
let node = list._root;
let level = list._level;
while (node && level > 0) {
node = node.array[(rawIndex >>> level) & MASK];
level -= SHIFT;
}
return node;
}
}
假设我们现在有一个两层的List(这里的两层是指第一层时32个VNode,每个VNode有32个子元素),该list最多可容纳32 * 32 = 1024个元素。
当通过List.get()获取第50个元素时需要先遍历第一层元素确定目标元素在哪个VNode下。因为第一层每个VNode下面有32个子元素,没经过一个第一层的Node实际上经过了32个元素,所以(50 >>> level) & MASK相当于Math.floor(50/32) = 1(对于层数不太多的情况下level是5,二进制的11111就是32),可以确定第50个元素位于node.array[1]的子数组内,接下来只需要求余数就可以获得具体坐标了,index & MASK相当于50 % 32 = 18, 因此坐标为50的元素位于1号VNode下的18号元素
对于层数更多的情况原理也是一样的,至少rawIndex需要右移更多位来逐步定位目标index的位置。
总结
简单读一遍Immutable.js源码真的学到了很多,比如map的原理,如何高效存取数据等,可以说immutable设计十分精妙,内部的其他数据结构也非常值得一读,不过由于精力有限暂时先写到这里,后面有机会回去看另一个和函数时编程相关的库immer.js。