原文:http://jlongster.com/Using-Immutable-Data-Structures-in-JavaScript

不久前我简要的谈过最近一次对我的博客的重写,并承诺对我学到的具体知识进行更深入的讲解。今天我要聊聊JavaScript中的不可变数据结构,具体就是immutable.jsseamless-immutable两个库。还有其他的库,但是不论你选择什么具体的库,概念上都是在持久化数据结构或拷贝原生JavaScript对象之间进行选择,并且比较两者各自突出的利弊。我也会讲一点transit-js,它可以很好的序列化任何东西。

本文并不适用于Redux,我会讲不可变数据结构的一般用法,但是会提出具体在Redux中使用时的一些看法。在Redux中,你有一个单独的应用状态对象并且对它进行不可变更新,有很多方法可以完成,各有利弊。我将在下面探索。

关于Redux要思考的一件事是怎么合并reducer形成单一应用的状态原子性,Redux提供的默认方法(combineReducers)假设你要合并多个值到一个JavaScript对象中去。例如,如果你真想把它们合并进一个Immutable.js对象,你需要自己写一个combineReducers去实现。这是必要的,如果你需要序列化你的应用状态,并且假设它整个都是由Immutable.js对象组成。

本文大部分适用于一般情况下在JavaScript中使用不可变对象。因为你这是在和默认的语法做对抗,所以有时候会有点不舒服,感觉你歪曲了类型。然而,取决于你的应用和设置,你可以得到很多。

目前有个提议,将不可变数据结构加到原生JavaScript中去,但是并不清楚是否会成功。目前来说,在JavaScript中使用它当然会解决很多问题。

Immutable.js

Immutable.js出自Facebook,是最流行的不可变数据结构的实现之一。它从头开始实现了完全的持久化数据结构,通过使用像tries这样的先进技术来实现结构共享。所有的更新操作都会返回新的值,但是在内部结构是共享的,来减少内存占用(和垃圾回收的失效)。这意味着如果你向容器中添加1000个元素,它实际上不会创建一个1001长度的容器。内部很有可能只有很少的对象被分配了空间。

在结构上数据共享的进步,很大程度上得益于Okasaki开创性的工作,几乎打破了不可变对象在实际应用中太慢的神话。事实上,令人惊讶的是有多少应用可以通过它变得更快。大量地读取和复制数据(来避免被其他人修改)的应用将很容易从不可变数据结构中获益(简单的一次性拷贝一个巨大的数组会削弱相比可变数据结构赢得的性能优势)。

另一个例子是ClojureScript如何发现当UI背后使用不可变数据结构时带来的巨大的性能提升。如果你正在改变一个UI,你通常会有很多非必须的DOM操作(因为你不知道值是否需要更新)。React会把DOM更新降到最低,但是你仍然需要通过生成虚拟DOM才能做到。当组件不可变时,你甚至不需要生成一个虚拟DOM,一个简单的===相等检查就能知道是否需要更新。

这么好的事是真的吗?你也许会不解为什么我们不一直使用不可变数据结构,这样就能享受它带来的好处。好吧,一些语言确实如此,比如ClojureScript和Elm。在JavaScript中是比较困难的,因为语言默认并不提供,所以我们需要权衡得失。

空间和垃圾回收效率

我已经解释过为什么结构上的共享使得不可变数据结构更高效。对数组的特定位置进行修改的开销是巨大的,但是使用不可变的开销并不大。如果你想避免修改,这无疑会胜过拷贝对象。

在Redux中,不可变是强制的,你不会在屏幕上看到任何更新除非你返回一个新的值。这有很大的优势,所以如果你想避免复制,你可以看看Immutable.js

引用和值相等

假设你保存了一个对象的引用,称为obj1,之后,obj2也引用了这个对象。如果你没有改过他们,那么obj1 === obj2成立,你能肯定什么都没有改变。在很多框架中,像React,允许你很容易就实现有效的优化。

这叫“引用相等”,你可以简单的比较两个指针。但还有一种概念叫“值相等”,你需要执行*obj1.equals(obj2)*来检查两个对象是否相等。在不可变的情况下,你可以把对象当做值来对待。

在ClojureScript中一切都是值,甚至默认的相等操作符也执行值相等检查(像 === 一样)。如果你真想比较两个实例你会使用identical?检查不可变数据结构的值相等的好处是通常会比全递归扫描更高效(如果它共享结构就能跳过这步)。

所以它在哪里执行呢?我已经解释了它如何优化React是无关紧要的。只需要实现shouldComponentUpdate方法并检查是否状态相同,如果相同就跳过渲染。

我也发现在Immutable.js中使用 === 并不会执行值相等检查(显然,你不能重写JavaScript的语义),Immutable.js在判断对象相等时使用值相等。在它想检查是否对象相同的任何地方,它使用值相等。

比如,一个Map对象的键会被值相等检查。这意味着我可以把对象保存在Map中,之后只需要用和它有相同结构的另一个对象来恢复它:

let map = Immutable.Map();
map = map.set(Immutable.Map({ x: 1, y: 2}), "value");
map.get(Immutable.Map({ x: 1, y: 2 })); // -> "value"

