我們知道 js 對象是按共享傳遞(call by sharing)的,因此在處理復雜 js 對象的時候,往往會因為修改了對象而產(chǎn)生副作用———因為不知道誰還引用著這份數(shù)據(jù),不知道這些修改會影響到誰。因此我們經(jīng)常會把對象做一次拷貝再放到處理函數(shù)中。最常見的拷貝是利用 Object.assign() 新建一個副本或者利用 ES6 的 對象解構(gòu)運算,但它們僅僅只是淺拷貝。
深拷貝
如果需要深拷貝,拷貝的時候判斷一下屬性值的類型,如果是對象,再遞歸調(diào)用深拷貝函數(shù)即可,具體實現(xiàn)可以參考 jQuery 的 $.extend。實際上需要處理的邏輯分支比較多,在 lodash 中 的深拷貝函數(shù) cloneDeep 甚至有上百行,那有沒有簡單粗暴點的辦法呢?
JSON.parse
最原始又有效的做法便是利用 JSON.parse 將該對象轉(zhuǎn)換為其 JSON 字符串表示形式,然后將其解析回對象:
const deepClone(obj) => JSON.parse(JSON.stringify(obj));
復制代碼
對于大部分場景來說,除了解析字符串略耗性能外(其實真的可以忽略不計),確實是個實用的方法。但是尷尬的是它不能處理循環(huán)對象(父子節(jié)點互相引用)的情況,而且也無法處理對象中有 function、正則等情況。
MessageChannel
MessageChannel 接口是信道通信 API 的一個接口,它允許我們創(chuàng)建一個新的信道并通過信道的兩個 MessagePort 屬性來傳遞數(shù)據(jù)
利用這個特性,我們可以創(chuàng)建一個 MessageChannel,向其中一個 port 發(fā)送數(shù)據(jù),另一個 port 就能收到數(shù)據(jù)了。
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);
復制代碼
除了這樣的寫法是異步的以外也沒什么大的問題了,它能很好的支持循環(huán)對象、內(nèi)置對象(Date、 正則)等情況,瀏覽器兼容性也還行。但是它同樣也無法處理對象中有 function的情況。
類似的 API 還有 History API 、Notification API 等,都是利用了結(jié)構(gòu)化克隆算法(Structured Clone) 實現(xiàn)傳輸值的。
Immutable
如果需要頻繁地操作一個復雜對象,每次都完全深拷貝一次的話效率太低了。大部分場景下都只是更新了這個對象一兩個字段,其他的字段都不變,對這些不變的字段的拷貝明顯是多余的??纯?Dan Abramov 大佬說的:

這些庫的關(guān)鍵思路即是:創(chuàng)建 持久化的數(shù)據(jù)結(jié)構(gòu)(Persistent data structure),在操作對象的時候只 clone 變化的節(jié)點和其祖先節(jié)點,其他的保持不變,實現(xiàn) 結(jié)構(gòu)共享(structural sharing)。例如在下圖中紅色節(jié)點發(fā)生變化后,只會重新產(chǎn)生綠色的 3 個節(jié)點,其余的節(jié)點保持復用(類似軟鏈的感覺)。這樣就由原本深拷貝需要創(chuàng)建的 8 個新節(jié)點減少到只需要 3 個新節(jié)點了。

Immutable.js
在 Immutable.js 中這里的 “節(jié)點” 并不能簡單理解成對象中的 “key”,其內(nèi)部使用了 Trie(字典樹) 數(shù)據(jù)結(jié)構(gòu), Immutable.js 會把對象所有的 key 進行 hash 映射,將得到的 hash 值轉(zhuǎn)化為二進制,從后向前每 5 位進行分割后再轉(zhuǎn)化為 Trie 樹。
舉個例子,假如有一對象 zoo:
zoo={
'frog':??
'panda':??,
'monkey':??,
'rabbit':??,
'tiger':??,
'dog':{
'dog1':??,
'dog2':??,
...// 還有 100 萬只 dog
}
...// 剩余還有 100 萬個的字段
}
復制代碼
'frog'進行 hash 之后的值為 3151780,轉(zhuǎn)成二進制 11 00000 00101 11101 00100,同理'dog' hash 后轉(zhuǎn)二機制為 11 00001 01001 11100 那么 frog 和 dog 在 immutable 對象的 Trie 樹的位置分別是:


當然實際的 Trie 樹會根據(jù)實際對象進行剪枝處理,沒有值的分支會被剪掉,不會每個節(jié)點都長滿了 32 個子節(jié)點。
比如某天需要將 zoo.frog 由 ?? 改成 ?? ,發(fā)生變動的節(jié)點只有上圖中綠色的幾個,其他的節(jié)點直接復用,這樣比深拷貝產(chǎn)生 100 萬個節(jié)點效率高了很多。

