JavaScript的浅拷贝与深拷贝:程序员的「复制」哲学大冒险

170 阅读8分钟

当面试官微笑着说"手写一个深浅拷贝"时,别慌——这不过是代码世界的「克隆人战争」。且看我拆解这场拷贝宇宙的生存法则。

一、前置知识: 🧠 数据存储:V8的「记忆宫殿」

1. JS 宇宙中两大物质阵营(数据类型)

在 JS 中数据类型分为基本数据类型和引用数据类型

基本数据类型:

let s = 'hello'    //字符串 
let n = 123        //数字   
let f = true       //布尔值   
let u = undefined   //未定义   
let nu = null       //空值   
let sym = Symbol()  //符号   
let b = 123321321321n   //大整形   

引用数据类型:

let arr = []   //数组   
let obj = {}   //对象   
let fun = function () {}   //函数    
let date = new Date()      //日期    
let set = new Set()        //集合    
let map = new Map()        //映射    

2. V8的存储密室解剖图

存储法则

  • 🧱 栈内存:存放基本类型值 + 引用类型门牌号
  • 🏭 堆内存:存放引用类型本体(对象/数组等)

执行时刻

  1. V8引擎启动时创建调用栈(执行上下文舞台)

  2. 遇到引用类型时:

    • 堆内存开辟空间存放本体
    • 栈内存存入指向该空间的地址指针

二、 🔁 拷贝:代码世界的「克隆术

拷贝: 复刻一个对象,和原对象长得一模一样

在JS宇宙中,拷贝分为两大流派——浅拷贝(表面功夫)和深拷贝(灵魂复刻)。掌握它们,你就能操控对象的生死轮回!


1. 🎭 浅拷贝:只扒第一层皮的"易容术"

核心特性

  • 创建新对象外壳
  • 嵌套对象仍共享内存(修改原对象内嵌属性会影响副本)
六大神器(附代码示例):

第一大神器: Object.create(obj)

Object.create(obj) 这个方法可以帮我们创建一个新对象,并且让新对象的隐式原型指向 obj

let obj = {
    a: 1,
}
let obj2 = Object.create(obj) 
console.log(obj2.a)

第二大神器: [].concat(arr)

(arr).concat(arr2)这个方法能将 arr与 arr2 进行合并,并返回一个新数组

let arr = [1,2,3]
let arr2 = [4,5]
  console.log(arr.concat(arr2) )  //将arr2拼接到arr
[1,2,3,4,5]

所以使用 [].concat(arr)这个方法,让 arr 与一个空数组进行拼接,并生成一个新数组,能实现拷贝的目的

 let arr3 = [].concat(arr)
 console.log(arr3)

第三大神器: 数组解构 [...arr]

es6 中的解构语法:定义一个与 arr 结构体一模一样的结构,并承接 arr 中的值

//定义一个与 arr 结构体一模一样的结构
let arr = [1,2,3]
const [x,y,z] = arr
console.log(x,y,z)
1,2,3

'...'是一个常见的解构语法:允许将一个可迭代对象(如数组、字符串、Set 等)展开为多个独立的元素。

let arr = [1,2,3]
console.log(...arr)
1,2,3

所以使用[...arr]这个方法,先定义一个空数组 arr2 让他承接 arr 的解构,这样可以达到拷贝目的

let arr = [1,2,3]
let arr2 = [...arr]
console.log(arr2)
[1,2,3]

第四大神器: arr.slice(0,arr.length)

数组中的 arr.splice(start[, deleteCount[, item1, item2, ...]]);方法, start:开始修改的索引位置(从 0 开始)。若为负数,则从数组末尾倒数(如 -1 表示最后一个元素)。 deleteCount(可选):要删除的元素个数。若省略或大于剩余元素数,则删除从 start 到末尾的所有元素。 item1, item2, ...(可选):要插入到 start 位置的新元素。

let arr = ['a','b','c','d','e']
arr.splice(0,1,'0')  //从0下标切1个加一个'o'  会影响原数组
console.log(arr);
[ '0', 'b', 'c', 'd', 'e' ]

可见 arr.splice(start[, deleteCount[, item1, item2, ...]]);方法会直接改变原数组的值。而数组中的 arr.slice(start,end)方法不会改变原数组的值, start(可选):开始截取的索引位置(包含)。若省略则从 0 开始;若为负数,则从末尾倒数。 end(可选):结束截取的索引位置(不包含)。若省略则截取到末尾;若为负数,则从末尾倒数。

