深入 js 深拷貝對(duì)象

前言

對(duì)象是 JS 中基本類型之一,而且和原型鏈够庙、數(shù)組等知識(shí)息息相關(guān)晶衷。不管是面試中蓝纲,還是實(shí)際開發(fā)中我們都會(huì)碰見深拷貝對(duì)象的問題。

顧名思義晌纫,深拷貝就是完完整整的將一個(gè)對(duì)象從內(nèi)存中拷貝一份出來税迷。所以無論用什么辦法,必然繞不開開辟一塊新的內(nèi)存空間锹漱。

通常有下面兩種方法實(shí)現(xiàn)深拷貝:

  1. 迭代遞歸法
  2. 序列化反序列化法

我們會(huì)基于一個(gè)測(cè)試用例對(duì)常用的實(shí)現(xiàn)方法進(jìn)行測(cè)試并對(duì)比優(yōu)劣:

let test = {
    num: 0,
    str: '',
    boolean: true,
    unf: undefined,
    nul: null,
    obj: {
        name: '我是一個(gè)對(duì)象',
        id: 1
    },
    arr: [0, 1, 2],
    func: function() {
        console.log('我是一個(gè)函數(shù)')
    },
    date: new Date(0),
    reg: new RegExp('/我是一個(gè)正則/ig'),
    err: new Error('我是一個(gè)錯(cuò)誤')
}

let result = deepClone(test)

console.log(result)
for (let key in result) {
    if (isObject(result[key]))
        console.log(`${key}相同嗎箭养? `, result[key] === test[key])
}

// 判斷是否為對(duì)象
function isObject(o) {
    return (typeof o === 'object' || typeof o === 'function') && o !== null
}

迭代遞歸法

這是最常規(guī)的方法,思想很簡單:就是對(duì)對(duì)象進(jìn)行迭代操作哥牍,對(duì)它的每個(gè)值進(jìn)行遞歸深拷貝毕泌。

for...in

// 迭代遞歸法:深拷貝對(duì)象與數(shù)組
function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj 不是一個(gè)對(duì)象!')
    }

    let isArray = Array.isArray(obj)
    let cloneObj = isArray ? [] : {}
    for (let key in obj) {
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
    }

    return cloneObj
}

結(jié)果:


迭代遞歸法結(jié)果.png

我們發(fā)現(xiàn)嗅辣,arr 和 obj 都深拷貝成功了懈词,它們的內(nèi)存引用已經(jīng)不同了,但 func辩诞、date、reg 和 err 并沒有復(fù)制成功纺涤,因?yàn)樗鼈冇刑厥獾臉?gòu)造函數(shù)译暂。

Reflect 法

// 代理法
function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj 不是一個(gè)對(duì)象抠忘!')
    }

    let isArray = Array.isArray(obj)
    let cloneObj = isArray ? [...obj] : { ...obj }
    Reflect.ownKeys(cloneObj).forEach(key => {
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
    })

    return cloneObj
}

結(jié)果:


代理法結(jié)果

我們發(fā)現(xiàn),結(jié)果和使用 for...in 一樣外永。那么它有什么優(yōu)點(diǎn)呢崎脉?讀者可以先猜一猜,答案我們會(huì)在下文揭曉伯顶。

lodash中的深拷貝實(shí)現(xiàn)

著名的 lodash 中的 cloneDeep 方法同樣是使用這種方法實(shí)現(xiàn)的囚灼,只不過它支持的對(duì)象種類更多,具體的實(shí)現(xiàn)過程讀者可以參考 lodash 的 baseClone 方法祭衩。

我們把測(cè)試用例用到的深拷貝函數(shù)換成lodash的:

let result = _.cloneDeep(test)

結(jié)果:


lodash深拷貝結(jié)果.png

我們發(fā)現(xiàn)灶体,arr、obj掐暮、date蝎抽、reg深拷貝成功了,但 func 和 err 內(nèi)存引用仍然不變路克。

為什么不變呢樟结?這個(gè)問題留給讀者自己去探尋,嘿嘿~不過可以提示下精算,這跟 lodash 中的 cloneableTags 有關(guān)瓢宦。

由于前端中的對(duì)象種類太多了,所以 lodash 也給用戶準(zhǔn)備了自定義深拷貝的方法 cloneDeepWith灰羽,比如自定義深拷貝 DOM 對(duì)象:

