这一次,彻底理解JavaScript深拷贝
导语
这一次,通过本文彻底理解JavaScript深拷贝!
阅读本文前可以先思考三个问题:
- JS世界里,数据是如何存储的?
- 深拷贝和浅拷贝的区别是什么?
- 如何写出一个真正合格的深拷贝?
本文会一步步解答这三个问题
数据是如何存储的
先看一个问题,下面这段代码的输出结果是什么:
function foo(){
let a = {name:"dellyoung"}
let b = a
a.name = "dell"
console.log(a)
console.log(b)
}
foo()
JS的内存空间
要解答这个问题就要先了解,JS中数据是如何存储的。
要理解JS中数据是如何存储的,就要先明白其内存空间的种类。下图就是JS的内存空间模型。
从模型中我们可以看出JS内存空间分为:代码空间、栈空间、堆空间。
代码空间:代码空间主要是存储可执行代码的。
栈空间:栈(call stack
)指的就是调用栈,用来存储执行上下文的。(每个执行上下文包括了:变量环境、词法环境)
堆空间:堆(Heap
)空间,一般用来存储对象的。
JS的数据类型
现在我们已经了解JS内存空间了。接下来我们了解一下JS中的数据类型 :
JS中一共有8中数据类型:Number、BigInt、String、Boolean、Symble、Null、Undefined、Object。
前7种称为原始类型,最后一种Object称为引用类型,之所以把它们区分成两种类型,是因为它们在内存中存放的位置不同。
原始类型存放在栈空间中,具体点到执行上下文来说就是:用var定义的变量会存放在变量环境中,而用let、const定义的变量会存放在词法环境中。并且对原始类型来说存放的是值,而引用类型存放的是指针,指针指向堆内存中存放的真正内容。
好啦,现在我们就明白JS中数据是如何存储的了:原始类型存放在栈空间中,引用类型存放在堆空间中。
深拷贝和浅拷贝的区别
我们先来明确一下深拷贝和浅拷贝的定义:
浅拷贝
创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以修改新拷贝的对象会影响原对象。
深拷贝
将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
接下来我们就开始逐步实现一个深拷贝
自带版
一般情况下如果不使用loadsh的深拷贝函数,我们可能会这样写一个深拷贝函数
JSON.parse(JSON.stringify());
但是这个方法局限性比较大:
- 会忽略 undefined
- 会忽略 symbol
- 不能序列化函数
- 不能解决循环引用的对象
显然这绝对不是我们想要的一个合格的深拷贝函数
基本版
手动实现的话我们很容易写出如下函数
const clone = (target) => {
let cloneTarget = {};
Object.keys(target).forEach((item) => {
cloneTarget[item] = target[item]
});
return cloneTarget
}
先看下这个函数做了什么:创建一个新对象,遍历原对象,并且将需要拷贝的对象依次添加到新对象上,返回新对象。
既然是深拷贝的话,对于引用了类型我们不知道对象属性的深度,我们可以通过递归来解决这个问题,接下来我们修改一下上面的代码:
- 判断是否是引用类型,如果是原始类型的话直接返回就可以了。
- 如果是原始类型,那么我们需要创建一个对象,遍历原对象,将需要拷贝的对象执行深拷贝后再依次添加到新对象上。
- 另外如果对象有更深层次的对象,我们就可以通过递归来解决。
这样我们就实现了一个最基本的深拷贝函数:
// 是否是引用类型
const isObject = (target) => {
return typeof target === 'object';
};
const clone = (target) => {
// 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
let cloneTarget = {};
Object.keys(target).forEach((item) => {
cloneTarget[item] = clone(target[item])
});
return cloneTarget
}
显然这个深拷贝函数还有很多缺陷,比如:没有考虑包含数组的情况
考虑数组
上面代码中,我们只考虑了是object的情况,并没有考虑存在数组的情况。改成兼容数组也非常简单:
- 判断传入的对象是数组还是对象,我们分别对它们进行处理
- 判断类型的方法有很多比如 type of、instanceof,但是这两种方法缺陷都比较多,这里我使用的是Object.prototype.toString.call()的方法,它可以精准的判断各种类型
- 当判断出是数组时,那么我们需要创建一个新数组,遍历原数组,将需要数组中的每个值执行深拷贝后再依次添加到新的数组上,返回新数组。
代码如下:
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是否是引用类型
const isObject = (target) => {
return typeof target === 'object';
};
// 获取标准类型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
const clone = (target) => {
// 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
const type = getType(target);
let cloneTarget;
switch (type) {
case typeArray:
// 数组
cloneTarget = [];
target.forEach((item, index) => {
cloneTarget[index] = clone(item)
});
return cloneTarget;
case typeObject:
// 对象
cloneTarget = {};
Object.keys(target).forEach((item) => {
cloneTarget[item] = clone(target[item])
});
return cloneTarget;
default:
return target;
}
return cloneTarget
}
OK,这样我们的深拷贝函数就兼容了最常用的数组和对象的情况。
循环引用
但是如果出现下面这种情况
const target = {
field1: 1,
field2: {
child: 'dellyoung'
},
field3: [2, 4, 8]
};
target.target = target;
我们来拷贝这个target对象的话,就会发现会出现报错:循环引用导致了栈溢出。
解决循环引用问题,我们需要额外有一个空间,来专门存储已经被拷贝过的对象。当需要拷贝对象时,我们先从这个空间里找是否已经拷贝过,如果拷贝过了就直接返回这个对象,没有拷贝过就进行接下来的拷贝。需要注意的是只有可遍历的引用类型才会出现循环引用的情况。
很显然这种情况下我们使用Map,以key-value来存储就非常的合适:
- 用has方法检查Map中有无克隆过的对象
- 有的话就获取Map存入的值后直接返回
- 没有的话以当前对象为key,以拷贝得到的值为value存储到Map中
- 继续进行克隆
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是否是引用类型
const isObject = (target) => {
return typeof target === 'object';
};
// 获取标准类型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
const clone = (target, map = new Map()) => {
// 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
const type = getType(target);
// 用于返回
let cloneTarget;
// 处理循环引用
if (map.get(target)) {
// 已经放入过map的直接返回
return map.get(target)
}
switch (type) {
case typeArray:
// 数组
cloneTarget = [];
map.set(target, cloneTarget);
target.forEach((item, index) => {
cloneTarget[index] = clone(item, map)
});
return cloneTarget;
case typeObject:
// 对象
cloneTarget = {};
map.set(target, cloneTarget);
Object.keys(target).forEach((item) => {
cloneTarget[item] = clone(target[item], map)
});
return cloneTarget;
default:
return target;
}
return cloneTarget
}
性能优化
循环性能优化:
其实我们写代码的时候已经考虑到了性能优化了,比如:循环没有使用 for in 循环而是使用的forEach循环,使用forEach或while循环会比for in循环快上不少的
WeakMap性能优化:
我们可以使用WeakMap来替代Map,提高性能。
const clone = (target, map = new WeakMap()) => {
// ...
};
为什么要这样做呢?,先来看看WeakMap的作用:
WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。
那什么是弱引用呢?
在计算机程序设计中,弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。 一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。
我们默认创建一个对象:const obj = {},就默认创建了一个强引用的对象,我们只有手动将obj = null,它才会被垃圾回收机制进行回收,如果是弱引用对象,垃圾回收机制会自动帮我们回收。
我们来举个例子:
let obj = { name : 'dellyoung'}
const target = new Map();
target.set(obj,'dell');
obj = null;
虽然我们手动将obj赋值为null,进行释放,但是target依然对obj存在强引用关系,所以这部分内存依然无法被释放。
基于此我们再来看WeakMap:
let obj = { name : 'dellyoung'}
const target = new WeakMap();
target.set(obj,'dell');
obj = null;
如果是WeakMap的话,target和obj存在的就是弱引用关系,当下一次垃圾回收机制执行的时候,这块内存就会被释放掉了。
如果我们要拷贝的对象非常庞大时,使用Map会对内存造成非常大的额外消耗,而且我们需要手动delete Map的key才能释放这块内存,而WeakMap会帮我们解决这个问题。
更多的数据类型
到现在其实我们已经解决了Number BigInt String Boolean Symbol Undefined Null Object Array,这9种情况了,但是引用类型中我们其实只考虑了Object和Array两种数据类型,但是实际上所有的引用类型远远不止这两个。
判断引用类型
判断是否是引用类型还需要考虑null和function两种类型。
// 是否是引用类型
const isObject = (target) => {
if (target === null) {
return false;
} else {
const type = typeof target;
return type === 'object' || type === 'function';
}
};
获取数据类型
获取类型,我们可以使用toString来获取准确的引用类型:
每一个引用类型都有toString方法,默认情况下,toString()方法被每个Object对象继承。如果此方法在自定义对象中未被覆盖,toString() 返回 "[object type]",其中type是对象的类型。
但是由于大部分引用类型比如Array、Date、RegExp等都重写了toString方法,所以我们可以直接调用Object原型上未被覆盖的toString()方法,使用call来改变this指向来达到我们想要的效果
// 获取标准类型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
类型非常多,本文先考虑大部分常用的类型,其他类型就等小伙伴来探索啦
// 可遍历类型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 非原始类型的 不可遍历类型 Date RegExp Function
const typeDate = '[object Date]';
const typeRegExp = '[object RegExp]';
const typeFunction = '[object Function]';
可继续遍历类型
上面我们已经考虑的Object、Array都属于可以继续遍历的类型,因为它们内存都还可以存储其他数据类型的数据,另外还有Map,Set等都是可以继续遍历的类型,这里我们只考虑这四种常用的,其他类型等你来探索咯。
下面,我们改写clone函数,使其对可继续遍历的数据类型进行处理:
// 可遍历类型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是否是引用类型
const isObject = (target) => {
if (target === null) {
return false;
} else {
const type = typeof target;
return type === 'object' || type === 'function';
}
};
// 获取标准类型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
/*
* 1、处理原始类型 Number String Boolean Symbol Null Undefined
* 2、处理循环引用情况 WeakMap
* 3、处理可遍历类型 Set Map Array Object
* */
const clone = (target, map = new WeakMap()) => {
// 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
// 用于返回
let cloneTarget;
// 处理循环引用
if (map.get(target)) {
// 已经放入过map的直接返回
return map.get(target)
}
// 处理可遍历类型
switch (type) {
case typeSet:
// Set
cloneTarget = new Set();
map.set(target, cloneTarget);
target.forEach((item) => {
cloneTarget.add(clone(item, map))
});
return cloneTarget;
case typeMap:
// Map
cloneTarget = new Map();
map.set(target, cloneTarget);
target.forEach((value, key) => {
cloneTarget.set(key, clone(value, map))
});
return cloneTarget;
case typeArray:
// 数组
cloneTarget = [];
map.set(target, cloneTarget);
target.forEach((item, index) => {
cloneTarget[index] = clone(item, map)
});
return cloneTarget;
case typeObject:
// 对象
cloneTarget = {};
map.set(target, cloneTarget);
Object.keys(target).forEach((item) => {
cloneTarget[item] = clone(target[item], map)
});
return cloneTarget;
default:
return target;
}
};
这样我们就完成了对Set和Map的兼容
考虑对象键名为Symbol类型
对于对象键名为Symbol
类型时,用Object.keys(target)
是获取不到的,这时候就需要用到Object.getOwnPropertySymbols(target)
方法。
case typeObject:
// 对象
cloneTarget = {};
map.set(target, cloneTarget);
[...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => {
cloneTarget[item] = clone(target[item], map)
});
return cloneTarget;
这样就实现了对于对象键名为Symbol
类型的兼容。
不可继续遍历类型
不可遍历的类型有Number BigInt String Boolean Symbol Undefined Null Date RegExp Function 等等,但是前7中已经被isObject拦截了,于是我们先对后面Date RegExp Function进行处理,其实后面不止有这几种,其他类型等你来探索咯。
其中对函数的处理要简单说下,我认为克隆函数是没有必要的其实,两个对象使用一个在内存中处于同一个地址的函数也是没有任何问题的,如下是lodash对函数的处理:
const isFunc = typeof value == 'function'
if (isFunc || !cloneableTags[tag]) {
return object ? value : {}
}
显然如果发现是函数的话就会直接返回了,没有做特殊的处理,这里我们暂时也这样处理,以后有时间我会把拷贝函数的部分给补上。
// 可遍历类型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 非原始类型的 不可遍历类型 Date RegExp Function
const typeDate = '[object Date]';
const typeRegExp = '[object RegExp]';
const typeFunction = '[object Function]';
// 非原始类型的 不可遍历类型的 集合(原始类型已经被过滤了不用再考虑了)
const simpleType = [typeDate, typeRegExp, typeFunction];
// 是否是引用类型
const isObject = (target) => {
if (target === null) {
return false;
} else {
const type = typeof target;
return type === 'object' || type === 'function';
}
};
// 获取标准类型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
/*
* 1、处理原始类型 Number String Boolean Symbol Null Undefined
* 2、处理不可遍历类型 Date RegExp Function
* 3、处理循环引用情况 WeakMap
* 4、处理可遍历类型 Set Map Array Object
* */
const clone = (target, map = new WeakMap()) => {
// 处理原始类型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
// 处理不可遍历类型
const type = getType(target);
if (simpleType.includes(type)) {
switch (type) {
case typeDate:
// 日期
return new Date(target);
case typeRegExp:
// 正则
const reg = /\w*$/;
const result = new RegExp(target.source, reg.exec(target)[0]);
result.lastIndex = target.lastIndex; // lastIndex 表示每次匹配时的开始位置
return result;
case typeFunction:
// 函数
return target;
default:
return target;
}
}
// 用于返回
let cloneTarget;
// 处理循环引用
if (map.get(target)) {
// 已经放入过map的直接返回
return map.get(target)
}
// 处理可遍历类型
switch (type) {
case typeSet:
// Set
cloneTarget = new Set();
map.set(target, cloneTarget);
target.forEach((item) => {
cloneTarget.add(clone(item, map))
});
return cloneTarget;
case typeMap:
// Map
cloneTarget = new Map();
map.set(target, cloneTarget);
target.forEach((value, key) => {
cloneTarget.set(key, clone(value, map))
});
return cloneTarget;
case typeArray:
// 数组
cloneTarget = [];
map.set(target, cloneTarget);
target.forEach((item, index) => {
cloneTarget[index] = clone(item, map)
});
return cloneTarget;
case typeObject:
// 对象
cloneTarget = {};
map.set(target, cloneTarget);
[...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => {
cloneTarget[item] = clone(target[item], map)
});
return cloneTarget;
default:
return target;
}
};
至此这个深拷贝函数已经能处理大部分的类型了:Number String Boolean Symbol Null Undefined Date RegExp Function Set Map Array Object,并且也能优秀的处理循环引用情况了
参考
总结
现在我们应该能理清楚写一个合格深拷贝的思路了:
- 处理原始类型 如: Number String Boolean Symbol Null Undefined
- 处理不可遍历类型 如: Date RegExp Function
- 处理循环引用情况 使用: WeakMap
- 处理可遍历类型 如: Set Map Array Object
看完两件事
- 欢迎加我微信(iamyyymmm),拉你进技术群,长期交流学习
- 关注公众号「呆鹅实验室」,和呆鹅一起学前端,提高技术认知
点一个在看支持我吧
这一次,彻底理解JavaScript深拷贝的更多相关文章
- 理解JavaScript继承(三)
理解JavaScript继承(三) 通过把父对象的属性,全部拷贝给子对象,也能实现继承. 7.浅拷贝 function extendCopy(p) { var o = {}; for (var pro ...
- 深入理解JavaScript系列(46):代码复用模式(推荐篇)
介绍 本文介绍的四种代码复用模式都是最佳实践,推荐大家在编程的过程中使用. 模式1:原型继承 原型继承是让父对象作为子对象的原型,从而达到继承的目的: function object(o) { fun ...
- 深入理解JavaScript系列(42):设计模式之原型模式
介绍 原型模式(prototype)是指用原型实例指向创建对象的种类,并且通过拷贝这些原型创建新的对象. 正文 对于原型模式,我们可以利用JavaScript特有的原型继承特性去创建对象的方式,也就是 ...
- 全方位深入理解JavaScript面向对象
JavaScript面向对象程序设计 转载:https://blog.csdn.net/lihangxiaoji/article/details/79753473#72__871 本文会碰到的知识点: ...
- 深入理解javascript原型和闭包 (转)
该教程绕开了javascript的一些基本的语法知识,直接讲解javascript中最难理解的两个部分,也是和其他主流面向对象语言区别最大的两个部分--原型和闭包,当然,肯定少不了原型链和作用域链.帮 ...
- 深入理解JavaScript运行机制
深入理解JavaScript运行机制 前言 本文是写作在给团队新人培训之际,所以其实本文的受众是对JavaScript的运行机制不了解或了解起来有困难的小伙伴.也就是说,其实真正的原理和本文阐述的并不 ...
- 深入理解javascript系列(4):立即调用的函数表达式
本文来自汤姆大叔 前言 大家学JavaScript的时候,经常遇到自执行匿名函数的代码,今天我们主要就来想想说一下自执行. 在详细了解这个之前,我们来谈了解一下“自执行”这个叫法,本文对这个功能的叫法 ...
- 深入理解javascript原型和闭包系列
从下面目录中可以看到,本系列有16篇文章,外加两篇后补的,一共18篇文章.写了半个月,从9月17号开始写的.每篇文章更新时,读者的反馈还是可以的,虽然不至于上头条,但是也算是中规中矩,有看的人,也有评 ...
- 深入理解JavaScript的闭包特性如何给循环中的对象添加事件
初学者经常碰到的,即获取HTML元素集合,循环给元素添加事件.在事件响应函数中(event handler)获取对应的索引.但每次获取的都是最后一次循环的索引.原因是初学者并未理解JavaScript ...
随机推荐
- 什么是谷歌PageRank (简称PR值)
http://www.wocaoseo.com/thread-213-1-1.html 谷歌pageRank是谷歌用来评测网页质量高低的一个工具,主要分为0到10共11个等级,目前有很多的工具或谷歌工 ...
- 3点带你快速学会Selenium工具的使用
(一)Selenium IDE Firefox的一个插件,有助于我们理解测试框架.在附加组件里搜索下载,一般搜的结果里前几个都不是,得点那个查看更多才行,找到这个: 安装以后浏览器工具栏会有: 安装好 ...
- python3笔记-列表
列表去重的两种方式: # 创建列表放数据 a =[1,2,1,4,2] b=[1,3,4,3,1,3] d=[] for i in a: if i not in d: d.append(i) prin ...
- WebApi OAuth2身份认证
一.什么是OAuth OAuth是一个关于授权(Authorization)的开放网络标准,目前的版本是2.0版.注意是Authorization(授权),而不是Authentication(认证). ...
- 本地模拟 gitlab ci 的 demo 项目
构建好的镜像能跑起来, 项目地址: https://github.com/szliugx/gitlab-ci-local 为了不每次提交,本地可以做一些模拟 gitlab ci 的测试≍,主要实现方式 ...
- RocketMQ生产部署架构如何设计
前言 看了我们之前的文章,相信小伙伴们对RocketMQ已经有了一个初步的了解,那么今天我们就来聊一聊具体如何来设计一套高可用的生产部署架构. 在聊如何设计这套架构的同时,我们再补充一些之前没提到的知 ...
- 鼠标移到图片上图片放大【css3实例】
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title> ...
- HTML常用实体字符参考手册
最常用的字符实体 显示结果 描述 实体名称 实体编号 空格 < 小于号 < < > 大于号 > > & 和号 & & " ...
- Linux通过命令行将英文改成中文
1.首先查看当前系统是否有中文语言包 locale -a 如果没有zh_CN.utf8,就需要下载中文语言包,否则,如果有中文语言包,跳过第二步. 2.安装中文语言包 Ubuntu: sudo apt ...
- ASP.NET Core 性能优化最佳实践
本文提供了 ASP.NET Core 的性能最佳实践指南. 译文原文地址:https://docs.microsoft.com/en-us/aspnet/core/performance/perfor ...