let arr = ['a','b','c','d','e']
let arr2 = arr.slice(0,2) //从0下标切到2但不包括2  不会影响原数组
console.log(arr2);
console.log(arr);
[ 'a', 'b' ]
[ 'a', 'b', 'c', 'd', 'e' ]

所以使用arr.slice(0,arr.length)将 arr 全部切给 arr2 达到拷贝目的


let arr = ['a','b','c','d','e']
let arr2 = arr.slice(0,arr.length) //全切就是拷贝
console.log(arr2)
console.log(arr);
[ 'a', 'b', 'c', 'd', 'e' ]
[ 'a', 'b', 'c', 'd', 'e' ]

第五大神器: Object.assign({},obj)

Object.assign(obj,girl) 方法可以将后面对象拼接到前面对象且原对象受影响

let obj = {
    name: '令狐冲',
    age: 18
}
let girl = {
    nickname: '东方不败'
}
let newObj = Object.assign(obj,girl) 
console.log(newObj)
{ name: '令狐冲', age: 18, nickname: '东方不败' }

所以使用 Object.assign({},obj) 方法,将一个空数组拼接 obj 后用 newObj 承载,达到拷贝目的

let obj = {
    name: '令狐冲',
    age: 18
}
let newObj = Object.assign({}, obj) //将后面对象拼接到前面对象 原对象不受影响
console.log(newObj)
{ name: '令狐冲', age: 18 }

第六大神器: arr.toReversed().reverse()

调用 arr.reverse()会将 arr 内容反转,且原数组会改变

let arr = [1,2,3]
let newarr = arr.reverse() 
console.log(newarr)
console.log(arr)
[ 3, 2, 1 ]
[ 3, 2, 1 ]

调用arr.toReversed() 能将数组反转,且不会影响原数组

let arr = [1,2,3]
let newarr = arr.toReversed() 
console.log(newarr)
console.log(arr)
[ 3, 2, 1 ]
[ 1, 2, 3 ]

所以我们先调用arr.toReversed() 将数组反转,且不改变原数组,再调用arr.reverse()将反转的数组再反转,用 newarr 承接,达到拷贝目的

let arr = [1,2,3]
let newArr = arr.toReversed().reverse()   //将数组反转   不会影响原数组
console.log(newArr,arr)
[ 1, 2, 3 ] [ 1, 2, 3 ]

2. 🎭 浅拷贝的致命陷阱:你以为的"复制"只是假象!

六大神器看似强大,实则都是只披着克隆外衣的共享傀儡。看这段代码如何撕开浅拷贝的伪装:

let obj={
    name:'坤坤',
    age:18,
    like:{     // 💣 嵌套对象 - 浅拷贝的爆破点
        a:'唱',
        b:'跳',
        c:'rap'
    }
}
// 使用神器5号:Object.assign() 
let newObj = Object.assign({}, obj);
/*----- 篡改时刻 -----*/
obj.age = 19;        // 修改第一层属性
obj.like.c = '🏀篮球'; // 修改嵌套属性
console.log(newObj);
{
  name: "坤坤",
  age: 18,          // ✅ 基本类型安全
  like: {           // ❌ 嵌套对象沦陷!
    a: "唱",
    b: "跳",
    c: "🏀篮球"     // 被污染了!
  }
}

🚨 六大神器的共同软肋

  1. 只克隆「外壳」不克隆「灵魂」
    就像复印身份证——卡套是新的,但里面的芯片仍是原件
  2. 嵌套对象=共享炸弹
    修改任何层级的嵌套属性都会引爆所有副本
  3. 最危险的是你以为已安全
    当基本属性保持独立时,开发者容易误以为拷贝完全成功

三、🧬 深拷贝:突破嵌套的终极克隆术

面对浅拷贝的共享诅咒,深拷贝祭出两大杀招——但各有致命缺陷!看这段代码如何揭露真相:

let obj = {
  name: '坤坤',
  age: 18,
  like: {          // 嵌套对象-深拷贝的试金石
    a: '唱',
    b: '跳',
    c: 'rap'
  },
  a: undefined,    // 幽灵属性
  b: null,         // 空值属性
  c: function() { console.log('我是函数') }, // 会消失的魔法
  d: Symbol(1),    // 唯一密钥
  e: new Date(),   // 时间胶囊
  f: new Set([1,2,3]), // 集合
  g: new Map([['key', 'value']]) // 映射
};

