前端面试手写代码系列(一): 深克隆方法 您所在的位置:网站首页 js新建对象的五种方法 前端面试手写代码系列(一): 深克隆方法

前端面试手写代码系列(一): 深克隆方法

2023-05-19 23:03| 来源: 网络整理| 查看: 265

我正在参加「掘金·启航计划」

大家好,我是晚天。

手写代码是前端面试中必不可少的环节,常见的手写代码题也基本是可枚举的。网上也有很多面经文章会讲到手写代码,但是大都不够全面、不够深度,有些低质量文章甚至会误导初学者。因此,计划出一个【前端手写代码】系列文章,全面深度的讲解下常见的前端面试手写代码题,并通过手写代码题,延伸讲解下背后面试官想要考察的基础能力。为了保障尽量全面深度,本系列文章会参考其他优秀文章,参考文章会附到参考资料中。

今天从深克隆开始讲起,深克隆是前端面试中非常高频的手写代码题,本文将用逐步优化的方式一步步带大家写出一个更好的代码实现,并延伸出面试官想要考察的知识点。

理解深克隆 & 浅克隆

要理解深克隆和浅克隆的不同,需要先理解 JavaScript 中的基础类型和引用类型的区别。

知识点1:JavaScript 数据类型。

在 JavaScript 中有两种不同的数据类型:基础类型和引用类型。

基础类型有 Number、Boolean、String、Symbol、Null 和 Undefined;

引用类型有 Object、Array、Function 等。

基础类型的传递方式均为按值传递,引用类型的传递方式均为按引用传递。

知识点2:按值传递和按引用传递

按值传递(Passing by value)意味着每次赋值给一个变量,都会将值的拷贝赋值给这个变量;

以下例子,可以形象的解释按值传递的过程。

let a = 1; let b = a; b = b + 2; console.log(a); // 1 console.log(b); // 3 复制代码

将 a 赋值给 b,会将 a 的值的拷贝赋值给 b。当对 b 进行重新赋值时,不会对 a 产生任何影响。所以最终 a !== b。

按引用传递(Passing by reference),则只会传递对对象的引用,而不是对象的拷贝。引用相同的两个对象,修改任何一个对象,都会影响对方。

如下,x 是一个数组 Array 类型,Array 类型是一个引用类型,Array 类型本质上是 Object 类型的子类型,遵循按引用传递。变量 x 赋值给变量 y,只会将 x 的引用传递给 y。此时,x 和 y 拥有对同一个数组共同的引用。

const x = [1]; typeof x; // object x instanceof Array; // true x instanceof Object; // true Array.prototype.__proto__ === Object.prototype; // true,Array 的原型指向 Object 的原型 复制代码

如下面例子,修改 x 和 y 任意一个变量,均会影响另一个,因为 x 和 y 拥有对同一个数组的引用:

let x = [1]; let y = x; y.push(2); console.log(x); // [1, 2] console.log(y); // [1, 2] 复制代码 知识点3:深克隆和浅克隆的区别。

深克隆和浅克隆的区别在于,浅克隆在复制引用类型对象时,会将同一个引用传递给新的克隆对象;深克隆在复制引用类型对象时,则会将引用类型的值的拷贝传递给新的克隆对象。因为浅克隆无需对对象类型的值进行拷贝,因此相较深克隆性能更好。

两张图来解释浅克隆和深克隆的不同:

浅克隆:

深克隆:

理解了深克隆和浅克隆的概念以及不同,我们现在来正式手写代码实现深克隆。

丐版实现

实现深克隆,最简单的方式是使用 JSON.parse(JSON.stringify(target)) 的方式,但是这种方式有明显的缺陷,比如:

无法克隆函数; 无法克隆存在死循环的对象; 等等; JSON.parse(JSON.stringify(target)); 复制代码

因此,我们需要手动实现一个深克隆方法。

基础实现

首先,我们来实现一个浅克隆,通过遍历的方式实现对目标对象的克隆。

function shadowClone(target) { let cloneTarget = {}; for (const key in target) { cloneTarget[key] = target[key]; } return cloneTarget; }; 复制代码

如果要实现一个深克隆,我们需要基于以上实现新增两个逻辑:

如果是基础类型,可以直接返回基础类型值; 如果是引用类型,则需要通过递归的方式基础支持 clone 方法,返回新的拷贝对象; function deepClone(target){ if(typeof target !== 'object'){ return target; } const cloneTarget = {}; for(const key in target){ cloneTarget[key] = deepClone(target[key]); } return cloneTarget; } 复制代码

上述实现,在面对对象类型的引用类型时,基本是可以满足需求的。但是如果是数组类型呢?上述实现会将数组也转化为对象。因此,我们接下来考虑兼容数组。

考虑数组

当我们考虑数组时,就会涉及到一个新的知识点,如何判断数组类型。

知识点4:数组类型的判断。

JavaScript 的类型判断,有以下几种方式:

