前端面试的底气之实现一个深拷贝
目录
- 前言
- 青铜段位
- 白银段位
- 黄金段位
- 铂金段位
- 砖石段位
- 星耀段位
- 王者段位
- 总结
前言
深拷贝这个功能在开发中经常使用到,特别在对引用类型的数据进行操作时,一般会先深拷贝一份赋值给一个变量,然后在对其操作,防止影响到其它使用该数据的地方。
如何实现一个深拷贝,在面试中出现频率一直居高不下。因为在实现一个深拷贝过程中,可以看出应聘者很多方面的能力。
本专栏将从青铜到王者来介绍怎么实现一个深拷贝,以及每个段位对应的能力。
青铜段位
JSON.parse(JSON.stringify(data))
这种写法非常简单,而且可以应对大部分的应用场景,但是它有很大缺陷的。如果你不知道它有那些缺陷,而且这种实现方法体现不出你任何能力,所以这种实现方法处于青铜段位。
- 如果对象中存在循环引用的情况也无法正确实现深拷贝。
const a = { b: 1, } a.c = a; JSON.parse(JSON.stringify(a));
- 如果 data 里面有时间对象,则
JSON.stringify
后再JSON.parse
的结果,时间将只是字符串的形式。而不是时间对象。
const a = { b: new Date(1536627600000), } console.log(JSON.parse(JSON.stringify(a)))
- 如果 data 里有RegExp、Error对象,则序列化的结果将只得到空对象;
const a = { b: new RegExp(/\d/), c: new Error('错误') } console.log(JSON.parse(JSON.stringify(a)))
- 如果 data 里有函数,undefined,则序列化的结果会把函数置为undefined或丢失;
const a = { b: function (){ console.log(1) }, c:1, d:undefined } console.log(JSON.parse(JSON.stringify(a)))
- 如果 data 里有NaN、Infinity和-Infinity,则序列化的结果会变成null
const a = { b: NaN, c: 1.7976931348623157E+10308, d: -1.7976931348623157E+10308, } console.log(JSON.parse(JSON.stringify(a)))
白银段位
深拷贝的核心就是对引用类型的数据的拷贝处理。
function deepClone(target){ if(target !== null && typeof target === 'object'){ let result = {} for (let k in target){ if (target.hasOwnProperty(k)) { result[k] = deepClone(target[k]) } } return result; }else{ return target; } }
以上代码中,deepClone函数的参数 target 是要深拷贝的数据。
执行 target !== null && typeof target === 'object'
判断 target 是不是引用类型。
若不是,直接返回 target。
若是,创建一个变量 result 作为深拷贝的结果,遍历 target,执行 deepClone(target[k])
把 target 每个属性的值深拷贝后赋值到深拷贝的结果对应的属性 result[k] 上,遍历完毕后返回 result。
在执行 deepClone(target[k])
中,又会对 target[k] 进行类型判断,重复上述流程,形成了一个递归调用 deepClone 函数的过程。就可以层层遍历要拷贝的数据,不管要拷贝的数据有多少子属性,只要子属性的值的类型是引用类型,就会调用 deepClone 函数将其深拷贝后赋值到深拷贝的结果对应的属性上。
另外使用 for...in
循环遍历对象的属性时,其原型链上的所有属性都将被访问,如果只要只遍历对象自身的属性,而不遍历继承于原型链上的属性,要使用 hasOwnProperty 方法过滤一下。
在这里可以向面试官展示你的三个编程能力。
- 对原始类型和引用类型数据的判断能力。
- 对递归思维的应用的能力。
- 深入理解for...in的用法。
黄金段位
白银段位的代码中只考虑到了引用类型的数据是对象的情况,漏了对引用类型的数据是数组的情况。
function deepClone(target){ if(target !== null && typeof target === 'object'){ let result = Object.prototype.toString.call(target) === "[object Array]" ? [] : {}; for (let k in target){ if (target.hasOwnProperty(k)) { result[k] = deepClone(target[k]) } } return result; }else{ return target; } }
以上代码中,只是额外增加对参数 target 是否是数组的判断。执行 Object.prototype.toString.call(target) === "[object Array]"
判断 target 是不是数组,若是数组,变量result 为 [],若不是数组,变量result 为 {}。
在这里可以向面试官展示你的两个编程能力。
- 正确理解引用类型概念的能力。
- 精确判断数据类型的能力。
铂金段位
假设要深拷贝以下数据 data
let data = { a: 1 }; data.f=data
执行 deepClone(data)
,会发现控制台报错,错误信息如下所示。
这是因为递归进入死循环导致栈内存溢出了。根本原因是 data 数据存在循环引用,即对象的属性间接或直接的引用了自身。
function deepClone(target) { function clone(target, map) { if (target !== null && typeof target === 'object') { let result = Object.prototype.toString.call(target) === "[object Array]" ? [] : {}; if (map[target]) { return map[target]; } map[target] = result; for (let k in target) { if (target.hasOwnProperty(k)) { result[k] = clone(target[k],map) } } return result; } else { return target; } } let map = new Map(); const result = clone(target, map); map.clear(); map = null; return result }
以上代码中利用额外的变量 map 来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去 map 中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。
之所以采用 ES6 中 Map 数据结构,是因为相对于普通的 Object 结构,其键值不限于字符串,各种类型的值(包括对象)都可以当作键。换句话来说。Object 结构提供了“字符串——值”的对应,Map 结构提供了“值——值” 的对应。若采用 Object 结构,当 target 不是字符串时,那么其键值全部都是 [Object Object]
,会引起混乱。
最后需要执行 map.clear();map = null;
,释放内存,防止内存泄露。
在这里可以向面试官展示你的三个编程能力。
- 对循环引用的理解,如何解决循环引用引起的问题的能力。
- 熟悉 ES6 中 Map 数据结构的概念及应用
- 对内存泄露的认识和避免泄露的能力。
砖石段位
该段位要考虑性能问题了。上面用 Map 来解决循环引用的问题。但是最后一定要执行 map.clear();map = null;
,释放内存,防止内存泄露。
也可以使用 WeakMap 来解决循环引用。WeakMap 数据结构和 Map 数据结构,有两点区别:
- WeakMap 只接受对象作为键名( null 除外),不接受其他类型的值作为键名。但是 target 正好符合要求故不影响。
- WeakMap 的键名所指向的对象不计入垃圾回收机制。这里用下面的例子来解释。
let map = new WeakMap(); let obj = {a : 1} map.set(obj , 1);
现代浏览器GC回收策略,采用计算引用次来回收,就是某个对象被引用的次数为 1,因为 map 中的键名 obj 和对象 obj 之间的引用是弱引用,也就是不计入引用次数,对象 obj 被引用的次数还是 1。 当执行 obj = null
后,执行GC回收时,对象 obj 被引用的次数变成 0 ,故会把 obj 所占的内存释放掉。
但是如果 map 是 Map 数据结构,因为 Map 的键名所引用的对象是强引用,就算执行 obj = null
后,再执行GC回收,Map 的键名 obj 对所引用的对象 obj 还是有引用,那么obj 被引用的次数还是 1,故 obj 所占的内存释放不掉。
所以使用 WeakMap 可以防止忘记把 map 置为 null 导致的内存泄漏。然而 WeakMap 这种弱引用,仅仅只是为了防止内存泄漏吗?其还有个特性,当执行 obj = null
,相当对象 obj 被GC回收了,但是执行 map.get(obj)
得到的值还是 1。我们可以利用这个特性来做一下性能优化。
在深拷贝的代码中。是使用拷贝的对象作为键名的,设想一下,当拷贝的对象非常庞大时,导致 map 会占用很大的内存。
如果 map 使用 Map 数据结构,因为 Map 的键名所引用的对象是强引用,所以在浏览器定时执行GC回收时,除非手动清除 Map 这个键值对,才能把这个拷贝对象所占用的内存释放掉。
如果 map 使用 WeakMap 数据结构,因为 WeakMap 的键名所引用的对象是弱引用,所以在浏览器会定时执行GC回收,会直接把这个拷贝对象所占用的内存释放掉,那么这样是不是对内存占用减少,间接优化了代码运行性能。
此外在上面的代码中,我们遍历数组和对象都使用了 for...in
这种方式,实际上 for...in
在遍历时效率是非常低的,故用效率比较高的 while 来遍历。
function deepClone(target) { /** * 遍历数据处理函数 * @array 要处理的数据 * @callback 回调函数,接收两个参数 value 每一项的值 index 每一项的下标或者key。 */ function handleWhile(array, callback) { const length = array.length; let index = -1; while (++index < length) { callback(array[index], index) } } function clone(target, map) { if (target !== null && typeof target === 'object') { let result = Object.prototype.toString.call(target) === "[object Array]" ? [] : {}; // 解决循环引用 if (map.has(target)) { return map.get(target); } map.set(target, result); const keys = Object.prototype.toString.call(target) === "[object Array]" ? undefined : Object.keys( target); function callback(value, key) { if (keys) { // 如果keys存在则说明value是一个对象的key,不存在则说明key就是数组的下标。 key = value; } result[key] = clone(target[key], map) } handleWhile(keys || target, callback) return result; } else { return target; } } let map = new WeakMap(); const result = clone(target,map); map = null; return result }
用 while 遍历的深拷贝记为 deepClone,把用 for ... in
遍历的深拷贝记为 deepClone1。利用 console.time()
和 console.timeEnd()
来计算执行时间。
let arr = []; for (let i = 0; i < 1000000; i++) { arr.push(i) } let data = { a: arr }; console.time(); const result = deepClone(data); console.timeEnd(); console.time(); const result1 = deepClone1(data); console.timeEnd();
从上图明显可以看到用 while 遍历的深拷贝的性能远优于用 for ... in
遍历的深拷贝。
在这里可以向面试官展示你的五个编程能力。
- 熟悉 ES6 中 WeakMap 数据结构的概念及应用
- 具有优化代码运行性能的能力。
- 了解遍历的效率的能力。
- 了解
++i
和i++
的区别。 - 代码抽象的能力。
星耀段位
在这个阶段应该考虑代码逻辑的严谨性。在上面段位的代码虽然已经满足平时开发的需求,但是还是有几处逻辑不严谨的地方。
- 判断数据不是引用类型时就直接返回 target,但是原始类型中还有 Symbol 这一特殊类型的数据,因为其每个 Symbol 都是独一无二,需要额外拷贝处理,不能直接返回。
- 判断数据是不是引用类型时不严谨,漏了
typeof target === function'
的判断。 - 只考虑了 Array、Object 两种引用类型数据的处理,引用类型的数据还有Function 函数、Date 日期、RegExp 正则、Map 数据结构、Set 数据机构,其中 Map 、Set 属于 ES6 的。
废话不多说,直接贴上全部代码,代码中有注释。
function deepClone(target) { // 获取数据类型 function getType(target) { return Object.prototype.toString.call(target) } //判断数据是不是引用类型 function isObject(target) { return target !== null && (typeof target === 'object' || typeof target === 'function'); } //处理不需要遍历的应引用类型数据 function handleOherData(target) { const type = getType(target); switch (type) { case "[object Date]": return new Date(target) case "[object RegExp]": return cloneReg(target) case "[object Function]": return cloneFunction(target) } } //拷贝Symbol类型数据 function cloneSymbol(targe) { const a = String(targe); //把Symbol字符串化 const b = a.substring(7, a.length - 1); //取出Symbol()的参数 return Symbol(b); //用原先的Symbol()的参数创建一个新的Symbol } //拷贝正则类型数据 function cloneReg(target) { const reFlags = /\w*$/; const result = new target.constructor(target.source, reFlags.exec(target)); result.lastIndex = target.lastIndex; return result; } //拷贝函数 function cloneFunction(targe) { //匹配函数体的正则 const bodyReg = /(?<={)(.|\n)+(?=})/m; //匹配函数参数的正则 const paramReg = /(?<=\().+(?=\)\s+{)/; const targeString = targe.toString(); //利用prototype来区分下箭头函数和普通函数,箭头函数是没有prototype的 if (targe.prototype) { //普通函数 const param = paramReg.exec(targeString); const body = bodyReg.exec(targeString); if (body) { if (param) { const paramArr = param[0].split(','); //使用 new Function 重新构造一个新的函数 return new Function(...paramArr, body[0]); } else { return new Function(body[0]); } } else { return null; } } else { //箭头函数 //eval和函数字符串来重新生成一个箭头函数 return eval(targeString); } } /** * 遍历数据处理函数 * @array 要处理的数据 * @callback 回调函数,接收两个参数 value 每一项的值 index 每一项的下标或者key。 */ function handleWhile(array, callback) { let index = -1; const length = array.length; while (++index < length) { callback(array[index], index); } } function clone(target, map) { if (isObject(target)) { let result = null; if (getType(target) === "[object Array]") { result = [] } else if (getType(target) === "[object Object]") { result = {} } else if (getType(target) === "[object Map]") { result = new Map(); } else if (getType(target) === "[object Set]") { result = new Set(); } // 解决循环引用 if (map.has(target)) { return map.get(target); } map.set(target, result); if (getType(target) === "[object Map]") { target.forEach((value, key) => { result.set(key, clone(value, map)); }); return result; } else if (getType(target) === "[object Set]") { target.forEach(value => { result.add(clone(value, map)); }); return result; } else if (getType(target) === "[object Object]" || getType(target) === "[object Array]") { const keys = getType(target) === "[object Array]" ? undefined : Object.keys(target); function callback(value, key) { if (keys) { // 如果keys存在则说明value是一个对象的key,不存在则说明key就是数组的下标。 key = value } result[key] = clone(target[key], map) } handleWhile(keys || target, callback) } else { result = handleOherData(target) } return result; } else { if (getType(target) === "[object Symbol]") { return cloneSymbol(target) } else { return target; } } } let map = new WeakMap; const result = clone(target, map); map = null; return result }
在这里可以向面试官展示你的六个编程能力。
- 代码逻辑的严谨性。
- 深入了解数据类型的能力。
- JS Api 的熟练使用的能力。
- 了解箭头函数和普通函数的区别。
- 熟练使用正则表达式的能力。
- 模块化开发的能力
王者段位
以上代码中还有很多数据类型的拷贝,没有实现,有兴趣的话可以在评论中实现一下,王者属于你哦!
总结
综上所述,面试官叫你实现一个深拷贝,其实是要考察你各方面的能力。例如
- 白银段位
- 对原始类型和引用类型数据的判断能力。
- 对递归思维的应用的能力。
- 黄金段位
- 正确理解引用类型概念的能力。
- 精确判断数据类型的能力。
- 铂金段位
- 对循环引用的理解,如何解决循环引用引起的问题的能力。
- 熟悉 ES6 中 Map 数据结构的概念及应用。
- 对内存泄露的认识和避免泄露的能力。
- 砖石段位
- 熟悉 ES6 中 WeakMap 数据结构的概念及应用。
- 具有优化代码运行性能的能力。
- 了解遍历的效率的能力。
- 了解
++i
和i++
的区别。 - 代码抽象的能力。
- 星耀段位
- 代码逻辑的严谨性。
- 深入了解数据类型的能力。
- JS Api 的熟练使用的能力。
- 了解箭头函数和普通函数的区别。
- 熟练使用正则表达式的能力。
- 模块化开发的能力
所以不要去死记硬背一些手写代码的面试题,最好自己动手写一下,看看自己达到那个段位了。
到此这篇关于前端面试的底气之实现一个深拷贝的文章就介绍到这了,更多相关深拷贝实现内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!