// 自引用陷阱(循环引用)
obj.self = obj; 

⚔️ 深拷贝方案一:JSON核弹法

let newObj = JSON.parse(JSON.stringify(obj))
// 篡改原对象
obj.like.a = '🏀篮球'; 
console.log(newObj)
{
  name: "坤坤",
  age: 18,
  like: { a: "唱", b: "跳", c: "rap" }, // ✅ 未受篡改影响
  b: null,        // null幸存
  e: "2023-05-15T12:00:00.000Z", // ❌ 日期变字符串
  f: {},           // ❌ Set被掏空
  g: {},           // ❌ Map被掏空
  self: {}         // ❌ 循环引用被腰斩
}
// 消失的属性:a(undefined), c(函数), d(Symbol)

☠️ 核弹辐射范围

数据类型下场
undefined人间蒸发
Function灰飞烟灭
Symbol量子分解
BigInt引发核爆(报错)
Date降维成字符串
Set/Map退化成空对象
循环引用引发黑洞(报错)

🛡️ 深拷贝方案二:structuredClone()

let newObj = structuredClone(obj)
console.log(newObj)
obj.like.b = '💃街舞'; 
console.log(newObj.like.b); // "跳" ✅ 未受影响
console.log(newObj.e instanceof Date); // true ✅ 
console.log(newObj.f instanceof Set); // true ✅ 
console.log(newObj.self === newObj); // true ✅ 循环引用完美闭环!

🚀 优势突破

  • 支持DateSetMap等复杂类型
  • 突破循环引用结界
  • 保留undefinednull

💔 仍存缺陷

console.log(现代副本.c); // undefined ❌ 函数消失
console.log(现代副本.d); // undefined ❌ Symbol蒸发

🔍 深拷贝能力对照表

数据类型JSON法structuredClone完美深拷贝
基本类型
嵌套对象
undefined
Function
Symbol
BigInt
Date⚠️字符串
Set/Map❌空对象
循环引用❌报错
DOM元素

💡 决策指南

  • 纯数据对象无循环引用 → JSON法(简单够用)
  • 现代浏览器环境 → structuredClone(首选方案)
  • 含函数/Symbol/循环引用 → 手写深拷贝(终极方案)

四、 ✨ 手写深拷贝

⚙️ 核心原理四步走

  1. 创建新容器
    let newObj = {} 创建空对象作为克隆基础

  2. 属性遍历
    for...in 循环获取对象所有可枚举属性

  3. 过滤原型属性
    hasOwnProperty 确保只复制对象自身属性(不复制原型链上的属性)

  4. 类型分级处理

    • typeof obj[key] === 'object' → 引用类型 → 递归深挖
    • 其他 → 原始类型 → 直接赋值
let obj = {
  name: '坤坤',
  age: 18,
  like: {
    a: '唱',
    b: '跳',
    c: 'rap',
  }}
function deepCopy(obj) {
  let newObj = {}
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 先判断 obj[key] 值的类型,如果是原始类型,直接赋值,如果是引用类型,xxxxx
      if (typeof obj[key] === 'object' && obj[key] !== null) {
        newObj[key] = deepCopy(obj[key])
      } else {
        newObj[key] = obj[key]
      }
    }
  }
  return newObj 
}

let obj2 = deepCopy(obj)
obj.like.a = '篮球'
console.log(obj2);
{ name: '坤坤', age: 18, like: { a: '唱', b: '跳', c: 'rap' } }

拷贝江湖生存指南总结🎯

JS 拷贝就像场克隆大战!浅拷贝是 "表面兄弟",用Object.assign等六大神器只能复制第一层,嵌套对象会共享内存,改原对象副本也遭殃。深拷贝才是 "灵魂复刻":JSON 法像核弹,能炸平简单对象但会让函数、Symbol 消失,Date 变字符串;structuredClone是现代武器,支持复杂类型和循环引用,却搞不定函数和 Symbol。终极杀招是手写深拷贝,通过递归层层克隆,不过得小心循环引用。记住:简单数据用 JSON,现代环境选structuredClone,复杂场景就手写,做 JS 世界的拷贝大师!