Immutable.js源码分析

3,099 阅读8分钟

什么是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流程基本都是根据现有结构创建一个可变结构 -> 完成修改 -> 返回新的不可变结构,这样可以避免对原有对象的影响

总结一下map初始化的流程

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如何存储数据后面会写,这里只看一下转换过程就可以。

ArrayMapNode转换到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);
  }

BitmapIndexedNode转换到HashArrayMapNode

如何保存数据

下面分析一下三种前面提到的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。