擴展 Object.assign 實現深拷貝
- 2019 年 11 月 3 日
- 筆記
本文參考: Object.assign 原理及其實現
需求場景
上一篇文章:手寫實現深拷貝中,我們講了淺拷貝和深拷貝,也實現了深拷貝方案。
但深拷貝,它是基於一個原對象,完完整整拷貝一份新對象出來,假如我們的需求是要將原對象上的屬性完完整整拷貝到另外一個已存在的對象上,這時候深拷貝就有點無能為力了。
就有點類似於 Object.assign():
var a = { a: 1, b: 2, c: { a: 1 } } var o = Object.assign(a, {a: 2, c: {b: 2}, d: 3}); o; // {a: 2, b: 2, c: {b: 2}, d: 3}
將一個原對象上的屬性拷貝到另一個目標對象上,最終結果取兩個對象的並集,如果有衝突的屬性,則以原對象上屬性為主,表現上就是直接覆蓋過去,這是 Object.assign() 方法的用途。
但很可惜的是,Object.assign 只是淺拷貝,它只處理第一層屬性,如果屬性是基本類型,則值拷貝,如果是對象類型,則引用拷貝,如果有衝突,則整個覆蓋過去。
這往往不符合我們的需求場景,講個實際中常接觸的場景:
在一些表單操作頁面,頁面初始化時可能會先前端本地創建一個對象來存儲表單項,對象中可能會有一些初始值,然後訪問了後台介面,讀取當前頁的表單數據,後台返回了 json 對象,這時候我們希望當前頁的表單存儲對象應該是後台返回的 json 對象和初始創建的對象的並集,有衝突以後台返回的為主,如:
var a = { a: { a: 1 } } var o = { a: { b: 2 } } // 我們希望得到的是: { a: { a: 1, b: 2 } } Object.assign(a, b); // {a: {b: 2}}
其實,說白了,這種需求就是希望可以進行深拷貝,而且是深拷貝到一個目標對象上。
上一篇的深拷貝方案雖然可以實現深度拷貝,但卻不支援拷貝到一個目標對象上,而 Object.assign 雖然支援拷貝到目標對象上,但它只是淺拷貝,只處理第一層屬性的拷貝。所以,兩種方案都不適用於該場景。
但兩種方案結合一下,其實也就是該需求的實現方案了,所以要麼擴展深拷貝方案,增加與目標對象屬性的交集處理和衝突處理;要麼擴展 Object.assign,讓它支援深拷貝。
實現方案
本篇就選擇基於 Object.assign,擴展支援深拷貝:assignDeep。
這裡同樣會給出幾個方案,因為深拷貝的實現可以用遞歸,也可以用循環,遞歸比較好寫、易懂,但有棧溢出問題;循環比較難寫,但沒有棧溢出問題。
遞歸版
function assignDeep(target, ...sources) { // 1. 參數校驗 if (target == null) { throw new TypeError('Cannot convert undefined or null to object'); } // 2. 如果是基本類型數據轉為包裝對象 let result = Object(target); // 3. 快取已拷貝過的對象,解決引用關係丟失問題 if (!result['__hash__']) { result['__hash__'] = new WeakMap(); } let hash = result['__hash__']; sources.forEach(v => { // 4. 如果是基本類型數據轉為對象類型 let source = Object(v); // 5. 遍歷原對象屬性,基本類型則值拷貝,對象類型則遞歸遍歷 Reflect.ownKeys(source).forEach(key => { // 6. 跳過自有的不可枚舉的屬性 if (!Object.getOwnPropertyDescriptor(source, key).enumerable) { return; } if (typeof source[key] === 'object' && source[key] !== null) { // 7. 屬性的衝突處理和拷貝處理 let isPropertyDone = false; if (!result[key] || !(typeof result[key] === 'object') || Array.isArray(result[key]) !== Array.isArray(source[key])) { // 當 target 沒有該屬性,或者屬性類型和 source 不一致時,直接整個覆蓋 if (hash.get(source[key])) { result[key] = hash.get(source[key]); isPropertyDone = true; } else { result[key] = Array.isArray(source[key]) ? [] : {}; hash.set(source[key], result[key]); } } if (!isPropertyDone) { result[key]['__hash__'] = hash; assignDeep(result[key], source[key]); } } else { Object.assign(result, {[key]: source[key]}); } }); }); delete result['__hash__']; return result; }
要注意的地方,其實也就是模擬實現 Object.assign 的一些細節處理,比如參數校驗,參數處理,屬性遍歷,以及引用關係丟失問題。
循環版
function assignDeep(target, ...sources) { // 1. 參數校驗 if (target == null) { throw new TypeError('Cannot convert undefined or null to object'); } // 2. 如果是基本類型,則轉換包裝對象 let result = Object(target); // 3. 快取已拷貝過的對象 let hash = new WeakMap(); // 4. 目標屬性是否可直接覆蓋賦值判斷 function canPropertyCover(node) { if (!node.target[node.key]) { return true; } if (node.target[node.key] == null) { return true; } if (!(typeof node.target[node.key] === 'object')) { return true; } if (Array.isArray(node.target[node.key]) !== Array.isArray(node.data)) { return true; } return false; } sources.forEach(v => { let source = Object(v); let stack = [{ data: source, key: undefined, target: result }]; while(stack.length > 0) { let node = stack.pop(); if (typeof node.data === 'object' && node.data !== null) { let isPropertyDone = false; if (hash.get(node.data) && node.key !== undefined) { if (canPropertyCover(node)) { node.target[node.key] = hash.get(node.data); isPropertyDone = true; } } if(!isPropertyDone) { let target; if (node.key !== undefined) { if (canPropertyCover(node)) { target = Array.isArray(node.data) ? [] : {}; hash.set(node.data, target); node.target[node.key] = target; } else { target = node.target[node.key]; } } else { target = node.target; } Reflect.ownKeys(node.data).forEach(key => { // 過濾不可枚舉屬性 if (!Object.getOwnPropertyDescriptor(node.data, key).enumerable) { return; } stack.push({ data: node.data[key], key: key, target: target }); }); } } else { Object.assign(node.target, {[node.key]: node.data}); } } }); return result; }
測試用例:
var a = {}; var o = { a: a, b: a, c: Symbol(), [Symbol()]: 1, d: function() {}, e(){}, f: () => {}, get g(){}, h: 1, i: 'sdff', j: null, k: undefined, o: /sdfdf/, p: new Date() } o.l = o; var o1 = assignDeep({}, {m: {b: 2}, n: 1}, o, {n: {a: 1}});
上面的方案仍舊不是100%完美,仍舊存在一些不足:
- 沒有考慮 ES6 的 set,Map 等新的數據結構類型
- get,set 存取器邏輯無法拷貝
- 沒有考慮屬性值是內置對象的場景,比如 /sfds/ 正則,或 new Date() 日期這些類型的數據
- 為了解決循環引用和引用關係丟失問題而加入的 hash 快取無法識別一些屬性衝突場景,導致同時存在衝突和循環引用時,拷貝的結果可能有誤
- 等等未發現的邏輯問題坑
雖然有一些小問題,但基本適用於大多數場景了,出問題時再想辦法慢慢填坑,目前這樣足夠使用了,而且,當目標對象是空對象時,此時也可以當做深拷貝來使用。
當然,也歡迎指點一下。
TypeScript 業務版
根據實際項目中的業務需求,進行的相關處理,就沒必要像上面的通用版考慮那麼多細節,比如我項目中使用 ts 開發,業務需求是要解決實體類數據的初始化和服務端返回的實體類的交集合併場景。
另外,只有對象類型的屬性需要進行交集處理,其餘類型均直接覆蓋即可:
/** 【需求場景】: export class ADomain { name: string = 'dasu'; wife: B[] = []; type: number; } export class B { count: number = 0; } xxxDomain: ADomain; xxxService.getXXX().subscript(json => { this.xxxDomain = json; if (!this.xxxDomain.wife) { // 這個處理很繁瑣 this.xxxDomain.wife = []; } }); 假設變數 xxxDomain 為實體類 ADomain 實例,實體類內部對其各欄位設置了一些初始值; 但由於 xxxService 從後端介面拿到數據後, json 對象可能並不包含 wife 欄位, 這樣當將 xxxDomain = json 賦值後,後續再使用到 xxxDomain.wife 時還得手動進行判空處理, 這種方式太過繁瑣,一旦實體結構複雜一點,層次深一點,判空邏輯會特別長,特別亂,特別煩 (後端不負責初始化,而之所以某些欄位需要初始化,是因為介面上需要該值進行呈現) 基於該需求場景,封裝了這個工具類: 【使用示例】: xxxService.getXXX().subscript(json => { DomainUtils.handleUndefined(json, ADomain); this.xxxDomain = json; }); */ export class DomainUtils { /** * 接收兩個參數,第一個是服務端返回的 json 對象,第二個是該對象對應的 class 類,內部會自動根據 class 創建一個新的空對象,然後跟 json 對象的每個屬性兩兩比較,如果在新對象中發現有某個欄位有初始值,但 json 對象上沒有,則複製過去。 */ static handleUndefined(domain: object, prop) { let o = new prop(); if (Array.isArray(domain)) { domain.forEach(value => { DomainUtils._clone(domain, o); }); } else { DomainUtils._clone(domain, o); } return domain; } private static _clone(target: object, source: object) { Object.keys(source).forEach(value => { if (!Array.isArray(source[value]) && typeof source[value] === 'object' && source[value] !== null) { if (target[value] == null) { target[value] = source[value]; } else { DomainUtils._clone(target[value] as object, source[value] as object); } } else { if (target[value] == null) { target[value] = source[value]; } } }); } }
因為直接基於業務需求場景來進行的封裝,所以我很明確參數的結構是什麼,使用的場景是什麼,很多細節就沒處理了,比如參數的校驗等。
而且,這個目的在於解決初始化問題,所以並不是一個深克隆,而是直接在原對象上進行操作,等效於將初始化的值都複製到原對象上,如果原對象同屬性沒有值的時候。