擴展 Object.assign 實現(xiàn)深拷貝
需求場景
上一篇文章:手寫實現(xiàn)深拷貝中疼蛾,我們講了淺拷貝和深拷貝趴泌,也實現(xiàn)了深拷貝方案薯鳍。
但深拷貝富寿,它是基于一個原對象见秽,完完整整拷貝一份新對象出來盔然,假如我們的需求是要將原對象上的屬性完完整整拷貝到另外一個已存在的對象上桅打,這時候深拷貝就有點無能為力了。
就有點類似于 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}
將一個原對象上的屬性拷貝到另一個目標對象上愈案,最終結(jié)果取兩個對象的并集挺尾,如果有沖突的屬性,則以原對象上屬性為主站绪,表現(xiàn)上就是直接覆蓋過去遭铺,這是 Object.assign() 方法的用途。
但很可惜的是恢准,Object.assign 只是淺拷貝魂挂,它只處理第一層屬性,如果屬性是基本類型馁筐,則值拷貝涂召,如果是對象類型,則引用拷貝敏沉,如果有沖突果正,則整個覆蓋過去。
這往往不符合我們的需求場景盟迟,講個實際中常接觸的場景:
在一些表單操作頁面秋泳,頁面初始化時可能會先前端本地創(chuàng)建一個對象來存儲表單項,對象中可能會有一些初始值攒菠,然后訪問了后臺接口轮锥,讀取當前頁的表單數(shù)據(jù),后臺返回了 json 對象要尔,這時候我們希望當前頁的表單存儲對象應(yīng)該是后臺返回的 json 對象和初始創(chuàng)建的對象的并集舍杜,有沖突以后臺返回的為主,如:
var a = {
a: {
a: 1
}
}
var o = {
a: {
b: 2
}
}
// 我們希望得到的是:
{
a: {
a: 1,
b: 2
}
}
Object.assign(a, b); // {a: {b: 2}}
其實赵辕,說白了既绩,這種需求就是希望可以進行深拷貝,而且是深拷貝到一個目標對象上还惠。
上一篇的深拷貝方案雖然可以實現(xiàn)深度拷貝饲握,但卻不支持拷貝到一個目標對象上,而 Object.assign 雖然支持拷貝到目標對象上,但它只是淺拷貝救欧,只處理第一層屬性的拷貝衰粹。所以,兩種方案都不適用于該場景笆怠。
但兩種方案結(jié)合一下铝耻,其實也就是該需求的實現(xiàn)方案了,所以要么擴展深拷貝方案蹬刷,增加與目標對象屬性的交集處理和沖突處理瓢捉;要么擴展 Object.assign,讓它支持深拷貝办成。
實現(xiàn)方案
本篇就選擇基于 Object.assign泡态,擴展支持深拷貝:assignDeep。
這里同樣會給出幾個方案迂卢,因為深拷貝的實現(xiàn)可以用遞歸某弦,也可以用循環(huán),遞歸比較好寫而克、易懂刀崖,但有棧溢出問題;循環(huán)比較難寫拍摇,但沒有棧溢出問題亮钦。
遞歸版
function assignDeep(target, ...sources) {
// 1. 參數(shù)校驗
if (target == null) {
throw new TypeError('Cannot convert undefined or null to object');
}
// 2. 如果是基本類型數(shù)據(jù)轉(zhuǎn)為包裝對象
let result = Object(target);
// 3. 緩存已拷貝過的對象,解決引用關(guān)系丟失問題
if (!result['__hash__']) {
result['__hash__'] = new WeakMap();
}
let hash = result['__hash__'];
sources.forEach(v => {
// 4. 如果是基本類型數(shù)據(jù)轉(zhuǎn)為對象類型
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;
}
要注意的地方映穗,其實也就是模擬實現(xiàn) Object.assign 的一些細節(jié)處理,比如參數(shù)校驗幕随,參數(shù)處理蚁滋,屬性遍歷,以及引用關(guān)系丟失問題赘淮。
循環(huán)版
function assignDeep(target, ...sources) {
// 1. 參數(shù)校驗
if (target == null) {
throw new TypeError('Cannot convert undefined or null to object');
}
// 2. 如果是基本類型辕录,則轉(zhuǎn)換包裝對象
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 等新的數(shù)據(jù)結(jié)構(gòu)類型
- get走诞,set 存取器邏輯無法拷貝
- 沒有考慮屬性值是內(nèi)置對象的場景,比如 /sfds/ 正則蛤高,或 new Date() 日期這些類型的數(shù)據(jù)
- 為了解決循環(huán)引用和引用關(guān)系丟失問題而加入的 hash 緩存無法識別一些屬性沖突場景蚣旱,導(dǎo)致同時存在沖突和循環(huán)引用時碑幅,拷貝的結(jié)果可能有誤
- 等等未發(fā)現(xiàn)的邏輯問題坑
雖然有一些小問題,但基本適用于大多數(shù)場景了塞绿,出問題時再想辦法慢慢填坑沟涨,目前這樣足夠使用了,而且异吻,當目標對象是空對象時裹赴,此時也可以當做深拷貝來使用。
當然涧黄,也歡迎指點一下篮昧。
TypeScript 業(yè)務(wù)版
根據(jù)實際項目中的業(yè)務(wù)需求赋荆,進行的相關(guān)處理笋妥,就沒必要像上面的通用版考慮那么多細節(jié),比如我項目中使用 ts 開發(fā)窄潭,業(yè)務(wù)需求是要解決實體類數(shù)據(jù)的初始化和服務(wù)端返回的實體類的交集合并場景春宣。
另外,只有對象類型的屬性需要進行交集處理嫉你,其余類型均直接覆蓋即可:
/**
【需求場景】:
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 = [];
}
});
假設(shè)變量 xxxDomain 為實體類 ADomain 實例月帝,實體類內(nèi)部對其各字段設(shè)置了一些初始值;
但由于 xxxService 從后端接口拿到數(shù)據(jù)后幽污, json 對象可能并不包含 wife 字段嚷辅,
這樣當將 xxxDomain = json 賦值后,后續(xù)再使用到 xxxDomain.wife 時還得手動進行判空處理距误,
這種方式太過繁瑣簸搞,一旦實體結(jié)構(gòu)復(fù)雜一點,層次深一點准潭,判空邏輯會特別長趁俊,特別亂,特別煩
(后端不負責(zé)初始化刑然,而之所以某些字段需要初始化寺擂,是因為界面上需要該值進行呈現(xiàn))
基于該需求場景,封裝了這個工具類:
【使用示例】:
xxxService.getXXX().subscript(json => {
DomainUtils.handleUndefined(json, ADomain);
this.xxxDomain = json;
});
*/
export class DomainUtils {
/**
* 接收兩個參數(shù)泼掠,第一個是服務(wù)端返回的 json 對象怔软,第二個是該對象對應(yīng)的 class 類,內(nèi)部會自動根據(jù) class 創(chuàng)建一個新的空對象择镇,然后跟 json 對象的每個屬性兩兩比較爽雄,如果在新對象中發(fā)現(xiàn)有某個字段有初始值,但 json 對象上沒有沐鼠,則復(fù)制過去挚瘟。
*/
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];
}
}
});
}
}
因為直接基于業(yè)務(wù)需求場景來進行的封裝叹谁,所以我很明確參數(shù)的結(jié)構(gòu)是什么,使用的場景是什么乘盖,很多細節(jié)就沒處理了焰檩,比如參數(shù)的校驗等。
而且订框,這個目的在于解決初始化問題析苫,所以并不是一個深克隆,而是直接在原對象上進行操作穿扳,等效于將初始化的值都復(fù)制到原對象上衩侥,如果原對象同屬性沒有值的時候。