手写实现深拷贝原来这么简单

深拷贝和浅拷贝的区别

在 JS 中,数据类型 分为原始类型和引用类型,我们平常所说的深、浅拷贝都是针对引用类型而言,因为不管是深拷贝还是浅拷贝,在遇到原始类型的时候,都会直接拷贝它们的值,并无区别。

深拷贝和浅拷贝都是创建一个新的对象。区别是

  • 浅拷贝是拷贝对象的内存地址(指针),如果原对象或新对象修改了这个地址指向的对象,那么双方都会受到影响。

  • 深拷贝是从内存中完整的拷贝一个对象,拷贝后的新对象和原对象之间互不影响。

原生 JS 可以很简单实现浅拷贝,比如使用 Object.assign()

let source = { obj: { bio: 'I am an object' }, str: 'Hello' }
let target = Object.assign({}, source)

console.log(target) // { obj: { bio: "I am an object" }, str: "Hello" }

source.obj.bio = 'I am I am'
source.str = 'Hi'

console.log(source) // { obj: { bio: "I am I am" }, str: "Hi" }
console.log(target) // { obj: { bio: "I am I am" }, str: "Hello" }

我们看到,source 对象中 obj 是一个对象,对象是引用类型。当改变 objbio 时,sourcetarget 两者都会改变,这就是浅拷贝。str 是一个字符串,是原始类型,会直接拷贝值,修改原对象不会影响新对象,所以只有 source 中的发生了改变。

而想要实现一个两者互不影响的深拷贝则没有这么简单。在实际项目中,我们可能都是直接使用下面这个方法:

const a = JSON.parse(JSON.stringify(b))

这种方法在大部分情况下虽然简单可行,但面对一些特殊对象,比如对象中包含函数、循环引用等,继续使用这个方法就行不通了。下面我们就来一步步实现。

第一步:实现基本的拷贝

首先我们创建一个拷贝函数 deepClone,并且将需要拷贝的值 source 传进去。

实现拷贝,需要先在函数内部判断该对象是否属于引用类型 object,如果是,创建一个全新的对象,通过遍历将对象内的属性和值全部存入到该对象,最后将其返回。反之则说明是原始类型,原始类型不存在深拷贝概念,直接返回该原始值即可。

function deepClone(source) {
  if (typeof source === 'object') {
    let target = {}
    for (const key in source) {
      target[key] = source[key]
    }
    return target
  } else {
    return source
  }
}

第二步:处理多层对象

我们知道在 JS 中,对象内部可以包含 N 多层对象。在上一步骤,我们已经进行了类型判断,并且对对象进行了遍历拷贝,所以在这一步骤中递归调用该函数即可,直至遇到原始类型。

function deepClone(source) {
  if (typeof source === 'object') {
    let target = {}
    for (const key in source) {
      target[key] = deepClone(source[key]) // +
    }
    return target
  } else {
    return source
  }
}

第三步:处理数组 Array

在上一步中,我们给需要返回的 target 设置的是对象 {} 形式,很显然还没有考虑数组的情况。所以这里我们需要在设置 target 类型的时候判断一下 source 是否为数组。

function deepClone(source) {
  if (typeof source === 'object') {
    let target = Array.isArray(source) ? [] : {} // +
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  } else {
    return source
  }
}

第四步:处理 null、正则和日期

我们知道,虽然 null数据类型中属于原始类型,但 typeof null 却输出为 object。而正则、日期等也会输出 object。但此三者是不能被遍历的,所以在遍历对象和数组之前需要先处理一下 null、正则和日期,防止进入遍历。

function deepClone(source) {
  // 处理 null
  if (source === null) return source // +
  // 处理正则
  if (source instanceof RegExp) return new RegExp(source) // +
  // 处理日期
  if (source instanceof Date) return new Date(source) // +
  // 处理对象和数组
  if (typeof source === 'object') {
    let target = Array.isArray(source) ? [] : {}
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  }

  // 处理原始类型
  return source
}

第五步:处理 Symbol

Symbol 虽然属于原始类型,但同时它拥有一个特性,就是每个从 Symbol() 返回的 symbol 值都是唯一的。如果按照上面的步骤直接返回同一个 Symbol,那显然就违背了它的特性。所以这里我们需要重新生成一个相同描述的 Symbol

function deepClone(source) {
  // 处理 null
  if (source === null) return source

  // 处理正则
  if (source instanceof RegExp) return new RegExp(source)

  // 处理日期
  if (source instanceof Date) return new Date(source)

  // 处理 Symbol
  if (typeof source === 'symbol') return Symbol(source.description) // +

  // 处理对象和数组
  if (typeof source === 'object') {
    let target = Array.isArray(source) ? [] : {}
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  }

  // 处理原始类型
  return source
}

第六步:处理 Map 和 Set

在上面的步骤中,我们使用花括号 {} 收集了对象,但是如果用同样的方式收集 Map 和 Set,则失去了拷贝的准确性。为此,我们需要单独对它们进行处理。

