前言
在JavaScript世界里,变量引用传递是非常常见的,带给我们逻辑实现过程的便利但同时也埋下变量值被无形改变的隐患,导致不期望的bug出现。比如
const a = [{ value: 1 }];
const b = a.map((item) => item.value = 2);
console.log(a);
console.log(b);
// [{ value: 2 }]
// [2]正如上面看到的,得到变量b的同时也无意间改变了变量a,因为在a.map中对数组元素即对象{ value: 1 }这个引用类型做了修改。
因此,在实际编码过程中,我们就需要像深浅拷贝这样的方式来断开引用,从而消除引用类型被无意修改的隐患。在现今前端业界盛行的react技术栈实现中,状态管理库redux在新旧state维护的过程中就非常需要一种高效的引用断开方式,由facebook推出的immutable.js正是为解决这类问题而来,需要弄清楚它的工作原来,才能更好的利用它来处理代码逻辑。
immutable.js
简介
顾名思义,immutable.js创建的数据是持久化的。定义了一整套持久化数据类型,Map、List、OrderedMap、Set、OrderedSet、Stack和Record。通过矢量字典树最小化复制开销并实现数据缓存,持久化数据类型提供mutative api,在更新数据后维持原数据不变并总是返回一个全新的更新过后的数据。
工作原理
在深入理解immutable.js的工作原理之前,有必要先深入理解一下Persistent Vectors(持久化矢量)。事实上immutable.js就是在Persistent Vectors(持久化矢量)的工作原理基础上实现的。
持久化矢量是一种数据结构,因为它们是持久化的,也就是说,每一次对一个vector进行修改时都会重新生成一个全新的vector,而不会改变原来的。
与Persistent Vectors相对的是Mutable Vectors(可变更矢量)。Mutable Vectors(可变更矢量)可以是普通arrays,可以很轻松的对其进行增加或缩减;而当希望在对它们修改时保持持久化,就不得不在修改前总是先复制一份,这无疑会加大内存消耗而带来性能问题。Persistent Vectors就是解决以上问题的一种理想方法。在快速查找或修改数据时,既能避免数据冗余又可以不损失性能。
Persistent Vectors是通过实现一种类似二叉树的结构来达到上面说到的效果。不同的是,在树结构里面的内部nodes最多引用两个subnodes,并且他们自身不包含任何元素。叶子nodes包含最多两个元素,而且这些元素是按序排列的,也就是说,从最左边叶子node开始,子元素依次排列直到最右边的叶子node的子元素。同时,这些叶子node在同一个层级上。如下图,是一个size为9的vector,同时包含整数0到8。

如果我们直接给这个vector追加另一个元素9,如下图,直接在最右边的叶子node上插入了元素9,这样无形之中就已经改变了当前的vector。

而假如我们复制当前vector的整棵树来防止对其做任何修改,那无疑复制的开销是很大的。那为了保持当前vector不变而又最小化复制带来的开销,我们就需要按节点路径进行复制。因为内部节点nodes引用了在它下面的subnodes,所以我们只需要从上往下按节点路径找到要修改的元素,并复制这条路径,保持其他节点的引用,从而实现结构复用,如下图里面的那些粉色nodes,这样我们就得到了一个有着7个元素的新vector,同时原来的vector依然保持不变。

更新元素
当我们更新一个元素时,向下找到元素所在的叶子node,在向下查找的过程中,正如上面说的,我们会复制这条路径上的nodes包括最底部的叶子node,最后修改元素的值。整个过程如下图,蓝色vector中包含了复制出来的节点路径。

插入元素
在尾部追加元素的操作和更新元素操作比较类似,但是插入元素也有3中比较特殊的case:
- 最后边叶子节点上有空位
- parent或root节点上有空位,而最右边叶子节点上无空位
- root节点上无空位
第一种情况,其实和元素更新是一样的,从上往下查找并复制节点路径,最后修改元素值。如下图,蓝色vector是新的,棕色的是旧的。

第二种情况,当节点不存时,我们可以重新生成。如下图,当前最右边的叶子节点中不存在空位,但是向上回溯我们能发现一个节点右边存在空位,复制得到新的节点(蓝色nodes),然后生成一个新的节点(粉色节点)挂载到右边空位里。

第三种情况,所有节点上都没有空位,甚至root节点。这个时候直接生成一个新的root节点,并把老的root节点的作为第一个子节点挂载到这个新生成的root节点上,然后按照第二种情况继续处理,如下图。

移除元素
弹出尾部元素,事实上和元素插入操作比较像。同样有3种case:
- 最右边的叶子节点包含的元素多余1个
- 最右边的叶子节点只包含一个元素
- 移除元素之后root节点只包含一个子节点
第一种情况,最右边的叶子节点含有两个元素,同样的按节点路径查找到它并复制节点路径,然后移除复制出来的叶子节点的一个元素,如下图。

第二种情况,最右边的叶子节点中有一个元素,和第一种情况一样,我们移除掉这个元素。但是我们会发现这个元素被移除掉之后,这个叶子节点就不包含任何元素了,成为了一个空节点(事实上是一个null),意味着父节点包含一个null指针,也就是断开这个空节点。如下图。棕色是原来的vector,蓝色是弹出元素6之后的心的vector,会发现少了一个叶子节点,也就是会断开那个空节点。

第三种情况,如下图。

当我们弹出元素8的时候,会发现得到的新的蓝色vector里,root节点(蓝色)指向了一个单一的子节点(棕色),显然这里这个root节点(蓝色)是多余的。而且正如我们在插入元素操作中看到的那样,这样的vector树结构会导致在插入元素时又会生成一个新的root节点,这是很糟糕的。所以这里需要将这个root节点移除掉,最终得到的新的vector如下图。

小结
从上面的分析我们可以发现,Persistent Vectors通过最小化复制来实现数据持久化,同时我们也会发现新旧vector中存在结构复用,也就是说内部一些节点的引用是共用的。以immutable.js为例:
const { fromJS } = require('immutable')
const data = {
content: {
val: 'Hello World',
},
desc: {
text: 'a',
},
}
// 把 data 转化为 immutable-js 中的内置对象
const a = fromJS(data)
const b = a.setIn(['desc', 'text'], 'b')
console.log(b.get('desc') === a.get('desc')) // false
// content 的值没有改动过,所以 a 和 b 的 content 还保持着引用
console.log(b.get('content') === a.get('content')) // true当我们改变某些字段时,未被修改的字段实际上是保持着引用的,正如例子中字段content,这也说明了immutable.js内部也是通过节点路径复制来最小化复制开销,减少数据冗余。
事实上,immutable.js利用字典树,将hash map的键作为关联数组保存起来,是以空间换时间的典型做法。配合Persistent Vectors机制来最小化复制以减少数据冗余带来的空间开销,极大程度上平衡了数据快速查找和更新的时间与空间消耗。