这将带来很多很棒的体验。比如,假设我有一个函数,用一个包含了一些指定属性的对象作为参数来查询,并从服务器拉取数据:

function runQuery(query) {
    // pseudo-code: somehow pass the query to the server and 
    // get some results
    return fetchFromServer(serialize(query));
}

runQuery(Immutable.Map({
    select: 'users',
    filter: { name: 'James' }
}));

如果我想实现查询的缓存,这就是所有我需要做的:

let queryCache = Immutable.Map();
function runQuery(query) {
    let cached = queryCache.get(query);
    if(cached) {
        return cached;
    } else {
        let results = fetchFromServer(serialize(query));
        queryCache = queryCache.set(query, results);
        return results;
    }
}

我可以把查询对象当做一个值对待,用它作为键名来保存结果。之后如果执行相同的查询,我会取出缓存的结果,即使查询对象和之前的不是同一个实例。

值相等简化了各种各样的模式。事实上,我在查询文章的时候用了完全一样的技术。

JavaScript互操作

大部分反对Immutable.js数据结构的原因是他们可以实现以上所有的特性:它们不是普通的JavaScript数据结构。一个Immutable.js对象和JavaScript对象是完全不同的。

这意味着你必须用map.get(“property”)代替map.property,用array.get(0)代替array[0]。与此同时Immutable.js的体积也变得很大,为了提供兼容JavaScript的API,即使它们是不同的(push方法必须返回一个新的数组而非在原有的实例上修改)。你能感觉到它对JavaScript默认的重度可修改的语义的反抗。

让事情变得复杂的原因是,除非你足够的hardcore,从头开始写一个项目,否则你就不能到处使用Immutable对象。你根本不需要为了小函数的本地对象而使用Immutable。即使你使用immutable创建每一个单独的对象、数组等,你还是不得不和使用普通JavaScript对象、数组等的第三方库一起使用。

结果是你永远不知道你在使用的是JavaScript对象还是Immutable对象。这使函数推导变得更难。虽然你清楚在哪里使用了Immutable对象,你仍然通过系统把它们传递到了不清楚的地方。

事实上,有时候你可能会尝试将一个普通JavaScript对象放入Immutable Map中。别这么做,将immutable和mutable混合放在一个对象里将会变得很混乱。

我看到这个问题的两个解决方案:

  1. 使用像TypeScript或Flow这样的类型系统。这会消除记忆immutable数据结构在系统中流动的位置带来的精神负担。很多项目使用这一招,因为它需要换一个完全不同的代码风格。

  2. 隐藏数据结构的细节。如果你正在你系统的特定部分使用Immutable.js,不要让外部的任何东西直接访问这部分数据结构。一个好案例就是Redux和它的单一原子性应用状态。如果应用状态是一个Immutable.js对象,不要强制React组件去直接使用Immutable.js API。

有两种方法可以做到。第一种是使用像typed-immutable的库,真正给你的对象分配不同的类型。通过创建记录,可以得到对Immutable.js对象的更小的封装,能提供map.property接口,通过由记录的类型提供的字段来定义读取器。所有从这个对象中读取的东西都可以看做普通的JavaScript对象。你仍然不能修改它,但是这就是你实际你想贯彻的。

第二个方法是提供一种方式去查询对象并且强制在读取的时候执行查询。这个方法并不总奏效,但是在Redux中却很好用,因为我们有一个单一应用状态对象,而且你如论如何都想隐藏数据布局。强制所有的React组件都依赖数据布局意味着你永远不能修改应用状态的实际结构,但这有可能是你以后想做的。

对于对象的深度查询,查询不必做成复杂的引擎,它们可以仅仅是简单的函数。我还没有在我的博客中这么做,但是想象下我有一大堆像*getPost(state, id)getEditorSettings(state)*这样的函数。这些都使用以状态作为参数的函数,并返回我查询的东西。我不再关心它存在于状态对象的哪个位置,唯一的问题是我可能还是要返回一个immutable对象,所以我可能需要先将它强制转为JavaScript对象,或者使用上面说到的记录类型。

归结起来:JavaScript互操作是一个真正的话题。绝对不要从immutable对象内部引用JavaScript对象。typed-immutable提供的记录类型可以使互操作问题减轻,还有其他的好处,像在修改或读取非法字段时的抛错。最后,如果你使用Redux,不要让任何东西依赖应用状态结构,因为你之后会修改它。把数据定义抽象出来,解决了immutable的互操作问题。

seamless-immutable

还有另一种方式保证不可变。seamless-immutable是一个使用普通JavaScript对象的更轻量级解决方案。它没有定义新的数据结构,所以没有结构共享,这意味着你在更新它们时必须自己手动拷贝对象(然而你仅需要一份浅拷贝)。你不会获得任何以上提到的性能或值相等的好处。

然而,作为回报你获得了出色的JavaScript互操作。所有的数据结构都是原本的JavaScript数据结构。不同之处在于seamless-immutable在它们上面调用了Object.freeze,所以你不能修改它们(在严格模式,也是ES6模块默认使用的模式,在修改时会抛出错误)。此外,它还加了一些方法来帮助更新数据,比如merge方法会返回包含合并进去的属性的新的对象。