function deepClone(source) {
  // 处理 null
  if (source === null) return source

  // 处理正则
  if (source instanceof RegExp) return new RegExp(source)

  // 处理日期
  if (source instanceof Date) return new Date(source)

  // 处理 Symbol
  if (typeof source === 'symbol') return Symbol(source.description)

  // 处理 Map
  if (Object.prototype.toString.call(source) === '[object Map]') {
    let target = new Map()
    source.forEach((value, key) => {
      target.set(key, deepClone(value))
    })
    return target
  }

  // 处理 Set
  if (Object.prototype.toString.call(source) === '[object Set]') {
    let starget = new Set()
    source.forEach(value => {
      target.add(deepClone(value))
    })
    return target
  }

  // 处理对象和数组
  if (typeof source === 'object') {
    let target = Array.isArray(source) ? [] : {}
    for (const key in source) {
      target[key] = deepClone(source[key])
    }
    return target
  }

  // 处理原始类型
  return source
}

第七步:处理循环引用

此时我们对类型的处理已经结束,还有一些其他的类型没有考虑,比如 DOM 元素,这里不再赘述。因为还有更重要的问题。

在下面的代码中,source 对象的 obj 属性,引用了其本身。这个时候还能进行深拷贝吗?

let source = {
  str: 'hello',
  arr: [1, 2, 3],
}

source.obj = source

let target = deepClone(source)

我们试着调用一下 deepClone 方法,发现控制台抛出了以下错误:

RangeError: Maximum call stack size exceeded

这是因为 deepClone 处理对象时递归调用了 deepClone 方法,而 source 也在引用自身,所以会无限递归下去,从而造成了内存溢出问题。

所以为了防止无限递归,我们还需要先判断有没有循环引用,如果出现循环引用,则直接返回,不再递归,从而避免出现内存溢出。

此时,我们需要额外的存储空间进行记录。当需要拷贝自身时,去这个存储空间查找是否已经存在该对象即可。

WeakMap 正好可以满足这种需求。

function deepClone(source, map = new WeakMap()) {
  // 处理 null
  if (source === null) return source

  // 处理正则
  if (source instanceof RegExp) return new RegExp(source)

  // 处理日期
  if (source instanceof Date) return new Date(source)

  // 处理 Symbol
  if (typeof source === 'symbol') return Symbol(source.description)

  // 处理原始类型
  if (typeof source !== 'object') return source

  // 创建 target 实例
  let target = new source.constructor()

  // 处理循环引用
  if (map.get(source)) {
    return source
  } else {
    map.set(source, target)
  }

  // 处理 Map
  if (Object.prototype.toString.call(source) === '[object Map]') {
    source.forEach((value, key) => {
      target.set(key, deepClone(value), map)
    })
    return target
  }

  // 处理 Set
  if (Object.prototype.toString.call(source) === '[object Set]') {
    source.forEach(value => {
      target.add(deepClone(value), map)
    })
    return target
  }

  // 处理对象和数组
  if (typeof source === 'object') {
    for (const key in source) {
      target[key] = deepClone(source[key], map)
    }
    return target
  }
}

另外,在之前的代码在处理 Map、Set、对象和数组时分别创建了实例,比较繁琐,所以改用了 Object 原型上的 constructor 构造函数。调用该函数会自动创建实例对象。

第八步:优化

我们在处理非 object 类型之后,一共处理了四个可遍历的类型。但此代码存在两个问题,一是仍然有可遍历的类型,二是并不是所有 object 类型都有可执行的 constructor 构造函数。所以我们需要对此进行优化处理。

// 可遍历对象
// 如果想处理其他的可遍历对象,比如函数的 arguments,可加入此数组,便于维护
const iterations = [
  '[object Object]',
  '[object Array]',
  '[object Map]',
  '[object Set]',
]

function deepClone(source, map = new WeakMap()) {
  // 处理 null
  if (source === null) return source

  // 获取对象类型
  const type = Object.prototype.toString.call(source)

  // 处理不可遍历对象
  if (!iterations.includes(type)) {
    // 处理日期
    if (type === '[object Date]') return new Date(source)

    // 处理正则
    if (type === '[object RegExp]') return new RegExp(source)

    // 处理 Symbol
    if (type === '[object Symbol]') return Symbol(source.description)

    // 其他未处理的类型,一般是原始类型或函数,直接返回
    return source
  }

  // 处理可遍历对象
  // 创建 target 实例
  let target = new source.constructor() // {} | [] | Map(0) | Set(0)

  // 处理循环引用,防止死循环
  if (map.get(source)) {
    return source // 如果已经处理过,则直接返回,不再遍历
  } else {
    map.set(source, target)
  }

  // 处理 Map
  if (type === '[object Map]') {
    source.forEach((value, key) => {
      target.set(key, deepClone(value), map)
    })
    return target
  }

  // 处理 Set
  if (type === '[object Set]') {
    source.forEach(value => {
      target.add(deepClone(value), map)
    })
    return target
  }

  // 处理对象和数组
  for (const key in source) {
    target[key] = deepClone(source[key], map)
  }
  return target
}

总结

到这里,我们实现的 deepClone 方法已经能够应对大部分情况了。但是仍然存在许多不足,尤其是对类型的判断和性能问题,还有很大的改进空间。但我们的目的在于——了解深拷贝的作用、原理以及对类型的更深入了解等方面。在实际项目中,更推荐使用成熟的开源库,比如 lodash 的 cloneDeep 方法


参考资料:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,612评论 5 471
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,345评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,625评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,022评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,974评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,227评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,688评论 3 392
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,358评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,490评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,402评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,446评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,126评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,721评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,802评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,013评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,504评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,080评论 2 341

推荐阅读更多精彩内容