处理 JavaScript 复杂对象:深拷贝、Immutable & Immer

8,004 阅读7分钟

我们知道 js 对象是按共享传递(call by sharing)的,因此在处理复杂 js 对象的时候,往往会因为修改了对象而产生副作用———因为不知道谁还引用着这份数据,不知道这些修改会影响到谁。因此我们经常会把对象做一次拷贝再放到处理函数中。最常见的拷贝是利用 Object.assign() 新建一个副本或者利用 ES6 的 对象解构运算,但它们仅仅只是浅拷贝。

深拷贝

如果需要深拷贝,拷贝的时候判断一下属性值的类型,如果是对象,再递归调用深拷贝函数即可,具体实现可以参考 jQuery 的 $.extend。实际上需要处理的逻辑分支比较多,在 lodash 中 的深拷贝函数 cloneDeep 甚至有上百行,那有没有简单粗暴点的办法呢?

JSON.parse

最原始又有效的做法便是利用 JSON.parse 将该对象转换为其 JSON 字符串表示形式,然后将其解析回对象:

    const deepClone(obj) => JSON.parse(JSON.stringify(obj));

对于大部分场景来说,除了解析字符串略耗性能外(其实真的可以忽略不计),确实是个实用的方法。但是尴尬的是它不能处理循环对象(父子节点互相引用)的情况,而且也无法处理对象中有 function、正则等情况。

MessageChannel

MessageChannel 接口是信道通信 API 的一个接口,它允许我们创建一个新的信道并通过信道的两个 MessagePort 属性来传递数据

利用这个特性,我们可以创建一个 MessageChannel,向其中一个 port 发送数据,另一个 port 就能收到数据了。

    function structuralClone(obj) {
        return new Promise(resolve => {
            const {port1, port2} = new MessageChannel();
            port2.onmessage = ev => resolve(ev.data);
            port1.postMessage(obj);
        });
    }
    const obj = /* ... */
    const clone = await structuralClone(obj);

除了这样的写法是异步的以外也没什么大的问题了,它能很好的支持循环对象、内置对象(Date、 正则)等情况,浏览器兼容性也还行。但是它同样也无法处理对象中有 function的情况。

类似的 API 还有 History APINotification API 等,都是利用了结构化克隆算法(Structured Clone) 实现传输值的。

Immutable

如果需要频繁地操作一个复杂对象,每次都完全深拷贝一次的话效率太低了。大部分场景下都只是更新了这个对象一两个字段,其他的字段都不变,对这些不变的字段的拷贝明显是多余的。看看 Dan Abramov 大佬说的:

Dan Abramov
)

这些库的关键思路即是:创建 持久化的数据结构Persistent data structure),在操作对象的时候只 clone 变化的节点和其祖先节点,其他的保持不变,实现 结构共享(structural sharing)。例如在下图中红色节点发生变化后,只会重新产生绿色的 3 个节点,其余的节点保持复用(类似软链的感觉)。这样就由原本深拷贝需要创建的 8 个新节点减少到只需要 3 个新节点了。

结构共享

Immutable.js

Immutable.js 中这里的 “节点” 并不能简单理解成对象中的 “key”,其内部使用了 Trie(字典树) 数据结构, Immutable.js 会把对象所有的 key 进行 hash 映射,将得到的 hash 值转化为二进制,从后向前每 5 位进行分割后再转化为 Trie 树。

举个例子,假如有一对象 zoo:

zoo={
    'frog':🐸
    'panda':🐼,
    'monkey':🐒,
    'rabbit':🐰,
    'tiger':🐯,
    'dog':{
        'dog1':🐶,
        'dog2':🐕,
        ...// 还有 100 万只 dog
    }
    ...// 剩余还有 100 万个的字段
}

'frog'进行 hash 之后的值为 3151780,转成二进制 11 00000 00101 11101 00100,同理'dog' hash 后转二机制为 11 00001 01001 11100 那么 frog 和 dog 在 immutable 对象的 Trie 树的位置分别是:

当然实际的 Trie 树会根据实际对象进行剪枝处理,没有值的分支会被剪掉,不会每个节点都长满了 32 个子节点。

