企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持知识库和私有化部署方案 广告
# JavaScript浅拷贝和深拷贝 [TOC] ## JavaScript的两种变量类型 JavaScript变量的类型分为两种, 基本类型和引用类型,其中 基本类型是指简单的数据段,有5种 : Undefined、 Null、 Boolean、 Number 和 String 引用类型是指可能有多个值构成的对象,一般为: Object, Array, function 等 为什么要先说变量类型呢,是因为基本类型是<b>按值访问</b>的,不会影响到其他数据,例如 <pre>var a = '前端' var b = a a = '前端工程师' b // 前端 </pre> 所以基本类型的值没有深拷贝的概念 而引用类型的值是<b>按地址访问</b>的,简单的赋值,实际上只是把地址复制了一遍,修改任意一个值会影响到另外一个,例如 <pre>var a = [1,2,3,4] var b = a a[1] = '已修改' b // [1, "已修改", 3, 4] </pre> 可以看到,数组a 赋值给了数组b ,JavaScript引擎只是将a的地址赋值给了b,他们指向同一个内存地址,并没有开辟新的栈,当修改a的值,b也被影响了,这就是浅拷贝 很多时候我们并不希望这样。 ## 什么是深拷贝浅拷贝 所以浅拷贝和深拷贝(也叫浅复制和深复制)的概括解释为: 对基本类型变量,浅拷贝是对值的拷贝,没有深拷贝的概念。 对引用类型来说,浅拷贝是对对象地址的拷贝,并没有开辟新的栈,复制的结果是两个对象指向同一个地址,修改其中一个对象的属性,另外一个对象的属性也会改变, 而深拷贝则是开辟新的栈。 ## 浅拷贝的实现 数组之间直接赋值`arr1 = arr2`,是浅拷贝这不多说。下面提一下数组有个方法容易人误导的方法 ### Array.concat Array.concat(value) 方法会返回一个新的数组,下面我们来做个测试 <pre>var arr1 = [1,2,3]; var arr2 = [4,5,6]; var Coll = arr1.concat(arr2) arr1[0] = '被修改了' arr1 //["被修改了", 2, 3] Coll //[1, 2, 3, 4, 5, 6] </pre> 可以看到,修改`arr1` 并不会影响数组`coll`的值,这表面现象刚开始也误导了我,让我以为`concat`是深拷贝,查阅<a href='https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/concat'>MDN文档</a> > concat 方法并不修改调用它的对象(this 指向的对象) 和参数中的各个数组本身的值,而是将他们的每个元素拷贝一份放在组合成的新数组中.原数组中的元素有两种被拷贝的方式: > 对象引用(非对象直接量):concat 方法会复制对象引用放到组合的新数组里,原数组和新数组中的对象引用都指向同一个实际的对象,所以,当实际的对象被修改时,两个数组也同时会被修改. 字符串和数字(是原始值,而不是包装原始值的 String 和 Number 对象): concat 方法会复制字符串和数字的值放到新数组里 文章开头说过引用类型是指可能有多个值构成的对象,当然允许有基本类型的值,所以,上一个例子修改`arr1`的值不会影响`oll`只是因为2个数组的下标对应的都是基本类型的值,修改基本类型的值不会影响其他,造成深拷贝的假象。如果下标对应的是引用类型的值,会拷贝地址。 那么来试试修改引用类型的值 <pre>var arr1 = [{name: '小红'}] var arr2 = [{name: '小明'}] var Coll = arr1.concat(arr2) Coll // [{name:'小红'},{name:'小明'}] arr1[0].name = '被修改了' Coll // [{name:'被修改了'},{name:'小明'}] </pre> 可以看到修改arr1下标0对应的对象,Coll的值也被影响了。他们指向同一个地址,所以concat是浅拷贝,另外,Array.slice方法和concat一样不会影响原数组,但同样是浅拷贝 ### 对象的浅拷贝 Object.assign() 方法用于将所有可枚举的属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。 `Object.assign(target, ...sources)` 他同样会让人产生困扰,如果对象深度只有1层,Object.assign()产生的对象和concat一样,不会影响其他对象,例如 <pre>var obj1 = {a: 1, b: 2} var obj2 = {A: 1, B: 2} var Coll = Object.assign({},obj1,obj2) obj1.a = '被修改了' Coll // {a: 1, b: 2, A: 1, B: 2} </pre> 修改obj1.a 的属性不会影响Coll,是因为 `obj.a` 对应的 数字1是 基本类型,按值引用。如果`obj1.a` 对应引用类型的值,复制的就是这个引用类型的地址。 <pre>var obj1 = {a: {name: '小红'}, b: 2} var obj2 = {A: 1, B: 2} var Coll = Object.assign({},obj1,obj2) obj1.a.name = '被修改了' Coll //{"a":{"name":"被修改了"},"b":2,"A":1,"B":2} </pre> ## 深拷贝的实现 ### 转JSON再解析回来 原理: 用JSON.stringify将对象转成JSON字符串,再用JSON.parse()把字符串解析成对象,一来一回之间,新的对象产生了,而且对象会开辟新的栈,实现深拷贝 <pre>var obj1 = {a: {name: '小红'}, b: 2} var obj2 = JSON.parse(JSON.stringify(obj1)) obj1.a.name = '被修改了' obj2 //{"a":{"name":"小红"},"b":2} 《---没有被修改 </pre> #### 缺点 会抛弃对象的constructor,也就是深复制之后,无论这个对象原本的构造函数是什么,在深复制之后都会变成Object。 不过对象的constructor,没什么用,constructor属性不影响任何JavaScript的内部属性,只是JavaScript语言设计的历史遗留物,由于constructor属性是可以变更的,所以未必真的指向对象的构造函数,只是一个提示而已,只是在编程习惯上应该尽量让对象的constructor指向其构造函数 另外诸如RegExp对象是无法通过这种方式深复制的。function也无法转成JSON ### 通过递归拷贝 比转JSON方式繁琐了点,不过听我解释,很好理解,先上代码 <pre> var obj1 = {a: {name: '小红'}, b: 2, arr:[1,2]} var obj2 = {} //参数:初始值,完成值 function deepClone(initalObj, finalObj) { var obj = finalObj || {}; for (var i in initalObj) { //判断是否引用类型,object,Array 的typeof检测 都是object if (typeof initalObj[i] === 'object') { //递归前,判断是对象还是数字,初始化 obj[i] = (initalObj[i].constructor === Array) ? [] : {}; //递归自己 arguments.callee(initalObj[i], obj[i]); } else { //基础类型值 直接复制 obj[i] = initalObj[i]; } } return obj; } deepClone(obj1, obj2) console.log(obj2) //{a: {name: '小红'}, b: 2, arr:[1,2]} </pre> 原理: 在浅拷贝部分的原理是一样的,如果第一层键名/下标 对应的是基础类型的值,就直接复制, 如果是引用类型的值,采用递归的方式,进入到这个引用类型的值。到了第二层,循环操作第一层的步骤。 注意: arguments.callee() 这个方法因为性能问题,在es5严格模式下禁止使用,具体原因,可以<a href='https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Functions/arguments/callee'>在这里查阅</a>。 解决方法很简单,直接用函数名即可。 另外如果遇到两个相互引用的对象,会出现死循环的情况 为了避免相互引用的对象导致死循环的情况,则应该在遍历的时候判断是否相互引用对象,如果是则退出循环。 改进版代码,如下: <pre>function deepClone(initalObj, finalObj) { var obj = finalObj || {}; for (var i in initalObj) { var prop = initalObj[i]; // 避免相互引用对象导致死循环,如initalObj.a = initalObj的情况 if(prop === obj) { continue; } if (typeof prop === 'object') { obj[i] = (prop.constructor === Array) ? [] : {}; arguments.callee(prop, obj[i]); } else { obj[i] = prop; } } return obj; } </pre> ### jquery的$.extend jQuery 的$.extend 也能实现深拷贝, `$.extend(true, obj1, obj2)` 如果需要用深拷贝,第一个参数为true即可。不用则可以省略true。 具体用法 <pre>var $ = require('jquery'); var obj1 = { a: 1, b: { f: { g: 1 } }, c: [1, 2, 3] }; var obj2 = $.extend(true, {}, obj1); obj1.b.f === obj2.b.f; // false </pre> 这是什么原理呢,我们一起来看下$.extend的源码 ~~~ jQuery.extend = jQuery.fn.extend = function() { var options, name, src, copy, copyIsArray, clone, target = arguments[0] || {}, // 目标对象 i = 1, length = arguments.length, deep = false; // 处理深度拷贝情况(第一个参数是boolean类型且为true) if ( typeof target === "boolean" ) { deep = target; target = arguments[1] || {}; // 跳过第一个参数(是否深度拷贝)和第二个参数(目标对象) i = 2; } // 如果目标不是对象或函数,则初始化为空对象 if ( typeof target !== "object" && !jQuery.isFunction(target) ) { target = {}; } // 如果只指定了一个参数,则使用jQuery自身作为目标对象 if ( length === i ) { target = this; --i; } for ( ; i < length; i++ ) { // Only deal with non-null/undefined values if ( (options = arguments[ i ]) != null ) { // Extend the base object for ( name in options ) { src = target[ name ]; copy = options[ name ]; // Prevent never-ending loop if ( target === copy ) { continue; } // 如果对象中包含了数组或者其他对象,则使用递归进行拷贝 if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { // 处理数组 if ( copyIsArray ) { copyIsArray = false; // 如果目标对象不存在该数组,则创建一个空数组; clone = src && jQuery.isArray(src) ? src : []; } else { clone = src && jQuery.isPlainObject(src) ? src : {}; } // 递归,从不改变原始对象,只做拷贝 target[ name ] = jQuery.extend( deep, clone, copy ); // 基本类型的值,直接拷贝,不拷贝undefined值 } else if ( copy !== undefined ) { target[ name ] = copy; } } } } // 返回已经被修改的对象 return target; }; ~~~ 可以看到,$.extend和上面递归实现拷贝部分是一个原理,判断是基本类型还是引用类型做相应处理,引用类型判断数组还是对象来初始化,然后调用自己 ## 深拷贝使用场景 刚学习前端不久的同学,可能对深拷贝的使用场景不熟悉,下面我说一个我写demo时候遇到需要用深拷贝的场景(没学过vue的同学可以不看这段)。 Vue.js 是当下最火的前端框架之一, 他有一套全家桶技术配合其使用,其中Vuex是状态管理工具。有这么几个核心概念,存放数据的State, 可以派生State里数据的 getters, 以及唯一能合法修改State的Mutations。 我在写一个时间记录的demo时,需求中每项记录有可自行创建标签,比如休息,学习,以便统计。标签的对象格式如下 <pre>tagName = [ { name: '休息', type:'xxx' }, { name: '学习', type:'xxx' } ] </pre> 在组件里,使用vuex提供的mapGetters 辅助函数映射到组件计算函数获取数据。当要删除标签时 <pre> var ret = this.tagName ret.splice(this.tagName.indexOf(要删除的目标),1) </pre> 这时候控制台发出非法修改state数据的警告,因为ret是浅拷贝this.tagName ,我们在getter中修改State数据。前面提到过,在vuex中只有Mutations能合法修改State。这时候就需要深拷贝,得到新tagName再通过Mutations修改State数据