function customizer(value) {
  if (_.isElement(value)) {
    return value.cloneNode(true);
  }
}
 
var el = _.cloneDeepWith(document.body, customizer);
 
console.log(el === document.body);
// => false
console.log(el.nodeName);
// => 'BODY'
console.log(el.childNodes.length);
// => 20

序列化反序列化法

這個(gè)方法非常有趣驮履,它先把代碼序列化成數(shù)據(jù),再反序列化回對(duì)象:

// 序列化反序列化法
function deepClone(obj) {
    return JSON.parse(JSON.stringify(obj))
}

結(jié)果:


序列化反序列化法結(jié)果.png

我們發(fā)現(xiàn)谦趣,它也只能深拷貝對(duì)象和數(shù)組疲吸,對(duì)于其他種類的對(duì)象,會(huì)失真前鹅。這種方法比較適合平常開發(fā)中使用摘悴,因?yàn)橥ǔ2恍枰紤]對(duì)象和數(shù)組之外的類型。

進(jìn)階

  1. 對(duì)象成環(huán)怎么辦舰绘?
    我們給 test 加一個(gè) loopObj 鍵蹂喻,值指向自身:
test.loopObj = test

這時(shí)我們使用第一種方法中的 for..in 實(shí)現(xiàn)和 Reflect 實(shí)現(xiàn)都會(huì)棧溢出:


環(huán)對(duì)象深拷貝報(bào)錯(cuò)

而使用第二種方法也會(huì)報(bào)錯(cuò):


但 lodash 卻可以得到正確結(jié)果:


lodash 深拷貝環(huán)對(duì)象.png

為什么呢?我們?nèi)?lodash 源碼看看:


lodash 應(yīng)對(duì)環(huán)對(duì)象辦法.png

因?yàn)?lodash 使用的是棧把對(duì)象存儲(chǔ)起來了捂寿,如果有環(huán)對(duì)象口四,就會(huì)從棧里檢測(cè)到,從而直接返回結(jié)果秦陋,懸崖勒馬蔓彩。這種算法思想來源于 HTML5 規(guī)范定義的結(jié)構(gòu)化克隆算法,它同時(shí)也解釋了為什么 lodash 不對(duì) Error 和 Function 類型進(jìn)行拷貝。

當(dāng)然赤嚼,設(shè)置一個(gè)哈希表存儲(chǔ)已拷貝過的對(duì)象同樣可以達(dá)到同樣的目的:

function deepClone(obj, hash = new WeakMap()) {
    if (!isObject(obj)) {
        return obj
    }
    // 查表
    if (hash.has(obj)) return hash.get(obj)

    let isArray = Array.isArray(obj)
    let cloneObj = isArray ? [] : {}
    // 哈希表設(shè)值
    hash.set(obj, cloneObj)

    let result = Object.keys(obj).map(key => {
        return {
            [key]: deepClone(obj[key], hash)
        }
    })
    return Object.assign(cloneObj, ...result)
}

這里我們使用 WeakMap 作為哈希表旷赖,因?yàn)樗逆I是弱引用的,而我們這個(gè)場(chǎng)景里鍵恰好是對(duì)象更卒,需要弱引用等孵。

  1. 鍵值不是字符串而是 Symbol

我們修改一下測(cè)試用例:

var test = {}
let sym = Symbol('我是一個(gè)Symbol')
test[sym] = 'symbol'

let result = deepClone(test)
console.log(result)
console.log(result[sym] === test[sym])

運(yùn)行 for...in 實(shí)現(xiàn)的深拷貝我們會(huì)發(fā)現(xiàn):


拷貝失敗了,為什么蹂空?

因?yàn)?Symbol 是一種特殊的數(shù)據(jù)類型俯萌,它最大的特點(diǎn)便是獨(dú)一無二,所以它的深拷貝就是淺拷貝上枕。

但如果這時(shí)我們使用 Reflect 實(shí)現(xiàn)的版本:


成功了咐熙,因?yàn)?for...in 無法獲得 Symbol 類型的鍵,而 Reflect 是可以獲取的姿骏。

當(dāng)然糖声,我們改造一下 for...in 實(shí)現(xiàn)也可以:

