面试 | 深拷贝的终极探索(99%的人都不知道)

开发 前端
本文我将给大家破解深拷贝的谜题,由浅入深,环环相扣,总共涉及4种深拷贝方式,每种方式都有自己的特点和个性。

划重点,这是一道面试必考题,我靠这道题刷掉了多少面试者✧(≖ ◡ ≖✿)嘿嘿

首先这是一道非常棒的面试题,可以考察面试者的很多方面,比如基本功,代码能力,逻辑能力,而且进可攻,退可守,针对不同级别的人可以考察不同难度,比如漂亮妹子就出1☆题,要是个帅哥那就得上5☆了,(*^__^*) 嘻嘻……

无论面试者多么优秀,漂亮的回答出问题,我总能够潇洒的再抛出一个问题,看着面试者露出惊异的眼神,默默一转身,深藏功与名。

本文我将给大家破解深拷贝的谜题,由浅入深,环环相扣,总共涉及4种深拷贝方式,每种方式都有自己的特点和个性。

深拷贝 VS 浅拷贝

再开始之前需要先给同学科普下什么是深拷贝,和深拷贝有关系的另个一术语是浅拷贝又是什么意思呢?如果对这部分部分内容了解的同学可以跳过

其实深拷贝和浅拷贝都是针对的引用类型,JS中的变量类型分为值类型(基本类型)和引用类型;对值类型进行复制操作会对值进行一份拷贝,而对引用类型赋值,则会进行地址的拷贝,最终两个变量指向同一份数据 

  1. // 基本类型  
  2. var a = 1;  
  3. var b = a;  
  4. a = 2;  
  5. console.log(a, b); // 2, 1 ,a b指向不同的数据  
  6. // 引用类型指向同一份数据  
  7. var a = {c: 1};  
  8. var b = a;  
  9. a.c = 2;  
  10. console.log(a.c, b.c); // 2, 2 全是2,a b指向同一份数据  

对于引用类型,会导致a b指向同一份数据,此时如果对其中一个进行修改,就会影响到另外一个,有时候这可能不是我们想要的结果,如果对这种现象不清楚的话,还可能造成不必要的bug

