参透 JavaScript —— 解析浅拷贝、深拷贝及手写实现

前言

本篇文章主要讲解浅拷贝和深拷贝

浅拷贝和深拷贝

JS 数据类型分为基本数据类型和引用数据类型,浅拷贝和深拷贝的行为在对基本类型时无区别,都是拷贝其值,两者的区别,主要体现在对引用类型的处理上

浅拷贝的行为是只进行第一层复制,对基本类型的属性进行值拷贝,对引用类型的属性进行内存地址拷贝(因此嵌套的引用类型,修改还是会互相影响)

深拷贝是递归复制对象的所有层级,从原对象上完整拷贝出一个新对象,新对象和原对象互不影响,也不相等

浅拷贝的实现方式

Object.assign 或展开运算符 ...

Object 对象上存在一个静态方法 assignMDN Object.assign()

Object.assign 的作用是可以把多个对象的自有属性(可枚举)复制到一个目标对象,并且返回复制后的目标对象

ES 6 新增了展开语法(...),可以很方便的来展开对象、数组、字符串等可迭代类型

使用 Object.assign 或展开运算符 ... 都可以实现浅拷贝

js 复制代码
let o1 = {
  name:'张三',
  hobby:['吃饭','睡觉','打豆豆'],
}

let o2 = Object.assign({}, o1)

// 或者使用展开运算符(...)
//let o2 = {...o1}

o2.name = '李四' // 不会影响原对象

o2.hobby.push('学习') // 嵌套引用类型,共享内存地址,会改变原对象

console.log(o1) // { name: '张三', hobby: [ '吃饭', '睡觉', '打豆豆', '学习' ] }

console.log(o2) // { name: '李四', hobby: [ '吃饭', '睡觉', '打豆豆', '学习' ] }

Array.prototype.slice() 与 Array.prototype.concat()

Array 数组上存在 sliceconcat 两个实例方法,都可以实现数组的浅拷贝

Array.prototype.slice() 方法用于数组分割,传入分割开始和结束的索引,返回指定的新数组,不影响原始数组

Array.prototype.concat() 方法用于合并两个或多个数组。返回一个合并后的数组,不影响原始数组

js 复制代码
const originArr = ['a','b','c',['d','e']]

const arr1 = originArr.slice()

const arr2 = originArr.concat()

arr1.push('f') // 不会影响原数组

arr2.push('g') // 不会影响原数组

arr1[3].push('h') // 嵌套引用类型,会影响到 originArr、arr2 

console.log(arr1) // [ 'a', 'b', 'c', [ 'd', 'e', 'h' ], 'f' ]

console.log(arr2) // [ 'a', 'b', 'c', [ 'd', 'e', 'h' ], 'g' ]

console.log(originArr) // [ 'a', 'b', 'c', [ 'd', 'e', 'h' ] ]

lodash.clone

lodash 库 提供了 clone 方法,用于浅拷贝

js 复制代码
var _ = require('lodash');

let o1 = {
  name:'张三',
  hobby:['吃饭','睡觉','打豆豆'],
}
 
let cloneResult = _.clone(o1);

cloneResult.hobby.push('学习')

console.log(cloneResult) // { name: '张三', hobby: [ '吃饭', '睡觉', '打豆豆', '学习' ] }

console.log(o1) // { name: '张三', hobby: [ '吃饭', '睡觉', '打豆豆', '学习' ] }

手动实现浅拷贝

写一个考虑对象和数组的 shallowClone 函数

js 复制代码
function shallowClone(params){
  // 可能是基本类型或 Null,直接返回
  if(typeof params !== 'object' || !params) return params;

  const result = Array.isArray(params) ? [] : {};

  for (const key of Object.keys(params)) {
    result[key] = params[key];
  }

  return result
}

const o1 = {
    name:'张三',
    age:18,
    sex:'男',
}

const arr1 = [1,2,3,4,5]

console.log(shallowClone(o1)) // { name: '张三', age: 18, sex: '男' }

console.log(shallowClone(arr1)) // [ 1, 2, 3, 4, 5 ]

使用 Object.keys 静态方法获取对象本身可枚举的字符串属性数组

深拷贝的实现方式

JSON.parse(JSON.stringify())

这是一种最简单粗暴的深拷贝方式

JSON.stringify() 静态方法将一个对象或值转化为 JSON 字符串

