当前位置: 首页 > Web前端 > JavaScript

JS原生方法原理探究(九)如何实现浅拷贝和深拷贝?

时间:2023-03-27 01:03:01 JavaScript

这是探索JS原生方法原理系列文章的第九篇。本文将介绍如何手工实现浅拷贝和深拷贝。实现浅拷贝什么是浅拷贝?原始对象的浅拷贝将生成一个与其“相同”的新对象。但是这种复制只会复制原始对象第一层的基本类型属性,引用类型属性仍然与原始对象共享相同的。用一个简单的例子来理解:letobj1={a:'Jack',b:{c:1}}letobj2=Object.assign({},obj1)obj1.a='Tom'obj1.b.c=2console。log(obj1.a)//'Tom'console.log(obj2.a)//'Jack'console.log(obj1.b.c)//2console.log(obj2.b.c)//可以看到2,因为新对象复制原对象的一级基本类型属性,修改obj1.a的值不会影响obj2.a的值;同时,因为新对象和原对象共享相同的一级引用类型属性,所以修改obj1.b对象也会影响到obj2.b对象。如何实现浅拷贝?JS中常见的浅拷贝方法有Object.assign()、...展开运算符、数组的slice方法。但是如果我们想自己实现一个浅拷贝呢?其实也很简单,因为浅拷贝只作用于第一层,所以只需要遍历原对象,将其每一个成员添加到新对象中即可。这里所说的原始对象是指对象字面量、数组、类数组对象、Sets、Maps等可以遍历的对象。对于其他不可遍历的对象和基本类型的值,直接返回即可。代码如下:functiongetType(obj){returnObject.prototype.toSrting.call(obj).slice(8,-1)}//可以遍历的数据类型letiterableList=['Object','Array','Arguments','Set','Map']//浅拷贝函数shallowCopy(obj){lettype=getType(obj)if(!iterableList.includes(type))returnobjletres=newobj.constructor()//如果是Set或Mapobj.forEach((value,key)=>{type==='Set'?res.add(value):res.set(key,value)})//如果是对象字面量,类数组对象或者数组Reflect.ownKeys(obj).forEach(key=>{res[key]=obj[key]})returnres}一些关键点:初始化新对象res:获取原对象obj的构造函数,用于创建与原对象同类型的实例。可以通过三种方式遍历对象或数组。第一种是使用Reflect.ownKeys()获取其所有属性(是否可枚举),第二种是使用for...in+hasOwnProperty()获取自身所有可枚举属性,第三种方法是通过Object.keys()一次获取自身所有的可枚举属性,实现对象深拷贝。什么是深拷贝?原始对象的深拷贝将生成一个与其“相同”的新对象。深拷贝会复制原始对象所有层次上的基本类型属性和引用类型属性。让我们通过一个例子来理解它:2console.log(obj1.a)//'Tom'console.log(obj2.a)//'Jack'console.log(obj1.b.c)//2console.log(obj2.b.c)//1可以看到,不管对obj1做了什么修改,都不会影响obj2,反之亦然,两者是完全独立的。如何实现深拷贝?实现深拷贝的常用方法是JSON.parse(JSON.stringify())。可以应对一般的深拷贝场景,但是也存在很多问题,而且这些问题基本都出现在序列化过程中。日期类型属性在深拷贝后会变成字符串:letobj={date:newDate()}JSON.parse(JSON.stringify(obj))//{date:"2021-07-04T13:01:35.934Z"}常规类型和错误类型属性在深拷贝后会变成空对象:},error:{}}如果key的值是函数类型,undefined类型,Symbol类型,深拷贝后会丢失://如果是对象,属性会直接丢失letobj={fn:function(){},name:undefined,sym:Symbol(),age:12}JSON.parse(JSON.stringify(obj))//{age:12}//如果是数组,则变成"null"letarr=[function(){},undefined,Symbol(),12]JSON.parse(JSON.stringify(arr))//["null","null","null"12]如果key是Symbol类型,深拷贝后会丢失:letobj={a:1}obj[Symbol()]=2JSON.parse(JSON.stringify(obj))//{a:1}NaN,Infinity,-Infinitypassed深拷贝后变成nullletobj={a:NaN,b:Infinity,c:-Infinity}JSON.parse(JSON.stringify(obj))//{a:null,b:null,c:null}可能构造函数指针丢失:functionSuper(){}让obj1=newSuper()让obj2=JSON.parse(JSON.stringify(obj1))console.log(obj1.constructor)//Superconsole.log(obj2.constructor)//ObjectJSON.stringify()只能序列化对象本身的可枚举属性,构造函数不是实例对象本身是实例的属性,而是实例的原型对象的属性。因此,当实例对象obj1被序列化时,实际上并没有处理指向构造函数的指针,以至于它的指针变成了默认的Object。存在循环引用问题letobj={}obj.a=objJSON.parse(JSON.stringify(obj1))上面的obj对象存在循环引用,即是循环结构(非树)对象,这样的对象不能转成JSON,所以会报错:Can'tconvertcircularstructuretoJSON。另外,我们也可以考虑使用Lodash提供的深拷贝方式。但是,如果要自己实现深拷贝,该怎么办呢?让我们一步步来看。基础版深拷贝的核心其实就是==浅拷贝+递归==,不管嵌套层数多深,我们总能通过不断的递归到达对象的最内层,完成基本类型属性和不可遍历的引用类型属性的副本这是最基本的深拷贝版本:[]:{}Reflect.ownKeys(target).forEach(key=>{cloneTarget[key]=deepClone(target[key])})returncloneTarget}else{returntarget}}这里只考虑数组和对象字面量.根据最初传递的目标是对象文字还是数组,确定返回的cloneTarget是对象还是数组。然后遍历每个目标自身的属性,递归调用deepClone。如果属性已经是基本类型,则直接返回;如果它仍然是一个对象或一个数组,它会像初始目标一样被处理。最后将处理后的结果一一添加到cloneTarget中。解决循环引用引起的爆栈问题但是这里有个循环引用的问题。假设深拷贝的目标是如下对象:letobj={}obj.a=objectlikeobj,结构中存在循环,即存在循环引用:obj通过属性a引用自身,并且a还必须有一个Propertya再次引用自身......最终导致obj的无限嵌套。但是由于在深拷贝的过程中使用了递归,无限嵌套的对象会导致无限递归,不断的压栈最终会导致栈溢出。如何解决循环引用引起的爆栈问题?其实也很简单,只需要为递归创建一个出口即可。对于第一次传入的对象或数组,会使用一个WeakMap来记录当前目标与复制结果的映射关系。当检测到再次传入同一个target时,不再重复复制,而是直接从WeakMap中取出,返回对应的复制结果。这里的“return”其实是为递归创建了一个出口,所以不会无限递归,栈也不会爆。所以改进后的代码如下:[]:{}//处理循环引用的问题if(map.has(target))returnmap.get(target)map.set(target,cloneTarget)Reflect.ownKeys(target).forEach(key=>{cloneTarget[key]=deepClone(target[key],map)})returncloneTarget}else{returntarget}}处理其他数据类型永远记住我们要处理三种类型的目标:基本数据类型:直接返回引用数据可以继续遍历的类型:除了上面已经处理过的对象字面量和数组,还有类数组对象,Set,Map。都属于可以继续遍历的引用类型,可能有嵌套,所以在处理的时候,需要递归引用不能继续遍历的数据类型:包括函数,错误对象,日期对象,正则对象,以及的包装对象基本类型(String、Boolean、Symbol、Number)等。它们不能被进一步遍历,或者说“没有层次嵌套”,所以在重新处理的时候,需要复制同一个副本并返回1)类型判断功能为了更好的判断是引用数据类型还是基本数据类型,你可以使用一个isObject函数:functionisObject(o){returno!==null&&(typeofo==='object'||typeofo==='function')}为了更准确的判断是什么类型的数据是,您可以使用getType函数:functiongetType(o){returnObject.prototype.toString.call(o).slice(8,-1)}//getType(1)"Number"//getType(null)"null"2)在初始化函数前深拷贝对象字面量或数组时,先将最终返回结果cloneTarget初始化为[]或{}。同理,Set、Map、类数组对象也需要同样的操作,所以最好用一个函数来统一实现cloneTarget的初始化。functioninitCloneTarget(target){returnnewtarget.constructor()}通过target.constructor可以得到传入的实例的构造函数,使用这个构造函数创建一个新的同类型实例并返回。3)处理可以连续遍历的引用类型:类数组对象、Set、类Map数组对象其实类似于数组和对象字面量,所以可以一起处理;Set和Map的处理过程基本相同,只是不能使用直接赋值的方式,而是使用add方法或者set方法,所以稍微改进一下。代码如下:functiondeepClone(target,map=newWeakMap()){//如果是基本类型,直接返回即可if(!isObject(target))returntarget//初始化返回结果lettype=getType(target)letcloneTarget=initCloneTarget(target)//处理循环引用if(map.has(target))returnmap.get(target)map.set(target,cloneTarget)//处理Setif(type==='Set'){target.forEach(value=>{cloneTarget.add(deepClone(value,map))})}//处理Mapelseif(type==='Map'){target.forEach((value,key)=>{cloneTarget.set(key,deepClone(value,map))})}//处理对象文字、数组、类数组对象elseif(type==='Object'||type==='Array'||type==='Arguments'){Reflect.ownKeys(target).forEach(key=>{cloneTarget[key]=deepClone(target[key],map)})}returncloneTarget}4)句柄无法遍历的引用类型现在来处理无法进一步遍历的引用类型。对于这样的对象,我们不能像基本数据类型那样直接返回,因为它们本质上也是对象,直接返回会返回相同的引用,达不到复制的目的。正确的做法应该是复印一份并归还。如何复制?这里有两种情况。其中String、Boolean、Number、error对象、date对象都可以通过new返回一个实例副本;而Symbol、function、regularobject的拷贝不能通过简单的new来拷贝,需要单独处理。复制SymbolfunctioncloneSymbol(target){returnObject(target.valueOf())//或returnObject(Symbol.prototype.valueOf.call(target))//或returnObject(Symbol(target.description))}PS:这里target是基本类型Symbol的包装类型。调用valueOf可以获得其对应的拆箱结果,然后将拆箱结果传递给Object构造出原始包装类型的副本。为了保险起见,可以使用Symbol原型调用valueOf;通过.description可以得到symbol的描述符,也可以以此为基础构建原始包装类型的副本。复制正则对象(参考lodash)functioncloneReg(target){constreFlags=/\w*$/;constresult=newRegExp(target.source,reFlags.exec(target));result.lastIndex=target.lastIndex;returnresult;}复制函数(其实不需要复制函数)functioncloneFunction(target){returneval(`(${target})`)//orreturnnewFunction(`return(${target})()`)}PS:传递给newFunction的参数声明了新创建的函数实例的函数体内容接下来,使用一个directCloneTarget函数来处理以上所有情况:functiondirectCloneTarget(target,type){let_constructor=target.constructorswitch(type):case'String':case'Boolean':case'Number':case'Error':case'Date':returnnew_constructor(target.valueOf())//或者返回newObject(_constructor.prototype.valueOf.call(target))case'RegExp':returncloneReg(target)case'Symbol':returncloneSymbol(target)case'Function':返回cloneFunction(target)default:returnnull}PS:注意这里有一些陷阱。为什么使用returnnew_constructor(target.valueOf())而不是returnnew_constructor(target)?因为如果传入的目标是newBoolean(false),那么最后返回的其实是newBoolean(newBoolean(false))。由于该参数不是空对象,因此它的值不对应于预期的false,而是对应于true。因此,最好使用valueOf来获取包装类型对应的实际值。也可以不使用基本类型对应的构造函数_constructor,而是直接newObject(target.valueOf())来包装基本类型。考虑到valueOf可能会被改写,为了保险起见,可以使用基本类型_constructor对应的构造函数来调用valueOf方法最终版本的最终代码如下:letobjectToInit=['Object','Array','Set','Map','Arguments']functiondeepClone(target,map=newWeakMap()){if(!isObject(target))returntarget//初始化lettype=getType(target)letcloneTargetif(objectToInit.includes(type)){cloneTarget=initCloneTarget(target)}else{returndirectCloneTarget(target,type)}//解决循环引用if(map.has(target))returnmap.get(target)map.set(target,cloneTarget)//复制Setif(type==='Set'){target.forEach(value=>{cloneTarget.add(deepClone(value,map))})}//复制Mapelseif(type==='Map'){target.forEach((value,key)=>{cloneTarget.set(key,deepClone(value,map))})}//复制对象字面量、数组、类数组对象else如果(类型==='对象'||类型==='数组'||type==='Arguments'){Reflect.ownKeys(target).forEach(key=>{cloneTarget[key]=deepClone(target[key],map)})}returncloneTarget}