那么如何切断a和b之间的关系呢,可以拷贝一份a的数据,根据拷贝的层级不同可以分为浅拷贝和深拷贝,浅拷贝就是只进行一层拷贝,深拷贝就是无限层级拷贝 

  1. var a1 = {b: {c: {}};  
  2. var a2 = shallowClone(a1); // 浅拷贝  
  3. a2.b.c === a1.b.c // true  
  4. var a3 = clone(a3); // 深拷贝  
  5. a3.b.c === a1.b.c // false  

浅拷贝的实现非常简单,而且还有多种方法,其实就是遍历对象属性的问题,这里只给出一种,如果看不懂下面的方法,或对其他方法感兴趣,可以看我的这篇文章 

  1. function shallowClone(source) {  
  2. var target = {};  
  3. for(var i in source) { 
  4. if (source.hasOwnProperty(i)) {  
  5. target[i] = source[i];  
  6.  
  7.  
  8. return target;  
  9.  

最简单的深拷贝

深拷贝的问题其实可以分解成两个问题,浅拷贝+递归,什么意思呢?假设我们有如下数据

  1. var a1 = {b: {c: {d: 1}}; 

只需稍加改动上面浅拷贝的代码即可,注意区别 

  1. function clone(source) {  
  2. var target = {};  
  3. for(var i in source) {  
  4. if (source.hasOwnProperty(i)) {  
  5. if (typeof source[i] === 'object') {  
  6. target[i] = clone(source[i]); // 注意这里  
  7. else {  
  8. target[i] = source[i];  
  9.  
  10.  
  11.  
  12. return target;  
  13.  

大部分人都能写出上面的代码,但当我问上面的代码有什么问题吗?就很少有人答得上来了,聪明的你能找到问题吗?

其实上面的代码问题太多了,先来举几个例子吧

  • 没有对参数做检验
  • 判断是否对象的逻辑不够严谨
  • 没有考虑数组的兼容

(⊙o⊙),下面我们来看看各个问题的解决办法,首先我们需要抽象一个判断对象的方法,其实比较常用的判断对象的方法如下,其实下面的方法也有问题,但如果能够回答上来那就非常不错了,如果完美的解决办法感兴趣,不妨看看这里吧 

  1. function isObject(x) {  
  2. return Object.prototype.toString.call(x) === '[object Object]' 
  3.  

函数需要校验参数,如果不是对象的话直接返回 

  1. function clone(source) {  
  2. if (!isObject(source)) return source;  
  3. // xxx  
  4.  

关于第三个问题,嗯,就留给大家自己思考吧,本文为了减轻大家的负担,就不考虑数组的情况了,其实ES6之后还要考虑set, map, weakset, weakmap,/(ㄒoㄒ)/~~

其实吧这三个都是小问题,其实递归方法最大的问题在于爆栈,当数据的层次很深是就会栈溢出

下面的代码可以生成指定深度和每层广度的代码,这段代码我们后面还会再次用到 

  1. function createData(deep, breadth) {  
  2. var data = {};  
  3. var temp = data;  
  4. for (var i = 0; i < deep; i++) { 
  5. temp = temp['data'] = {};  
  6. for (var j = 0; j < breadth; j++) { 
  7. temp[j] = j;  
  8.  
  9.  
  10. return data;  
  11.  
  12. createData(1, 3); // 1层深度,每层有3个数据 {data: {0: 0, 1: 1, 2: 2}}  
  13. createData(3, 0); // 3层深度,每层有0个数据 {data: {data: {data: {}}}}  

当clone层级很深的话就会栈溢出,但数据的广度不会造成溢出 

  1. clone(createData(1000)); // ok  
  2. clone(createData(10000)); // Maximum call stack size exceeded  
  3. clone(createData(10, 100000)); // ok 广度不会溢出  

其实大部分情况下不会出现这么深层级的数据,但这种方式还有一个致命的问题,就是循环引用,举个例子 

  1. var a = {};  
  2. a.a = a;  
  3. clone(a) // Maximum call stack size exceeded 直接死循环了有没有,/(ㄒoㄒ)/~~  

关于循环引用的问题解决思路有两种,一直是循环检测,一种是暴力破解,关于循环检测大家可以自己思考下;关于暴力破解我们会在下面的内容中详细讲解

一行代码的深拷贝

有些同学可能见过用系统自带的JSON来做深拷贝的例子,下面来看下代码实现 

  1. function cloneJSON(source) {  
  2. return JSON.parse(JSON.stringify(source));  
  3.  

其实我第一次简单这个方法的时候,由衷的表示佩服,其实利用工具,达到目的,是非常聪明的做法

下面来测试下cloneJSON有没有溢出的问题,看起来cloneJSON内部也是使用递归的方式 

  1. cloneJSON(createData(10000)); // Maximum call stack size exceeded 

既然是用了递归,那循环引用呢?并没有因为死循环而导致栈溢出啊,原来是JSON.stringify内部做了循环引用的检测,正是我们上面提到破解循环引用的第一种方法:循环检测 

  1. var a = {};  
  2. a.a = a;  
  3. cloneJSON(a) // Uncaught TypeError: Converting circular structure to JSON  

破解递归爆栈

其实破解递归爆栈的方法有两条路,第一种是消除尾递归,但在这个例子中貌似行不通,第二种方法就是干脆不用递归,改用循环,当我提出用循环来实现时,基本上90%的前端都是写不出来的代码的,这其实让我很震惊

举个例子,假设有如下的数据结构

  1. var a = { 
  2.     a1: 1, 
  3.     a2: { 
  4.         b1: 1, 
  5.         b2: { 
  6.             c1: 1 
  7.         } 
  8.    } 
  9.  

这不就是一个树吗,其实只要把数据横过来看就非常明显了

  1.  /   \ 
  2. a1   a2         
  3. |    / \          
  4. 1   b1 b2      
  5.     |   |         
  6.     1  c1 
  7.         | 
  8.         1    

用循环遍历一棵树,需要借助一个栈,当栈为空时就遍历完了,栈里面存储下一个需要拷贝的节点

首先我们往栈里放入种子数据,key用来存储放哪一个父元素的那一个子元素拷贝对象

然后遍历当前节点下的子元素,如果是对象就放到栈里,否则直接拷贝 

  1. function cloneLoop(x) { 
  2.     const root = {}; 
  3.  
  4.     // 栈 
  5.     const loopList = [ 
  6.         { 
  7.             parent: root, 
  8.             key: undefined, 
  9.             data: x, 
  10.         } 
  11.     ]; 
  12.  
  13.     while(loopList.length) { 
  14.         // 深度优先 
  15.         const node = loopList.pop(); 
  16.         const parent = node.parent; 
  17.         const key = node.key
  18.         const data = node.data; 
  19.  
  20.         // 初始化赋值目标,key为undefined则拷贝到父元素,否则拷贝到子元素 
  21.         let res = parent; 
  22.         if (typeof key !== 'undefined') { 
  23.             res = parent[key] = {}; 
  24.         } 
  25.  
  26.         for(let k in data) { 
  27.             if (data.hasOwnProperty(k)) { 
  28.                 if (typeof data[k] === 'object') { 
  29.                     // 下一次循环 
  30.                     loopList.push({ 
  31.                         parent: res, 
  32.                         key: k, 
  33.                         data: data[k], 
  34.                     }); 
  35.                 } else { 
  36.                     res[k] = data[k]; 
  37.                 } 
  38.             } 
  39.         } 
  40.     } 
  41.  
  42.     return root; 
  43.  

改用循环后,再也不会出现爆栈的问题了,但是对于循环引用依然无力应对

破解循环引用

有没有一种办法可以破解循环应用呢?别着急,我们先来看另一个问题,上面的三种方法都存在的一个问题就是引用丢失,这在某些情况下也许是不能接受的

举个例子,假如一个对象a,a下面的两个键值都引用同一个对象b,经过深拷贝后,a的两个键值会丢失引用关系,从而变成两个不同的对象,o(╯□╰)o 

  1. var b = 1;  
  2. var a = {a1: b, a2: b};  
  3. a.a1 === a.a2 // true  
  4. var c = clone(a);  
  5. c.a1 === c.a2 // false  

如果我们发现个新对象就把这个对象和他的拷贝存下来,每次拷贝对象前,都先看一下这个对象是不是已经拷贝过了,如果拷贝过了,就不需要拷贝了,直接用原来的,这样我们就能够保留引用关系了,✧(≖ ◡ ≖✿)嘿嘿

但是代码怎么写呢,o(╯□╰)o,别急往下看,其实和循环的代码大体一样,不一样的地方我用// ==========标注出来了

引入一个数组uniqueList用来存储已经拷贝的数组,每次循环遍历时,先判断对象是否在uniqueList中了,如果在的话就不执行拷贝逻辑了

find是抽象的一个函数,其实就是遍历uniqueList 

  1. // 保持引用关系 
  2. function cloneForce(x) { 
  3.     // ============= 
  4.     const uniqueList = []; // 用来去重 
  5.     // ============= 
  6.  
  7.     let root = {}; 
  8.  
  9.     // 循环数组 
  10.     const loopList = [ 
  11.         { 
  12.             parent: root, 
  13.             key: undefined, 
  14.             data: x, 
  15.         } 
  16.     ]; 
  17.  
  18.     while(loopList.length) { 
  19.         // 深度优先 
  20.         const node = loopList.pop(); 
  21.         const parent = node.parent; 
  22.         const key = node.key
  23.         const data = node.data; 
  24.  
  25.         // 初始化赋值目标,key为undefined则拷贝到父元素,否则拷贝到子元素 
  26.         let res = parent; 
  27.         if (typeof key !== 'undefined') { 
  28.             res = parent[key] = {}; 
  29.         } 
  30.          
  31.         // ============= 
  32.         // 数据已经存在 
  33.         let uniqueData = find(uniqueList, data); 
  34.         if (uniqueData) { 
  35.             parent[key] = uniqueData.target; 
  36.             break; // 中断本次循环 
  37.         } 
  38.  
  39.         // 数据不存在 
  40.         // 保存源数据,在拷贝数据中对应的引用 
  41.         uniqueList.push({ 
  42.             source: data, 
  43.             target: res, 
  44.         }); 
  45.         // ============= 
  46.      
  47.         for(let k in data) { 
  48.             if (data.hasOwnProperty(k)) { 
  49.                 if (typeof data[k] === 'object') { 
  50.                     // 下一次循环 
  51.                     loopList.push({ 
  52.                         parent: res, 
  53.                         key: k, 
  54.                         data: data[k], 
  55.                     }); 
  56.                 } else { 
  57.                     res[k] = data[k]; 
  58.                 } 
  59.             } 
  60.         } 
  61.     } 
  62.  
  63.     return root; 
  64.  
  65. function find(arr, item) { 
  66.     for(let i = 0; i < arr.length; i++) { 
  67.         if (arr[i].source === item) { 
  68.             return arr[i]; 
  69.         } 
  70.     } 
  71.  
  72.     return null
  73.  

下面来验证一下效果,amazing 

  1. var b = 1;  
  2. var a = {a1: b, a2: b};  
  3. a.a1 === a.a2 // true  
  4. var c = cloneForce(a);  
  5. c.a1 === c.a2 // true  

接下来再说一下如何破解循环引用,等一下,上面的代码好像可以破解循环引用啊,赶紧验证一下

惊不惊喜,(*^__^*) 嘻嘻…… 

  1. var a = {};  
  2. a.a = a;  
  3. cloneForce(a)  

看起来完美的cloneForce是不是就没问题呢?cloneForce有两个问题

第一个问题,所谓成也萧何,败也萧何,如果保持引用不是你想要的,那就不能用cloneForce了;

第二个问题,cloneForce在对象数量很多时会出现很大的问题,如果数据量很大不适合使用cloneForce

性能对比

上边的内容还是有点难度,下面我们来点更有难度的,对比一下不同方法的性能

我们先来做实验,看数据,影响性能的原因有两个,一个是深度,一个是每层的广度,我们采用固定一个变量,只让一个变量变化的方式来测试性能

测试的方法是在指定的时间内,深拷贝执行的次数,次数越多,证明性能越好

下面的runTime是测试代码的核心片段,下面的例子中,我们可以测试在2秒内运行clone(createData(500, 1)的次数 

  1. function runTime(fn, time) { 
  2.     var stime = Date.now(); 
  3.     var count = 0; 
  4.     while(Date.now() - stime < time) { 
  5.         fn(); 
  6.         count++; 
  7.     } 
  8.  
  9.     return count
  10.  
  11. runTime(function () { clone(createData(500, 1)) }, 2000);  

下面来做第一个测试,将广度固定在100,深度由小到大变化,记录1秒内执行的次数 

深度 clone cloneJSON cloneLoop cloneForce
500 351 212 338 372
1000 174 104 175 143
1500 116 67 112 82
2000 92 50 88 69

 

将上面的数据做成表格可以发现,一些规律

  • 随着深度变小,相互之间的差异在变小
  • clone和cloneLoop的差别并不大
  • cloneLoop > cloneForce > cloneJSON

 

 

 

 

我们先来分析下各个方法的时间复杂度问题,各个方法要做的相同事情,这里就不计算,比如循环对象,判断是否为对象

  • clone时间 = 创建递归函数 + 每个对象处理时间
  • cloneJSON时间 = 循环检测 + 每个对象处理时间 * 2 (递归转字符串 + 递归解析)
  • cloneLoop时间 = 每个对象处理时间
  • cloneForce时间 = 判断对象是否缓存中 + 每个对象处理时间

cloneJSON的速度只有clone的50%,很容易理解,因为其会多进行一次递归时间

cloneForce由于要判断对象是否在缓存中,而导致速度变慢,我们来计算下判断逻辑的时间复杂度,假设对象的个数是n,则其时间复杂度为O(n2),对象的个数越多,cloneForce的速度会越慢

1 + 2 + 3 ... + n = n^2/2 - 1

关于clone和cloneLoop这里有一点问题,看起来实验结果和推理结果不一致,其中必有蹊跷

接下来做第二个测试,将深度固定在10000,广度固定为0,记录2秒内执行的次数 

宽度 clone cloneJSON cloneLoop cloneForce
0 13400 3272 14292 989

排除宽度的干扰,来看看深度对各个方法的影响

  • 随着对象的增多,cloneForce的性能低下凸显
  • cloneJSON的性能也大打折扣,这是因为循环检测占用了很多时间
  • cloneLoop的性能高于clone,可以看出递归新建函数的时间和循环对象比起来可以忽略不计

下面我们来测试一下cloneForce的性能极限,这次我们测试运行指定次数需要的时间 

  1. var data1 = createData(2000, 0); 
  2. var data2 = createData(4000, 0); 
  3. var data3 = createData(6000, 0); 
  4. var data4 = createData(8000, 0); 
  5. var data5 = createData(10000, 0); 
  6.  
  7. cloneForce(data1) 
  8. cloneForce(data2) 
  9. cloneForce(data3) 
  10. cloneForce(data4) 
  11. cloneForce(data5)  

通过测试发现,其时间成指数级增长,当对象个数大于万级别,就会有300ms以上的延迟

 

 

 

 

总结

尺有所短寸有所长,无关乎好坏优劣,其实每种方法都有自己的优缺点,和适用场景,人尽其才,物尽其用,方是真理

下面对各种方法进行对比,希望给大家提供一些帮助 

  clone cloneJSON cloneLoop cloneForce
难度 ☆☆ ☆☆☆ ☆☆☆☆
兼容性 ie6 ie8 ie6 ie6
循环引用 一层 不支持 一层 支持
栈溢出 不会 不会
保持引用
适合场景 一般数据拷贝 一般数据拷贝 层级很多 保持引用关系

本文的灵感都来自于@jsmini/clone,如果大家想使用文中的4种深拷贝方式,可以直接使用@jsmini/clone这个库 

  1. // npm install --save @jsmini/clone  
  2. import { clone, cloneJSON, cloneLoop, cloneForce } from '@jsmini/clone' 

本文为了简单和易读,示例代码中忽略了一些边界情况,如果想学习生产中的代码,请阅读@jsmini/clone的源码

责任编辑:庞桂玉 来源: segmentfault
相关推荐

2020-07-29 09:53:09

VSCode编码工具插件

2023-01-13 16:48:48

前端开发JavaScript

2021-07-22 09:28:35

DockerLinux命令

2022-06-19 14:38:55

Python

2022-10-31 18:38:24

MySQL数据订单表

2021-09-24 14:20:25

开发技能工具

2022-06-23 13:13:36

GitHub开发技巧

2020-12-21 09:00:04

MySQL缓存SQL

2020-08-04 09:57:52

JS开发前端

2023-10-11 08:16:42

客户端服务器内容

2017-10-22 15:34:34

手机内存清理内存手机

2020-12-21 09:44:53

MySQL查询缓存数据库

2021-01-12 12:33:20

Pandas技巧代码

2020-11-12 10:16:32

企业信息化

2021-11-12 10:05:19

跳表BAT面试

2022-09-06 07:57:10

生命周期SpringIoC

2019-07-12 15:28:41

缓存数据库浏览器

2022-10-29 17:34:18

JVMJava

2021-07-26 05:17:39

Linux PosixLinux 系统

2009-12-10 09:37:43

点赞
收藏

51CTO技术栈公众号