JSON.parse() 静态方法将一个 JSON 字符串解析回 JavaScript 对象

先把目标序列化为 JSON 字符串,再反序列化为 JS 对象,就实现了一个深拷贝,这在大部分情况下是可行的

但注意,这个方法不能处理以下情况

  • 属性有 Function 函数、undefinedSymbol 等,会被忽略
  • 属性有 Date 对象 等,会被转换为字符串
  • 属性有 RegExp 对象、Error 对象等,会被转换为空对象
  • 属性有 NaNInfinity-Infinity,会被转换为 null
js 复制代码
const obj = {
  name:'张三',
  age:18,
  sex: undefined,
  date:new Date(),
  getName(){
    console.log(name)
  }
}

JSON.parse(JSON.stringify(obj)) // { name: '张三', age: 18, date: '2025-08-20T13:55:14.625Z' }

lodash.cloneDeep

lodash 库提供了一个 cloneDeep 方法,用于深拷贝

js 复制代码
const _ = require('lodash');

const obj = {
  name:'张三',
  age:18,
  sex: undefined,
  getName(){
    console.log(name)
  }
}

const deepClone = _.cloneDeep(obj)

deepClone.getName() // 张三

手动实现深拷贝

手写实现深拷贝,想一想我们需要考虑哪些方面:

  1. 处理基本类型和 null 等,直接返回
  2. 考虑数据的嵌套结构,使用递归处理
  3. 处理数据内的引用类型,除了 ObjectArray 这些外,还有比如 DateRegExp、SetMap 等 这些引用类型有的实现了迭代器方法,也就是可以遍历的类型,这意味着可能存在嵌套的数据,因此处理方法也需要递归,比如 SetMap
  4. 不能遍历的引用类型,没有层级嵌套,所以处理的时候只需要拷贝一份副本返回

根据这个思路,目前实现的深拷贝是这样的:

部分代码参考了 lodash 库源码

js 复制代码
const protoString = Object.prototype.toString;

const setTag = "[object Set]";
const mapTag = "[object Map]";
const dateTag = "[object Date]";
const regexpTag = "[object RegExp]";

const arrayTag = "[object Array]";
const objectTag = "[object Object]";

// 深拷贝
function deepClone(params) {
  // 1. 基本类型或 null
  if (typeof params !== "object" || params === null) return params;

  const currentTag = protoString.call(params);

  // 2. 考虑不同引用类型的处理
  switch (currentTag) {
    case dateTag:
      return new Date(params.getTime());
    case regexpTag: {
      const result = new RegExp(params.source, params.flags);
      result.lastIndex = params.lastIndex;
      return result;
    }
    case setTag: {
      const result = new Set();
      params.forEach((v) => result.add(deepClone(v)));
      return result;
    }
    case mapTag: {
      const result = new Map();
      params.forEach((v, k) => result.set(k, deepClone(v)));
      return result;
    }
    //...
  }

  const isArray = currentTag === arrayTag;
  const isObject = currentTag === objectTag;

  // 3. 处理对象或数组:注意递归返回
  if (isObject || isArray) {
    const newValue = isArray ? [] : {};
    for (const key in params) {
      if (params.hasOwnProperty(key)) {
        newValue[key] = deepClone(params[key]);
      }
    }
    return newValue;
  }
}

模拟一段数据,看看 deepClone 函数的实现效果

js 复制代码
const obj = {
  name: "张三",
  age: 18,
  hobby: ["唱", "跳", "rap", "篮球"],
  time: new Date(),
  arr: [1, 2, 3],
  set: new Set([1, 2, 3]),
  map: new Map([
    ["a", 1],
    ["b", 2],
  ]),
};

const deepCloneObj = deepClone(obj);

console.log(deepCloneObj)

打印效果

栈溢出问题

后来看到一些文章说有"爆栈"的问题,也就是说,对象中的某个属性指向了这个对象本身,形成了一个闭环,导致递归调用无限循环,最终导致栈溢出

栈溢出:每次函数调用都会向调用栈添加一个新的帧,而调用栈有其最大容量限制。当调用深度超过这个限制时,就会触发栈溢出错误

还是拿上面那个 obj 数据来复现一下问题

