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

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

深拷贝

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

JSON.parse

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

const deepClone(obj) => JSON.parse(JSON.stringify(obj));
复制代码

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

MessageChannel

MessageChannel 接口是信道通讯 API 的一个接口,它容许咱们建立一个新的信道并经过信道的两个 MessagePort 属性来传递数据github

利用这个特性,咱们能够建立一个 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 个新节点了。less

结构共享

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、dog一、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
相关文章
相关标签/搜索