function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj 不是一個(gè)對(duì)象!')
    }

    let isArray = Array.isArray(obj)
    let cloneObj = isArray ? [] : {}
    let symKeys = Object.getOwnPropertySymbols(obj)
    // console.log(symKey)
    if (symKeys.length > 0) {
        symKeys.forEach(symKey => {
            cloneObj[symKey] =  isObject(obj[symKey]) ? deepClone(obj[symKey]) : obj[symKey]
        })
    }
    for (let key in obj) {
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
    }

    return cloneObj
}
  1. 拷貝原型上的屬性

眾所周知分瘦,JS 對(duì)象是基于原型鏈設(shè)計(jì)的蘸泻,所以當(dāng)一個(gè)對(duì)象的屬性查找不到時(shí)會(huì)沿著它的原型鏈向上查找,也就是一個(gè)非構(gòu)造函數(shù)對(duì)象的 __proto__ 屬性嘲玫。

我們創(chuàng)建一個(gè) childTest 變量悦施,讓 result 為它的深拷貝結(jié)果,其他不變:

let childTest = Object.create(test)
let result = deepClone(childTest)

這時(shí)去团,我們最初提供的四種實(shí)現(xiàn)只有 for...in 的實(shí)現(xiàn)能正確拷貝抡诞,為什么呢?原因還是在結(jié)構(gòu)化克隆算法里:原形鏈上的屬性也不會(huì)被追蹤以及復(fù)制土陪。

落在具體實(shí)現(xiàn)上就是:for...in 會(huì)追蹤原型鏈上的屬性昼汗,而其它三種方法(Object.keys、Reflect.ownKeys 和 JSON 方法)都不會(huì)追蹤原型鏈上的屬性:


  1. 需要拷貝不可枚舉的屬性
    第四種情況鬼雀,就是我們需要拷貝類似屬性描述符顷窒,setters 以及 getters 這樣不可枚舉的屬性,一般來說源哩,這就需要一個(gè)額外的不可枚舉的屬性集合來存儲(chǔ)它們鞋吉。類似在第二種情況使用 for...in 拷貝 Symbol 類型鍵時(shí):
    我們給 test 變量里的 obj 和 arr 屬性定義一下屬性描述符:
Object.defineProperties(test, {
    'obj': {
        writable: false,
        enumerable: false,
        configurable: false
    },
    'arr': {
        get() {
            console.log('調(diào)用了get')
            return [1,2,3]
        },
        set(val) {
            console.log('調(diào)用了set')
        }
    }
})

然后實(shí)現(xiàn)我們的拷貝不可枚舉屬性的版本:

function deepClone(obj, hash = new WeakMap()) {
    if (!isObject(obj)) {
        return obj
    }
    // 查表,防止循環(huán)拷貝
    if (hash.has(obj)) return hash.get(obj)

    let isArray = Array.isArray(obj)
    // 初始化拷貝對(duì)象
    let cloneObj = isArray ? [] : {}
    // 哈希表設(shè)值
    hash.set(obj, cloneObj)
    // 獲取源對(duì)象所有屬性描述符
    let allDesc = Object.getOwnPropertyDescriptors(obj)
    // 獲取源對(duì)象所有的 Symbol 類型鍵
    let symKeys = Object.getOwnPropertySymbols(obj)
    // 拷貝 Symbol 類型鍵對(duì)應(yīng)的屬性
    if (symKeys.length > 0) {
        symKeys.forEach(symKey => {
            cloneObj[symKey] = isObject(obj[symKey]) ? deepClone(obj[symKey], hash) : obj[symKey]
        })
    }

    // 拷貝不可枚舉屬性,因?yàn)?allDesc 的 value 是淺拷貝励烦,所以要放在前面
    cloneObj = Object.create(
        Object.getPrototypeOf(cloneObj),
        allDesc
    )
    // 拷貝可枚舉屬性(包括原型鏈上的)
    for (let key in obj) {
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key], hash) : obj[key];
    }

    return cloneObj
}

結(jié)果:


結(jié)語

  1. 日常深拷貝谓着,建議序列化反序列化方法。
  2. 面試時(shí)遇見面試官搞事情坛掠,寫一個(gè)能拷貝自身可枚舉赊锚、自身不可枚舉治筒、自身 Symbol 類型鍵、原型上可枚舉改抡、原型上不可枚舉矢炼、原型上的 Symol 類型鍵,循環(huán)引用也可以拷的深拷貝函數(shù):
