最近在整理js相关的文档,回想起一个前端说难不难,说简单又能考验职业素养(基本功)扎实程度的问题:js之深浅拷贝的问题,相信前端的同窗都或少或多的了解及懂得其根由,在这里我就很少赘述,我们先简单的抛出几个问题:javascript
咱们先来聊下如何理解深浅拷贝。前端
咱们知道,许多变成语言,赋值和参数传递能够经过值复制(value-copy)或者引用复制(reference-copy)来完成,这取决于语法的不一样。vue
JavaScript 中引用指向的是值。若是一个值有不止一个引用,这些引用都指向的是同一值,他们相互之间没有引用 / 指向关系。java
JavaScript 对值和引用的赋值 / 传递在语法上没法区分,彻底取决于值得类型来决定。数组
来看一下下面的列子:缓存
var a = 3; var b = a; // b是a值的一个副本 b++; a = 3; b = 4; var c = [0, 1, 2]; var d = c; // d是[0, 1, 2]的一个引用 d.push(3); c; // [1, 2, 3, 4] d; // [1, 2, 3, 4]
简单值(即标量基本类型)老是经过值复制的方式来赋值 / 传递, 包括 null
、undefined
、字符串
、number
、布尔值
和 ES6 中的 symbol
以及最新的 bigint
七种基本类型。数据结构
复合值(compound value)—— 对象(包括数组和封装对象)和函数,老是经过引用复制的方式来赋值 / 传递。函数
上例中 3 是一个标量基本数据类型,因此变量 a 持有该值的一个副本,b 持有它的另外一个副本。b 更改时,a 值保持不变。工具
c 和 d则分别指向同一个符合值 [0, 1, 2] 的两个不一样引用。请注意,c 和 d 仅仅是指向值 [0, 1, 2], 并不是持有。因此它们更改的是同一值(调用 push 方法),随后它们都指向了更改后的新值 [0, 1, 2, 3]。性能
因为引用指向的是值自己而非变量,因此一个引用没法更改另外一个引用的指向。
var a = [1, 2, 3]; var b = a; a; // [1, 2, 3] b; // [1, 2, 3] b = [4, 5, 6]; a; // [1, 2, 3] b; // [4, 5, 6]
b=[4, 5, 6] 并不影响 a 指向 [1, 2, 3],除非 b 指向的不是数组的引用,而是指向 a 的指针,这样 b 的赋值就会影响到 a ,可是 JavaScript 中不存在这种状况。
注意:JavaScript 中没有指针的概念,引用的工做机制也不尽相同,在 JavaScript 中变量不可能成为指向另外一个变量的引用。
<img :src="$withBase('/栈内存.jpg')" alt="foo">
咱们再看一个例子:
var m = [1, 2, 3]; function fn(n) { n.push(4); n; // [1, 2, 3, 4] n = [4, 5, 6]; n.push(7); n; // [4, 5, 6, 7] } fn(m); m; // 是[1, 2, 3, 4] 而不是 [4, 5, 6, 7]
在调用函数传递参数的时候,其实是将引用 m 的一个副本赋值给 n,因此在当调用 push(4) 操做的时候,由于都指向同一对象 [1, 2, 3, 4] ,可是当手动改变 n 的引用的时候,这时候并不影响 m,因此才会出现最终的 m 是[1, 2, 3, 4] 而不是 [4, 5, 6, 7]。
若是咱们不想要函数外的变量受到牵连,能够先建立一个复本,这样就不会影响原始值。例如:
fn(n.slice())
不带参数的slice()
方法会返回当前数组的一个浅复本,因为传递给函数的是指向该副本的引用,因此内部操做n 就再也不影响 m 。
目前为止,咱们大体理解了不一样类型的数据在复制的时候可能会形成相互的影响,因此实现一个深拷贝就显得颇有必要了。
接下来咱们将会逐步的展开介绍,最终实现一种较为完善的深拷贝。
没错,这也是最容易想到的一种,仅仅经过序列化和反序列化实现。
JSON.parse(JSON.stringify());
这种写法虽然能够应对大部分的应用场景,可是它仍是有很大缺陷的,好比拷贝拷贝函数、循环引用结构、其余类型的对象(Reg、Map)等状况。
使用
JSON.stringify
注意:
undefined
、任意的函数以及 symbol 值,在序列化过程当中会被忽略- 对包含循环引用的对象(对象之间相互引用,造成无限循环)执行此方法,会抛出错误
- 其余类型的对象,包括 Map/Set/WeakMap/WeakSet,仅会序列化可枚举的属性
因此使用这种方法仅适用数据格式简单的对象。
若是是浅拷贝的话,相似于 jqery
里面的extend,很简单仅仅作的是遍历属性进行赋值:
function clone(target) { let _target = {}; for (const key in target) { _target[key] = target[key]; } return _target; };
若是是深拷贝的话,而且不知道嵌套对象的层级结构,咱们可使用递归来实现:
function deepClone(target) { if (typeof target === 'object') { let _target = {}; for (const key in target) { _target[key] = deepClone(target[key]); } return _target; } else { return target; } };
虽然这里基本实现了一个深拷贝的 demo,可是咱们应该会想到缺乏点什么。没错,就是Array
,其实考虑到Array
的状况也很简单,咱们稍微该一下。
其实思路很简单,就是在咱们每次遍历建立新对象的时候对数组进行兼容就 ok 了:
function deepClone(target) { if (typeof target === 'object') { let _target = Array.isArray(target) ? [] : {}; for (const key in target) { _target[key] = deepClone(target[key]); } return _target; } else { return target; } };
目前为止,咱们基本上实现一个深拷贝的例子。可是,就像咱们会考虑到数组的状况,还有一种状况不常见,但却不能忽视的一个问题: 循环引用(circularReference)。
咱们执行下面这样一个测试用例:
const target = { refer: 'circularReference' }; target.refer = target;
咱们在控制台上输出一下:
<img :src="$withBase('/circleReference.jpg')" alt="foo">
能够看到一个无限展开的结构(即对象的属性间接或直接的引用了自身的状况)。
首先来分析一下循环引用结构:若是咱们不对存在循环引用的结构作处理的话,每次递归都会指向自身对象,这样下去就会形成内存泄漏的问题。解决这个问题咱们就从根本点出发,针对于循环结构咱们能够再每次循环的时候找一个chche
存储当前对象,下次拷贝的时候就能够去cache
中查找有无当前对象,有就返回,没有就继续遍历拷贝,咱们先来实现一下这个:
function deepClone(target, cache = []) { if (target === null || typeof target !== 'object') { return target } let circleStructure = cache.filter(e => e.original === target)[0] if (circleStructure) { return circleStructure.copy } let copy = Array.isArray(target) ? [] : {} cache.push({ original: target, copy }) Object.keys(target).forEach(key => { copy[key] = deepClone(target[key], cache) }) return copy }
该方法缺陷:
- 只能克隆
{}
格式的对象,对于拥有有原型链的对象却无能为力- 不能克隆其它类型的对象(可迭代的集合、
RegExp
、Symbol
、Date
、TypedArray
)等等
针对目前的缺陷咱们寻找解决方案:
{}
类型对象不能拷贝原型链,咱们能够拷贝它的原型对象而且扩展其熟悉Object.keys()
没法对其进行遍厉,那咱们可使用它们自身的构造器要区分对象类型,咱们首先要找到一个能够严格判断对象类型的方法。以前由于看vue
源码的时候看到一个严格判断对象类型的方法,经过Object.toString
方法能够返回对象的具体类型:
function getPlainObjType(obj) { return Object.prototype.toString.call(obj) }
相信不少小伙伴在阅读其余组件库的时候该方法能够随处可见。其次咱们想想(先把function
排除在外)针对于集合类型,咱们可使用键-值
对的map
进行存储,可是若是使用对象做为映射的键,这个对象即使后来全部的引用被解除了,某一时刻(GC
)开始回收其内存,那map
自己仍然会保持其项目(值对象),除非手动的移除项目(clear)来支持 GC
。
这个时候WeakMap
的做用便显现出来,其实它们两者外部的行为特性基本同样,区别就体如今了内存分配的工做方式。
WeakMap
只接受对象做为键,而且这些对象是被弱持有的,也就是说若是键对象自己被垃圾回收的话,那么WeakMap
中的这个项目也会被自动移除,这也是为何WeakMap
在这方面会优于Map
。咱们看个例子:
var wm = new WeakMap(); var x = {id: 1}, y = {id: 2}, z = {id: 3}, w = {id: 4}; wm.set(x, y); x = null; // {id: 1} 可被垃圾回收 y = null; // {id: 2} 可被垃圾回收, 实际上 x = null; weakMap里面的项目也就被回收了 wm.set(z, w); y = null; // {id: 4} 并未被回收,由于键还软关联着 {id: 4} 这个对象
接下来完善一下上面的deepClone
方法,lodash
上实现了比较全面的深拷贝,咱们能够借鉴一下lodash
的思路,实现一个简化版的:
cache
替换为WeakMap
deepInit
map
或者是set
使用它们自身的添加方法拷贝{}
使用Object.keys
遍历拷贝属性Date、RegExp、Symbol
类型的对象使用它们的构造器进行拷贝var boolTag = '[object Boolean]', dateTag = '[object Date]', errTag = '[object Error]', mapTag = '[object Map]', arrTag = '[object Array]', objTag = '[object Object]', numberTag = '[object Number]', regexpTag = '[object RegExp]', setTag = '[object Set]', stringTag = '[object String]', argsTag = '[object Arguments]', symbolTag = '[object Symbol]'; function getPlainObjType(obj) { return Object.prototype.toString.call(obj) } // 判断对象类型 function isObject(obj) { var type = typeof obj; return obj != null && (type == 'object' || type == 'function'); } // 其它(内置)引用类型对象 function isReferObj(type) { return ~([dateTag, errTag, regexpTag, symbolTag].indexOf(type)) } function isSet(type) { return type === '[object Set]' } function isMap(type) { return type === '[object Map]' } // 返回传入对象构造器,这样就能够拷贝原型链属性 function deepInit(obj) { const Ctor = obj.constructor; return new Ctor(); } function cloneObjByTag(object, tag) { var Ctor = object.constructor; switch (tag) { case dateTag: return new Ctor(+object); case errTag: return new Ctor(object); case regexpTag: return cloneRegExp(object); case symbolTag: return cloneSymbol(object); } } function cloneRegExp(object) { let reFlags = /\w*$/; let result = new object.constructor(object.source, reFlags.exec(object)); result.lastIndex = object.lastIndex; return result; } function cloneSymbol(object) { return Object(Symbol.prototype.valueOf.call(object)); } function deepClone(target, wm = new WeakMap()) { if (!isObject(target)) { return target; } let type = getPlainObjType(target); let copy = deepInit(target); // 判断是否存在循环引用结构 let hit = wm.get(target); if (hit) { return hit; } wm.set(target, copy); if(isReferObj(type)) { copy = cloneObjByTag(target, type); return copy; } if (isSet(type)) { target.forEach(value => { copy.add(deepClone(value)); }); return copy; } if (isMap(type)) { target.forEach((value, key) => { copy.set(key, deepClone(value)); }); return copy; } Object.keys(target).forEach(key => { copy[key] = deepClone(target[key], wm) }); return copy; }
注意: 由于拷贝对象属性的时候使用的是Object.keys
暂且先不考虑typedArray
类型对象和Function
,咱们将列举出来的对象类型分为可遍历对象和不遍历代对象(内置对象),使用不一样的遍历方法进行属性复制,Map
和Set
类型可使用其自带的forEach
遍历,对象、数组使用Object.keys
进行遍历,其它内置的引用类型对象直接使用其构造器从新生成新对象。
其实写到这里至关于完成了一大部分,lodash
作了不少细节上面的优化工做,好比针对于对象层级很是多的时候特地对遍历这块作了些手脚:
function arrayEach(array, iteratee) { var index = -1, length = array == null ? 0 : array.length; while (++index < length) { if (iteratee(array[index], index, array) === false) { break; } } return array; }
比较for
、for..in
、while
循环,因为for..in
会遍历整个对象上包括(原型链)的除Symbol
之外的可枚举属性,因此会慢些。可是网上诸多帖子的测试结果发现for
、while
相差很少,总的单纯从执行时间长短来说while
更快一些。还有一些使用迭代器遍历的方法,例如:
forEach
、every
、some
它们的惟一区别在于对回调函数返回值的处理方式不一样:forEach
会遍历数组中的全部之并忽略回调函数的返回值。every
会一直运行直到callback
返回falsy
,some
会一直运行知道回调函数返回truthy
。上例即是模仿every
行为来进行遍历。还有一些遍历是访问对象属性时用到的:
Object.keys
、Object.getOwnPropertyNames
。这些和in
的区别:Object.keys
只会遍厉对象直接包含的可枚举属性,Object.getOwnPropertyNames
会遍历对象直接包含的属性(不论它们是否可枚举)。in
操做符会查找对象(包括原型链)属性是否存在(不管是否能够遍历),for..in
会遍历整个对象上包括(原型链)的除Symbol
之外的可枚举属性。此外,ES6新增的
for..of
能够对数组的值进行遍历(若是对象自身定义了迭代器也能够进行遍历)。
能够很清楚的看到使用while
是重写了forEach
,这里的 array
须要值得注意:
key
和value
须要对调,由于对象的 key
是数组的值而非下标咱们能够改一下deepClone
中的遍历的逻辑:
let keys = Array.isArray(target) ? undefined : Object.keys(target); // Object.keys(target).forEach(key => { // copy[key] = deepClone(target[key], wm); // }); arrayEach(keys || target, (value, key) => { if (keys) { key = value } copy[key] = deepClone(target[key], wm); })
目前来说Function
类型和二进制数组的typedArray
还未实现深拷贝没不过目前来说,平常开发使用最多的也仍是序列化和饭序列化版本。并且我也不常用这个封装的深拷贝,写这么些东西只是出于学习和扩展思路用的,真正用的话 lodash
的彻底够用了,后续还会去继续研究 function
和arrayBuffer
的拷贝。
因为深拷贝须要考虑的edge case
太多,相信你们也会有不少探讨,写一个深拷贝不容易。具体孰优孰劣也须要跟业务想结合一下。
function
、集合、包装对象、引用对象自动忽略,由此引出了递归拷贝。WeakMap
来做为缓存对象。从而引出了像symbol
、正则
、及其余引用类型的对象的拷贝问题。lodash
对于拷贝时遍历的性能的优化,给了咱们一个在遍历数据量很大时一个思路。