處理 JavaScript 複雜物件:深拷貝、Immutable & Immer
我們知道 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); 複製程式碼
除了這樣的寫法是非同步的以外也沒什麼大的問題了,它能很好的支援迴圈物件、function 物件等情況,瀏覽器相容性也還行。
類似的 API 還有 History API
、 Notification API
等,都是利用了結構化克隆演算法( ofollow,noindex"> Structured Clone
) 實現傳輸值的。
Immutable
如果需要頻繁地操作一個複雜物件,每次都完全深拷貝一次的話效率太低了。大部分場景下都只是更新了這個物件一兩個欄位,其他的欄位都不變,對這些不變的欄位的拷貝明顯是多餘的。看看 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'::frog: 'panda'::panda_face:, 'monkey'::monkey:, 'rabbit'::rabbit:, 'tiger'::tiger:, 'dog':{ 'dog1'::dog:, 'dog2'::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 由 :frog: 改成 :alien: ,發生變動的節點只有上圖中綠色的幾個,其他的節點直接複用,這樣比深拷貝產生 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 這些么蛾子了,就是為其擴充套件了 updateIn
、 merge
等 9 個方法的普通簡單物件,利用 Object.freeze
凍結物件本身改動, 每次修改返回副本。感覺像是閹割版,效能不及 Immutable.js,但在部分場景下也是適用的。
類似的庫還有 Dan Abramov 大佬提到的 immutability-helper
和 updeep
,它們的用法和實現都比較類似,其中諸如 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 屬性遞迴父節點,不斷淺拷貝,直到根節點為止。

draft.dog.dog1.name.firstName = 'haha'
為例,會依次觸發 dog、dog1、name 節點的 getter,生成 proxy。對 name 節點的 firstName 執行 setter 操作時會先將 name 所有屬性淺拷貝至節點的 copy 屬性再直接修改 copy,然後將 name 節點的所有父節點也依次淺拷貝到自己的 copy 屬性。當所有修改結束後會遍歷整個樹,返回新的物件包括每個節點的 base 沒有修改的部分和其在 copy 中被修改的部分。