Array.isArray() const arr = [1,2,3,4,5]; Array.isArray(arr); // true const obj = {id: 1, name: “Josh”}; Array.isArray(obj); // false 复制代码 instanceof const arr = [1,2,3,4,5]; data instanceof Array; // true const obj = {id: 1, name: “Josh”}; data instanceof Array; // false 复制代码 Object.prototype.toString.call const data = [1,2,3,4,5]; Object.prototype.toString.call(data) === '[object Array]'; // true const data = {id: 1, name: “Josh”}; Object.prototype.toString.call(data) === '[object Array]'; // false 复制代码

这里,可能有初学者会使用 typeof 来判断类型,但是 typeof 是无法判断数组类型的。

知识点5:typeof。

typeof 的返回类型如下:

string number undefined object function boolean symbol bigint

数组的 typeof 的返回值也是 object。

typeof []; // object 复制代码

接下来,我们在代码实现中考虑数组类型的情况。

function deepClone(target){ if(typeof target !== 'object'){ return target; } const cloneTarget = Array.isArray(target) ? [] : {}; for(let key in target){ cloneTarget[key] = deepClone(target[key]); } return cloneTarget; } 复制代码 考虑循环引用

如果当一个对象存在循环引用时,使用上述实现,会怎样呢?

const obj = {a: {b: 1}, c: 'hello'} obj.a.c = obj; cloneDeep(obj); 复制代码

执行上述代码,我们将得到如下结果。

因为 obj 中存在循环引用,所以对 cloneDeep 的调用将无限循环下去,最终导致内存溢出。

要解决上述问题,我们需要一个存储空间来存储当前对象和拷贝对象的关系,如果当前对象已经被拷贝过,则直接返回存储的拷贝对象,如果没有被拷贝过,则进行拷贝,并将拷贝对象存储到存储空间中。这个存储空间需要是 key-value 形式的,因此我们选择 Map 进行存储。

使用 Map 的实现方式如下:

function deepClone(target, map = new Map()) { if (typeof target !== 'object') { return target; } let cloneTarget = Array.isArray(target) ? [] : {}; if (map.get(target)) { return map.get(target); } map.set(target, cloneTarget); for (const key in target) { cloneTarget[key] = deepClone(target[key], map); } return cloneTarget; }; 复制代码

重新拷贝上述存在循环引用的对象 obj,得到如下结果:

存在循环引用的对象已可以正常深克隆。

熟悉 ES6 的同学肯定已经想到了 Map 的孪生姊妹 WeakMap。此处,我们可以使用 WeakMap 对我们的实现继续进行优化。

知识点6:Map 和 WeakMap 的区别

WeakMap 是弱引用的键值对集合,键值必须是对象,值可以是任意类型。和 Map 的区别就在于 Map 是强引用,WeakMap 是弱引用。

与Map对象不同的是,WeakMap的键是不可枚举的。不提供列出其键的方法

什么是弱引用呢?弱引用意味着在没有其他引用的情况下,弱引用的对象可以被垃圾回收机制回收。如果一个对象只有弱引用存在,则该对象会在下一次垃圾回收机制执行时被回收。

当我们在深克隆一个很大的对象时,使用 Map 将造成很大的性能损耗,我们必须手动清楚 Map 的键值来释放内存。WeakMap 则不会有这个问题。

考虑原型链

上述实现中,我们获取对象的 key 使用的方法是 for...in,但是 for...in 有个问题,它会获取到原型链上所有除 Symbol 以外的可枚举属性。对于深克隆来说,只需要克隆对象本身的属性即可,不需要克隆原型链上的非自身属性。那如何获取对象本身属性呢?

知识点7:如何获取对象自身属性

获取对象自身属性有以下几种方式:

Object.getOwnPropertyNames() 获取对象自身所有属性,包括不可枚举属性; Object.keys() 获取对象的自身可枚举属性; for...in + Object.hasOwnProperty() + Object.getOwnPropertySymbols() for...in 以任意顺序迭代一个对象的除Symbol以外的可枚举属性,包括继承的可枚举属性; Object.hasOwnProperty() 判断属性是否是对象自身属性; Object.getOwnPropertySymbols() 获取对象 Symbol 类型的属性;

可见,如果我们的目标是获取对象自身的所有属性,Object.getOwnPropertyNames() 是一个合适的方法。

基于上述分析,对我们的实现继续进行优化。

function cloneDeep(target, map = new WeakMap()) { if (typeof target !== 'object') { return target; } if (map.has(target)) { return map.get(target); } const cloneTarget = Array.isArray(target) ? [] : {}; map.set(target, cloneTarget); Object.getOwnPropertyNames(target).forEach((key) => { cloneTarget[key] = cloneDeep(target[key], map); }); return cloneTarget; } const obj = {a: {value: 1}, b: {value: 2}} Object.defineProperty(obj, 'a', {enumerable: true}); Object.defineProperty(obj, 'b', {enumerable: false}); const newObj = cloneDeep(obj); console.log(newObj); // { a: { value: 1 }, b: { value: 2 } } 复制代码 其他更多类型

