處理 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
相關文章
相關標籤/搜索