總的來說,使用 Immutable.js 在處理大量數(shù)據(jù)的情況下和直接深拷貝相比效率高了不少,但對于一般小對象來說其實差別不大。不過如果需要改變一個嵌套很深的對象, Immutable.js 倒是比直接 Object.assign 或者解構(gòu)的寫法上要簡潔些。
例如修改 zoo.dog.dog1.name.firstName = 'haha',兩種寫法分別是:
// 對象解構(gòu)
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
如果數(shù)據(jù)量不大但想用這種類似 updateIn 便利的語法的話可以用 seamless-immutable。這個庫就沒有上面的 Trie 這些幺蛾子了,就是為其擴展了 updateIn、merge 等 9 個方法的普通簡單對象,利用 Object.freeze 凍結(jié)對象本身改動, 每次修改返回副本。感覺像是閹割版,性能不及 Immutable.js,但在部分場景下也是適用的。
類似的庫還有 Dan Abramov 大佬提到的 immutability-helper 和 updeep,它們的用法和實現(xiàn)都比較類似,其中諸如 updateIn 的方法分別是通過 Object.assign 和對象解構(gòu)實現(xiàn)的。
Immer.js
而 Immer.js 的寫法可以說是一股清流了:
import produce from "immer"
const zoo2 = produce(zoo, draft=>{
draft.dog.dog1.name.firstName = 'haha'
})
復制代碼
雖然遠看不是很優(yōu)雅,但是寫起來倒比較簡單,所有需要更改的邏輯都可以放進 produce 的第二個參數(shù)的函數(shù)(稱為 producer 函數(shù))內(nèi)部,不會對原對象造成任何影響。在 producer 函數(shù)內(nèi)可以同時更改多個字段,一次性操作,非常方便。
這種用 “點” 操作符類似原生操作的方法很明顯是劫持了數(shù)據(jù)結(jié)果然后做新的操作?,F(xiàn)在很多框架也喜歡這么搞,用 Object.defineProperty 達到效果。而 Immer.js 卻是用的 Proxy 實現(xiàn)的:對原始數(shù)據(jù)中每個訪問到的節(jié)點都創(chuàng)建一個 Proxy,修改節(jié)點時修改副本而不操作原數(shù)據(jù),最后返回到對象由未修改的部分和已修改的副本組成。
在 immer.js 中每個代理的對象的結(jié)構(gòu)如下:
function createState(parent, base) {
return {
modified: false, // 是否被修改過,
assigned:{},// 記錄哪些 key 被改過或者刪除,
finalized: false // 是否完成
base, // 原數(shù)據(jù)
parent, // 父節(jié)點
copy: undefined, // base 和 proxies 屬性的淺拷貝
proxies: {}, // 記錄哪些 key 被代理了
}
}
復制代碼
在調(diào)用原對象的某 key 的 getter 的時候,如果這個 key 已經(jīng)被改過了則返回 copy 中的對應 key 的值,如果沒有改過就為這個子節(jié)點創(chuàng)建一個代理再直接返回原值。 調(diào)用某 key 的 setter 的時候,就直接改 copy 里的值。如果是第一次修改,還需要先把 base 的屬性和 proxies 的上的屬性都淺拷貝給 copy。同時還根據(jù) parent 屬性遞歸父節(jié)點,不斷淺拷貝,直到根節(jié)點為止。

仍然以 draft.dog.dog1.name.firstName = 'haha' 為例,會依次觸發(fā) dog、dog1、name 節(jié)點的 getter,生成 proxy。對 name 節(jié)點的 firstName 執(zhí)行 setter 操作時會先將 name 所有屬性淺拷貝至節(jié)點的 copy 屬性再直接修改 copy,然后將 name 節(jié)點的所有父節(jié)點也依次淺拷貝到自己的 copy 屬性。當所有修改結(jié)束后會遍歷整個樹,返回新的對象包括每個節(jié)點的 base 沒有修改的部分和其在 copy 中被修改的部分。
總結(jié)
操作大量數(shù)據(jù)的情況下 Immutable.js 是個不錯的選擇。一般數(shù)據(jù)量不大的情況下,對于嵌套較深的對象用 immer 或者 seamless-immutable 都不錯,看個人習慣哪種寫法了。如果想要 “完美” 的深拷貝,就得用 lodash 了??。
擴展閱讀
作者:表示很不蛋定
鏈接:https://juejin.im/post/5bbad07ce51d450e894e4228
來源:掘金
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。