// 將之前寫的 deepClone 函數(shù)封裝一下
function cloneDeep(obj) {
    let family = {}
    let parent = Object.getPrototypeOf(obj)

    while (parent != null) {
        family = completeAssign(deepClone(family), parent)
        parent = Object.getPrototypeOf(parent)
    }

    // 下面這個(gè)函數(shù)會(huì)拷貝所有自有屬性的屬性描述符,來自于 MDN
    // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
    function completeAssign(target, ...sources) {
        sources.forEach(source => {
            let descriptors = Object.keys(source).reduce((descriptors, key) => {
                descriptors[key] = Object.getOwnPropertyDescriptor(source, key)
                return descriptors
            }, {})

            // Object.assign 默認(rèn)也會(huì)拷貝可枚舉的Symbols
            Object.getOwnPropertySymbols(source).forEach(sym => {
                let descriptor = Object.getOwnPropertyDescriptor(source, sym)
                if (descriptor.enumerable) {
                    descriptors[sym] = descriptor
                }
            })
            Object.defineProperties(target, descriptors)
        })
        return target
    }

    return completeAssign(deepClone(obj), family)
}

  1. 有特殊需求的深拷貝阿纤,建議使用 lodash 的 copyDeep 或 copyDeepWith 方法。

最后感謝一下知乎上關(guān)于這個(gè)問題的提問的啟發(fā)夷陋,無論做什么欠拾,盡量不要把簡單的事情復(fù)雜化,深拷貝能不用就不用骗绕,它面對(duì)的問題往往可以用更優(yōu)雅的方式解決藐窄,當(dāng)然面試的時(shí)候裝個(gè)逼是可以的。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末酬土,一起剝皮案震驚了整個(gè)濱河市荆忍,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌撤缴,老刑警劉巖刹枉,帶你破解...
    沈念sama閱讀 207,248評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異屈呕,居然都是意外死亡微宝,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評(píng)論 2 381
  • 文/潘曉璐 我一進(jìn)店門虎眨,熙熙樓的掌柜王于貴愁眉苦臉地迎上來蟋软,“玉大人,你說我怎么就攤上這事嗽桩≡朗兀” “怎么了?”我有些...
    開封第一講書人閱讀 153,443評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵碌冶,是天一觀的道長湿痢。 經(jīng)常有香客問我,道長种樱,這世上最難降的妖魔是什么蒙袍? 我笑而不...
    開封第一講書人閱讀 55,475評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮嫩挤,結(jié)果婚禮上害幅,老公的妹妹穿的比我還像新娘。我一直安慰自己岂昭,他們只是感情好以现,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,458評(píng)論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般邑遏。 火紅的嫁衣襯著肌膚如雪佣赖。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,185評(píng)論 1 284
  • 那天记盒,我揣著相機(jī)與錄音憎蛤,去河邊找鬼。 笑死纪吮,一個(gè)胖子當(dāng)著我的面吹牛俩檬,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播碾盟,決...
    沈念sama閱讀 38,451評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼棚辽,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了冰肴?” 一聲冷哼從身側(cè)響起屈藐,我...
    開封第一講書人閱讀 37,112評(píng)論 0 261
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎熙尉,沒想到半個(gè)月后联逻,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,609評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡骡尽,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,083評(píng)論 2 325
  • 正文 我和宋清朗相戀三年遣妥,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片攀细。...
    茶點(diǎn)故事閱讀 38,163評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡箫踩,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出谭贪,到底是詐尸還是另有隱情境钟,我是刑警寧澤,帶...
    沈念sama閱讀 33,803評(píng)論 4 323
  • 正文 年R本政府宣布俭识,位于F島的核電站慨削,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏套媚。R本人自食惡果不足惜缚态,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,357評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望堤瘤。 院中可真熱鬧玫芦,春花似錦、人聲如沸本辐。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,357評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至老虫,卻和暖如春叶骨,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背祈匙。 一陣腳步聲響...
    開封第一講書人閱讀 31,590評(píng)論 1 261
  • 我被黑心中介騙來泰國打工忽刽, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人菊卷。 一個(gè)月前我還...
    沈念sama閱讀 45,636評(píng)論 2 355
  • 正文 我出身青樓缔恳,卻偏偏與公主長得像,于是被迫代替她去往敵國和親洁闰。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,925評(píng)論 2 344

推薦閱讀更多精彩內(nèi)容