在上述实现中,引用类型我们只考虑了普通对象和数组。事实上,引用类型远远不止这两个,比如 String、Number、Boolean、RegExp、Date、Map、Set、Error 等。

我们如何获取到真实的引用类型呢?通过 Object.prototype.toString 方法,我们可以观察到各种不同对象实例的 toString 都会遵循相同的格式输出。

知识点8:获取引用对象真实类型的方法

因此,可以通过以下方法:

function getType(obj){ return Object.prototype.toString.call(obj).slice(8, -1); } 复制代码

其中 Symbol 类型需要特别注意一下:

知识点9:Symbol 类型的创建方法

symbol 是一种基本数据类型(primitive data type)。Symbol() 函数会返回 symbol 类型的值,该类型具有静态属性和静态方法。它的静态属性会暴露几个内建的成员对象;它的静态方法会暴露全局的 symbol 注册,且类似于内建对象类,但作为构造函数来说它并不完整,因为它不支持语法:"new Symbol()"。

可以通过如下方式创建 Symbol 类型的数据:

const sym = Symbol(1); // Symbol(1) typeof sym; // 'symbol' 复制代码

不能通过 new Symbol() 的方式创建 Symbol 类型,因为 Symbol 不是构造函数。

如果真的想创建 Symbol 对象,可以通过如下方式:

const sym = Object(Symbol(1)); // Symbol {Symbol(1), description: '1'} typeof sym; // 'object' Object.prototype.toString.call(sym); // '[object Symbol]' 复制代码

接下来,我们来支持 Map、Set、RegExp、Number、String、Boolean、Date、Error、Null类型和 Symbol 对象类型。其中 Symbol 对象类型,指的是通过 Object(Symbol()) 方式创建的 Symbol 类型。

最终实现

基于上述优化,我们最终实现了尽可能兼容各种情况的深克隆方法,并通过测试验证功能正确性:

function cloneDeep(target, map = new WeakMap()) { if (typeof target !== 'object') { return target; } if (map.has(target)) { return map.get(target); } const type = Object.prototype.toString.call(target).slice(8, -1); let cloneTarget; switch (type) { case 'Object': case 'Array': cloneTarget = type === 'Array' ? [] : {}; map.set(target, cloneTarget); Object.getOwnPropertyNames(target).forEach(key => { cloneTarget[key] = cloneDeep(target[key], map); }); break; case 'Map': cloneTarget = new Map(); map.set(target, cloneTarget); target.forEach((value, key) => { cloneTarget.set(cloneDeep(key, map), cloneDeep(value, map)); }); break; case 'Set': cloneTarget = new Set(); map.set(target, cloneTarget); target.forEach((value) => { cloneTarget.add(cloneDeep(value, map)); }); break; case 'RegExp': case 'Number': case 'String': case 'Boolean': case 'Date': case 'Error': cloneTarget = new target.constructor(target); break; case 'Symbol': cloneTarget = Object(Object.prototype.valueOf.call(target)); break; case 'Null': cloneTarget = null; break; } return cloneTarget; } // test const map = new Map(); map.set('key', 'value'); const set = new Set(); set.add('value1'); set.add('value2'); const obj = { field1: 1, field2: undefined, field3: { child: 'child' }, field4: [2, 4, 8], empty: null, map, set, bool: new Boolean(true), num: new Number(2), str: new String(2), symbol: Object(Symbol(1)), date: new Date(), reg: /\d+/, error: new Error(), func1: () => { console.log('hello friend!'); }, func2: function (a, b) { return a + b; } }; console.log(obj); const copy = cloneDeep(obj); console.log(copy); 复制代码 知识点回顾

前文中,我们通过对深克隆方法的不断优化,延伸学习了以下知识点:

JavaScript 数据类型 值传递 & 对象传递 深克隆和浅克隆的区别 数组类型的判断 typeof Map 和 WeakMap 的区别 如何获取对象自身属性 如何获取引用对象的真实类型 Symbol 类型的创建方法 总结

通过上文,我们不断对深克隆的方法进行优化,逐步支持了所有基础类型(number/boolean/string/undefined/bigint/function/symbol)、引用类型(Object/Array/Map/Set/Symbol/Error/Regex/Number/String/Boolean/Date/Null)的支持,并且考虑到对原型链、循环引用等情况的兼容。

敬请其他《前端面试手写代码系列》,其他更多内容欢迎访问晚天的个人主页。

参考资料 Pass by Value and Pass by Reference in Javascript The Difference Between Values and References in JavaScript [JavaScript] Check if a variable is a type of an Object or Array? WeakMap Write a Better Deep Clone Function in JavaScript 一篇搞定对象的深克隆 Shallow Copy vs Deep Copy in JavaScript Create cloneDeep - BFT Javascript经典面试之深拷贝VS浅拷贝 Difference between Shallow and Deep copy of a class



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有