前文JavaScript由什么组成中讲述了JavaScript 的数据类型分为基本类型和引用类型,而区分两则的依据是基本类型是”轻量“的,它存在栈内存中,而引用类型比较重,它存在堆内存中。所以当基本类型拷贝时,能直接拷贝,引用类型拷贝时,拷贝的不是对象(引用类型有且只有一个数据类型——对象),而是该对象在内存中的地址
在一切皆对象中,我们曾经清晰地表达一个观点,在 JavaScript 的世界里,除去 undefined、null外,一切皆对象。对象在使用过程中频繁用到的一点是赋值拷贝,而当你拷贝出错时,情况会很糟糕。所以我们这节就是为讲解对象中的拷贝而生
正文
首先 JavaScript 没有不可变数据结构,不可变数据结构是函数式编程中必备的
可变的好处是节省内存或是利用可变性做一些事情,但是在复杂的开发中它的副作用远比好处大得多,于是有了浅拷贝和深拷贝
笔者这里有几个自问自答的解释
- 对象为什么是拷贝地址?
- 为了性能(节省内存),试想如果每个对象都是拷贝值,那对象一大/多,占用的内存就会几何上升
- 如何拷贝对象的值
- Object.assign
- 扩展运算符
- slice(数组方法)
- concat(数组方法)
- JSON.stringify
这五种方法都能拷贝对象的值,而前四者是浅拷贝,JSON.stringify 是深拷贝
什么是浅拷贝?什么又是深拷贝?
- 浅拷贝是创建一个新对象,这个对象对原始对象属性值进行复制
- 属性是基础类型,拷贝的就是基本类型的值,修改内容不影响
- 属性是引用类型,拷贝的就是内存地址,修改内容互相影响
- 深拷贝:整个对象拷贝到另一个内存中,修改内容互不影响
说得直白点,浅拷贝只拷贝一层,深拷贝直接复制对象
为什么要有浅拷贝,直接深拷贝代替不就好了。当然,这个问题同样有网友提出——JS的浅拷贝究竟有什么作用?[1],虽然笔者没找到相关资料,但怀疑还是因为性能,浅拷贝能应付很多场景,非不必要不用深拷贝。在设计上让开发者少用,无形中提高开发体验
Object.assign
Object.assign()方法可以把任意多个原对象自身的可枚举属性拷贝给目标对象,然后返回目标对象
它拷贝的是对象的属性的引用,而不是对象本身
是 ES6 中 Object 对象新增的方法
参数:
target:目标对象
sources: 任意多个原对象。
返回值:目标对象会被返回
适用对象:Object
案例一:
var obj1 = { a: 10, b: 20, c: 30 }; var obj2 = Object.assign({}, obj1); obj2.b = 100; console.log(obj1); // {a: 10, b: 20, c: 30} console.log(obj2); // {a: 10, b: 100, c: 30}
案例二:
var obj = { a: { a: 'hello', b: 21 } }; var initialObj = Object.assign({}, obj); initialObj.a.a = 'changed'; console.log(obj.a.a); // "change"
可以看出,Object 只能拷贝第一层对象,如果再往深一层拷贝,就有问题了。所以Object.assign 是浅拷贝
扩展运算符(...)
扩展运算符,可以在函数调用/数组构造时,将数组表达式或者 string 在语法层面展开;还可以在构造字面量对象时,将对象表达式按 key-value 的方式展开
诚然,我们都知道展开运算符的作用并不是为了拷贝。但无可厚非,浅拷贝也是展开运算符的功能点之一
适用对象:Object/Array
案例一:一维数组
var arr = [1, 2, 3]; var arr2 = [...arr]; arr2.push(4); // arr2 [1, 2, 3, 4] // arr1 不受影响
案例二:多维数组
var a = [ [1, 2], [3, 4], [5, 6], ]; var b = [...a]; b.shift().shift(); // b [[3, 4], [5, 6]] // a [[2], [3, 4], [5, 6]]
扩展运算符也是浅拷贝
slice
slice() 方法返回一个新的数组对象,这一对象是一个由 begin
和 end
决定的原数组的浅拷贝(包括 begin
不包括 end
)。原始数组不会被改变
适用对象:Array
案例:
const family = [ 'father', 'mother', 'brother', ['sister0', 'sister1', 'sister2'], ]; const copyFamily = family.slice(); copyFamily[0] = 'father1'; copyFamily[3][1] = 'brother1'; console.log(family); // ['father', 'mother', 'brother', ['sister0' , 'brother1', 'sister2']] console.log(copyFamily); // ['father1', 'mother', 'brother', ['sister0' , 'brother1', 'sister2']] // 复制一层,第二层开始引用
如上案例,slice 只能复制一层,第二层就是复制引用地址了,slice 也是浅拷贝
concat
concat() 方法用于合并两个或多个数组。此方法不会改变现有数组,而是返回一个新数组
适用对象:Array
const array1 = ['a', 'b', ['c0', 'c1', 'c2']]; const array2 = array1.concat(); array2[1] = 'B'; array2[2][1] = 'C1'; console.log(array1); // ['a', 'b', ['c0', 'C1', 'c2']] console.log(array2); // ['a', 'B', ['c0', 'C1', 'c2']] // 复制一层,第二层开始引用
concat 同 slice,都是针对数组的浅拷贝
如何实现浅拷贝
简单来说,浅拷贝只复制一层对象的属性
hasOwnProperty 的作用是判断对象自身属性中是否具有指定的属性
function shallowClone(source) { if (typeof target === 'object' && target !== null) { var target = Array.isArry(source) ? [] : {}; for (let prop in source) { if (source.hasOwnProperty(prop)) { target[prop] = source[prop]; } } return target; } else { return source; } }
综上分析,JavaScript 的浅拷贝有 4 种,针对数组的浅拷贝有 slice、concat,针对对象的 Object.assign() ,还有就是适用数组和对象的扩展运算符(...)
深拷贝的原理
浅拷贝只是创建一个新的对象,复制了原有对象的基本类型的值,而引用类型只拷贝了一层属性,再深层的就无法拷贝。深拷贝则不同,它会在堆内存中开辟一块内存地址,将原有对象完全复制过来
深拷贝的是将一个对象从内存中完整地拷贝出来一份给目标对象,并从堆内存中开辟一个全新的空间存放新对象,且新对象的修改并不会改变元对象,二者实现真正的分离
简单归纳:深拷贝是递归复制了所有层级中对象的属性
JSON.stringify
var arr = [1, 2, 3, 4, { value: 5 }]; var arr1 = JSON.parse(JSON.stringify(arr)); arr[4].value = 6; console.log(arr1); //[1, 2, 3, 4, { value: 5 }] var obj = { name: "johan", address: {city: "shanghai"} } var obj1 = JSON.parse(JSON.stringify(obj)); obj.address.city = "beijing"; console.log(obj1); //{name: "johan", address:{city: "shanghai"}
虽然 JSON.stringify 能实现对数组和对象的深拷贝,但它却有几个坑
- 它无法实现对函数、RegExp 等特殊对象的克隆
- 它会抛弃对象的 constructor,所有的构造函数会指向 Object
- 对象有循环引用,会报错
我们来测试一下这几个坑,
// 构造函数 function Person(name) { this.name = name; } const Elaine = new Person('elaine'); // 函数 function say() { console.log('hi'); } const oldObj = { a: say, b: new Array(1), c: new RegExp('ab+c', 'i'), d: Elaine, }; const newObj = JSON.parse(JSON.stringify(oldObj)); // 无法复制函数 console.log(newObj.a, oldObj.a); // undefined [Function: say] // 稀疏数组复制错误 console.log(newObj.b[0], oldObj.b[0]); // null undefined // 无法复制正则对象 console.log(newObj.c, oldObj.c); // {} /ab+c/i // 构造函数指向错误 console.log(newObj.d.constructor, oldObj.d.constructor); // [Function: Object] [Function: person]
我们可以看到在对函数、正则对象。稀疏数组等对象克隆时会发生意外,构造函数指向也会发生错误
const oldObj = {}; oldObj.a = oldObj; const newObj = JSON.parse(JSON.stringify(oldObj)); console.log(newObj.a, oldObj.a); // TypeError: Converting circular structure to JSON
对象的循环引用会抛出错误
JSON.stringify 深拷贝能解决现实中的大部分场景,但缺陷也让其成了面试中的常客,现在我们挑战下手写深拷贝
手写深拷贝
深拷贝的要领是递归+深拷贝
我们先实现一个针对数组和对象的深拷贝
function deepClone(source) { // 针对基本数据类型 if (typeof source !== 'object' || source === null ) { return source } // 判断它是数组还是对象 // 或者 let target = source instanceof Array ? [] : {} let target = Array.isArray(source) ? [] : {} // 循环遍历复制每个属性 for (let prop in source) { // 自有属性才做拷贝 if (source.hasOwnProperty(prop)) { // 判断自有属性是否是对象 target[prop] = typeof source[prop] === 'object' ? deepClone(source[prop]) : source[prop] } } return target }
以上是简单的深拷贝,与 JSON.stringify 的深拷贝效果大差不差。它们同样存在的缺点是:
- 对包含循环引用的对象(对象之间互相引用,形成无限循环)执行此方法,会抛出错误
- 以 Symbol 类型为属性值的属性都会被忽略掉
- 缺少针对其他的内置构造函数的兼容,如 Function、RegExp、Date、Set、Map
我们使用 WeakMap 来解决循环引用,如要其他数据类型,加上便是
这里说明一下为什么用 WeakMap 来解决循环引用,以及它与 Map 的区别
要想解决循环引用问题,可以额外开辟一块存储空间,来存储当前对象和拷贝对象的对应关系,当拷贝时,先从空间中找,找到直接返回,没有的话正常拷贝
而这类数据结构可以用 map、WeakMap 。两者的区别在于
- WeakMap 对象是一组键/值对的集合,其中的键是弱引用。其键必须是对象,而值可以是任意的。Map的键可以是任意的,包括函数、对象或任意基本类型
- WeakMap 是弱引用,可以被垃圾回收。Map 的键与内存绑定
- Map 可以被遍历,WeakMap 不能被遍历
简单来说,因为 WeakMap 是弱引用,所以在没有其他引用存在时垃圾回收能正常进行
function deepClone(source, storage = new WeakMap()) { // 针对基本数据类型 if (typeof source !== 'object' || source === null) { return source } // 是否是日期 if (source.constructor === Date) { return new Date(source) } // 是否是正则 if (source.constructor === RegExp) { return new RegExp(source) } // 是否是数组 let target = source instanceof Array ? [] : {} // 循环引用 返回存储的引用数据 if (storage.has(source)) return storage.get(source) // 开辟存储空间设置临时存储值 storage.set(source, target) // 是否包含 Symbol 类型 let isSymbol = Object.getOwnPropertySymbols(source) // 包含 Symbol 类型 if (isSymbol.length) { isSymbol.forEach((item) => { if (typeof source[item] === 'object') { target[item] = deepClone(source[item], storage); return } target[item] = source[item] }) } // 不包含 Symbol for(let key in source) { if (source.hasOwnProperty(key)) { target[key] = typeof source[key] === 'object' ? deepClone(sourcep[key], storage) : source[key] } } return target; }
笔者的这个深拷贝肯定不是最全的,非大佬写出的让面试官惊艳的深拷贝可比。笔者只能说提供一丢丢深拷贝的思路
总结
深拷贝是前端面试中必考的一项,他若问你怎么手写,你若是只写了 JSON.parse(JSON.stringify(source))肯定是不合格的。写 hasOwnProperty 也只能靠边站,而如果解决循环引用、Symbol、各个数据类型拷贝等问题,才能说明你明白了深拷贝
参考资料
- 使用 slice 和 concat 对数组的深拷贝和浅拷贝[2]
- 如何写出一个惊艳面试官的深拷贝[3]
- 闲庭信步聊前端 - 一文摸清 ES 拷贝的深浅[4]
- 如何写出一个惊艳面试官的深拷贝?[5]
[1]
JS的浅拷贝究竟有什么作用?: https://www.zhihu.com/question/294117087
[2]
使用 slice 和 concat 对数组的深拷贝和浅拷贝: https://www.cnblogs.com/baiyangyuanzi/p/6518218.html
[3]
如何写出一个惊艳面试官的深拷贝: https://www.conardli.top/docs/JavaScript/%E6%B5%85%E6%8B%B7%E8%B4%9D%E5%92%8C%E6%B7%B1%E6%8B%B7%E8%B4%9D.html
[4]
闲庭信步聊前端 - 一文摸清 ES 拷贝的深浅: https://zhuanlan.zhihu.com/p/338443023?utm_source=wechat_session&utm_medium=social&utm_oi=56197411504128
[5]
如何写出一个惊艳面试官的深拷贝?: https://juejin.cn/post/6844903929705136141