js 复制代码
const obj = {
  name: "张三",
  age: 18,
  hobby: ["唱", "跳", "rap", "篮球"],
  time: new Date(),
  arr: [1, 2, 3],
  set: new Set([1, 2, 3]),
  map: new Map([
    ["a", 1],
    ["b", 2],
  ]),
}

// 指向 obj 本身
obj.name = obj

console.log(deepClone(obj))

打印报错:Uncaught RangeError: Maximum call stack size exceeded

文章也提到了解决办法,重点在于记录,把初次进入的数据记录下来,后续再遇到相同的属性时,直接返回记录的值,避免无限循环

Map 提供保存键值对数据的功能,key 作为记录标识符,value 作为记录的值,很合适

Map 是 ES6 提供的新数据结构,保存键值对数据,键的类型不限于字符串,参考:阮一峰 - ES6MDN - Map

最终的深拷贝代码如下:

js 复制代码
const protoString = Object.prototype.toString;

const setTag = "[object Set]";
const mapTag = "[object Map]";
const dateTag = "[object Date]";
const regexpTag = "[object RegExp]";

const arrayTag = "[object Array]";
const objectTag = "[object Object]";

// 深拷贝
function deepClone(params, map = new Map()) {
  // 1. 基本类型或 null
  if (typeof params !== "object" || params === null) return params;

  // 检查循环引用
  if (map.has(params)) return map.get(params);


  const currentTag = protoString.call(params);

  // 2. 考虑不同引用类型的处理
  switch (currentTag) {
    case dateTag:
      return new Date(params.getTime());
    case regexpTag: {
      const result = new RegExp(params.source, params.flags);
      result.lastIndex = params.lastIndex;
      return result;
    }
    case setTag: {
      const result = new Set();
      map.set(params, result);

      params.forEach((v) => result.add(deepClone(v, map)));
      return result;
    }
    case mapTag: {
      const result = new Map();
      map.set(params, result);

      params.forEach((v, k) => result.set(k, deepClone(v, map)));
      return result;
    }
    //...
  }

  const isArray = currentTag === arrayTag;
  const isObject = currentTag === objectTag;

  // 3. 处理对象或数组:注意递归返回
  if (isObject || isArray) {
    const newValue = isArray ? [] : {};
    map.set(params, newValue);

    for (const key in params) {
      if (params.hasOwnProperty(key)) {
        newValue[key] = deepClone(params[key], map);
      }
    }
    return newValue;
  }
}

最后再来试一下效果

js 复制代码
const obj = {
  name: "张三",
  age: 18,
  hobby: ["唱", "跳", "rap", "篮球"],
  time: new Date(),
  arr: [1, 2, 3],
  set: new Set([1, 2, 3]),
  map: new Map([
    ["a", 1],
    ["b", 2],
  ]),
  getName() {
    return this.name;
  },
};

obj.key = obj

console.log(deepClone(obj));

打印结果如下:

总结

文章主要讲解浅拷贝和深拷贝两种方法的特点和实现

浅拷贝只拷贝开始一层,手写实现也好处理

深拷贝是递归拷贝所有层级,手写实现的话,需要考虑很多实际场景,代码量也会比较大

如有兴趣的话,建议查看 lodashcloneDeep 方法的实现

参考资料

参透JavaScript系列

本文已收录至《参透 JavaScript 系列》,全文地址:我的 GitHub 博客 | 掘金专栏

交流讨论

对文章内容有任何疑问、建议,或发现有错误,欢迎交流和指正

相关推荐
yvvvy3 分钟前
前端必懂的 Cache 缓存机制详解
前端
北海几经夏18 分钟前
React自定义Hook
前端·react.js
龙在天23 分钟前
从代码到屏幕,浏览器渲染网页做了什么❓
前端
TimelessHaze24 分钟前
【performance面试考点】让面试官眼前一亮的performance性能优化
前端·性能优化·trae
yes or ok36 分钟前
前端工程师面试题-vue
前端·javascript·vue.js
我要成为前端高手1 小时前
给不支持摇树的三方库(phaser) tree-shake?
前端·javascript
Noxi_lumors1 小时前
VITE BALABALA require balabla not supported
前端·vite
周胜21 小时前
node-sass
前端
aloha_1 小时前
Windows 系统中,杀死占用某个端口(如 8080)的进程
前端
牧野星辰1 小时前
让el-table长个小脑袋,记住我的滚动位置
前端·javascript·element