比如某天需要将 zoo.frog 由 🐸 改成 👽 ,发生变动的节点只有上图中绿色的几个,其他的节点直接复用,这样比深拷贝产生 100 万个节点效率高了很多。

总的来说,使用 Immutable.js 在处理大量数据的情况下和直接深拷贝相比效率高了不少,但对于一般小对象来说其实差别不大。不过如果需要改变一个嵌套很深的对象, Immutable.js 倒是比直接 Object.assign 或者解构的写法上要简洁些。

例如修改 zoo.dog.dog1.name.firstName = 'haha',两种写法分别是:

    // 对象解构
    const zoo2 = {...zoo,dog:{...zoo.dog,dog1:{...zoo.dog.dog1,name:{...zoo.dog.dog1,firstName:'haha'}}}}
    //Immutable.js 这里的 zoo 是 Immutable 对象
    const zoo2 = zoo.updateIn(['dog','dog1','name','firstName'],(oldValue)=>'haha')

seamless-immutable

如果数据量不大但想用这种类似 updateIn 便利的语法的话可以用 seamless-immutable。这个库就没有上面的 Trie 这些幺蛾子了,就是为其扩展了 updateInmerge 等 9 个方法的普通简单对象,利用 Object.freeze 冻结对象本身改动, 每次修改返回副本。感觉像是阉割版,性能不及 Immutable.js,但在部分场景下也是适用的。

类似的库还有 Dan Abramov 大佬提到的 immutability-helperupdeep,它们的用法和实现都比较类似,其中诸如 updateIn 的方法分别是通过 Object.assign 和对象解构实现的。

Immer.js

而 Immer.js 的写法可以说是一股清流了:

    import produce from "immer"
    const zoo2 = produce(zoo, draft=>{
        draft.dog.dog1.name.firstName = 'haha'
    }) 

虽然远看不是很优雅,但是写起来倒比较简单,所有需要更改的逻辑都可以放进 produce 的第二个参数的函数(称为 producer 函数)内部,不会对原对象造成任何影响。在 producer 函数内可以同时更改多个字段,一次性操作,非常方便。

这种用 “点” 操作符类似原生操作的方法很明显是劫持了数据结果然后做新的操作。现在很多框架也喜欢这么搞,用 Object.defineProperty 达到效果。而 Immer.js 却是用的 Proxy 实现的:对原始数据中每个访问到的节点都创建一个 Proxy,修改节点时修改副本而不操作原数据,最后返回到对象由未修改的部分和已修改的副本组成。

在 immer.js 中每个代理的对象的结构如下:

function createState(parent, base) {
    return {
        modified: false,    // 是否被修改过,
        assigned:{},// 记录哪些 key 被改过或者删除,
        finalized: false    //  是否完成
        base,            // 原数据
        parent,          // 父节点
        copy: undefined,    // base 和 proxies 属性的浅拷贝
        proxies: {},        // 记录哪些 key 被代理了
    }
}

在调用原对象的某 key 的 getter 的时候,如果这个 key 已经被改过了则返回 copy 中的对应 key 的值,如果没有改过就为这个子节点创建一个代理再直接返回原值。 调用某 key 的 setter 的时候,就直接改 copy 里的值。如果是第一次修改,还需要先把 base 的属性和 proxies 的上的属性都浅拷贝给 copy。同时还根据 parent 属性递归父节点,不断浅拷贝,直到根节点为止。

proxy
仍然以 draft.dog.dog1.name.firstName = 'haha' 为例,会依次触发 dog、dog1、name 节点的 getter,生成 proxy。对 name 节点的 firstName 执行 setter 操作时会先将 name 所有属性浅拷贝至节点的 copy 属性再直接修改 copy,然后将 name 节点的所有父节点也依次浅拷贝到自己的 copy 属性。当所有修改结束后会遍历整个树,返回新的对象包括每个节点的 base 没有修改的部分和其在 copy 中被修改的部分。

总结

操作大量数据的情况下 Immutable.js 是个不错的选择。一般数据量不大的情况下,对于嵌套较深的对象用 immer 或者 seamless-immutable 都不错,看个人习惯哪种写法了。如果想要 “完美” 的深拷贝,就得用 lodash 了😂。

扩展阅读

  1. Deep-copying in JavaScript
  2. Introducing Immer: Immutability the easy way