它少了一些更新不可变数据结构的通用方法,比如Immutable.js的setInmergeIn,它们可以方便的更新深层嵌套的对象。但是这些很容易实现,我计划贡献这些到这个项目中去[2]。

混合immutable和mutable对象是不可能的。seamless-immutable封装一个实例的时候会将所有对象深度转换为immutable,而且所有新加入的值都会自动被封装。实际上Immutable.js的工作原理非常类似,Immutable.fromJs和像ojb.merge的各种方法都会深度转换。但是obj.set不会自动转换,所以你可以保存任何你喜欢的数据类型。这在seamless-immutable中是不可能的,所以你不能随意的保存一个mutable JavaScript对象。

在我看来,我希望所有的库都保持原样,它们有不同的目标。比如,由于seamless-immutable的自动强制转换,你不能保存任何它不知道的类型,所以它不能很好的和任何其他库一起工作,除了基本的内建类型(事实上,你现在甚至不支持Map和Set类型)。

seamless-immutable是一个有很大优势的小库,但是同时也失去了一些不可变数据结构的基本优势,像值相等。如果JavaScript的互操作是你最关心的,它是一个极好的解决方案。如果你正在迁移现有代码,它是特别有用的,因为你可以慢慢的修改成immutable,而不需要重写每一块涉及到的代码。

缺失的环节:用transit-js序列化

最后一个需要考虑的是:序列化。如果你正在使用自定义数据类型,JSON.stringify就不再适用了。但是JSON.stringify从来就不是最好的,你甚至不能用它来序列化ES6的MapSet实例。

transit-jsDavid Nolen写的一个很好的库,它定义了一种可扩展的数据传输结构。默认情况下你不能把MapSet实例放到它里面,但是关键的区别是,你可以很容易地把自定义类型重写为transit理解的类型。事实上,所有用来序列化和反序列化整套Immutable.js类型的代码不到150行。

transit在如何编码类型方面也更聪明。比如,它知道到Map的键名可能会是复杂的类型,所以能很容易序列化Map类型。使用transit-immutable-js库去支持Immutable.js,现在我们可以做这样的事:

let { toJSON, fromJSON } = require('transit-immutable-js');

let map = Immutable.Map();
map = map.set(Immutable.Map({ x: 1, y: 2 }), "value");

let newMap = fromJSON(toJSON(map));
newMap.get(Immutable.Map({ x: 1, y: 2 })); // -> "value"

值相等和transit轻松愉快的Map序列化结合起来,让我们可以用一种简单的方式在任何系统中使用这些模式。事实上,我的博客在服务器端渲染时会构建查询缓存,然后把缓存发送给客户端,所以这个缓存仍然是完全完好的。这个使用场景使我切换到transit的主要原因。

它也可以很容易的序列化ES6 Map类型,但是如果你有复杂的键名,我不确定你会在没有值相等的情况下如何使用未序列化的实例。仍然可能会有一些使用场景要把它们序列化。

如果你有混合的普通JavaScript对象和Immutable.js对象,用transit序列化会保持所有这些类型的完整性。虽然我建议不要将它们混合,transit会将每个对象反序列化到合适的类型,而使用原生的JSON意味着你需要在反序列化时将所有的东西都转换为Immutable.js类型(假设你使用Immutable.fromJS(JSON.parse(str)))。

你可以扩展transit去支持序列化任何对象,像Date实例或任何自定义类型。可以从transit-format查看它如何编码类型。

如果你使用seamless-immutable,你已经限制自己只能使用JavaScript(和JSON兼容)的内建类型,所以你只能使用JSON.stringify。虽然简单,但是你丢失了扩展性,全靠自己权衡。

总结

不可变提供了很多好处,但是是否需要Immutable.js提供的全面的持久化数据结构,取决于应用。我怀疑很多应用都使用友好复制对象,因为它们大部分都很小。

虽然你在一些特征性的成本上轻松地赢了,但是不仅API有很多限制,你还不能使用值相等。此外,之后如果你发现你需要结构共享的性能,再想切换到Immutable.js就比较困难了。

通常我会建议将数据结构细节对外部隐藏起来,特别是使用Immutable.js的时候。试着遵循JavaScript对象和数组的默认规则,比如obj.propertyarr[0]。应该可以很快用这些接口把Immutable对象封装起来,但是需要更多的研究。

Redux中尤为如此,在那些你未来想要改变应用状态如何构成的地方。即使你的应用状态是普通JavaScript对象,你也有这个问题。无论你如何改变应用状态的内部,外部用户不应该察觉。提供一种方式查询应用状态结构,至少可以通过把数据访问函数抽象出来。像RelayFalcor这样的更复杂的解决方案解决了这个问题,因为查询语言是访问数据的默认方式。

[1] mori是另外一个持久化数据结构的实现(ClojureScript的分支),React’s immutability helpers是另外一个库,它简单地浅拷贝原生JavaScript对象

[2] 我对所有我知道的和不可变有关的类库做了一个概要