轻笑Chuckle
外观

学习笔记

数据类型与拷贝

JS八种数据类型与引用类型的拷贝

13 分钟 5,384 字 次浏览 条评论

数据类型

JS中有8种数据类型,分为基本数据类型和引用数据类型

  1. 基本数据类型(值类型): String字符串、Number数值、BigInt(ES6)大型数值、Boolean布尔值、Null空值、Undefined未定义、Symbol(ES6)。
  2. 引用数据类型(引用类型): Object 对象(除了基本数据类型之外,都可称之为Object类型)。

存储方式:

  1. 基本类型直接保存在栈中
  2. 引用类型存放在堆中。在栈空间中只保留了数据在堆中的地址,访问时,通过栈中的引用地址来访问堆中实际的数据。

至于V8堆栈框架,以后再说。

null 和 undefined

undefined 表示值的缺失,null 表示对象的缺失。当没有值时,通常默认为 undefined。

null 是一个关键字,但是 undefined 是一个普通的标识符,恰好是一个全局属性。

早期 undefined 作为全局属性可以被赋值,所以有些人使用 void 0 来获取 undefined。

console.log(null == undefined); // true
console.log(null === undefined); // false

null 转为数字时为 0,undefined 转为数字时为 NaN。

console.log(+null) // 0
console.log(+undefined) // NaN

判断类型

typeof

typeof 运算符,返回数据类型的字符串,对于引用类型,除了函数,都会返回 object。

console.log(typeof '123'); // string
console.log(typeof 123); // number
console.log(typeof 123n); // bigint
console.log(typeof NaN); // number
console.log(typeof true); // boolean
console.log(typeof Symbol()); // symbol
console.log(typeof undefined); // undefined
console.log(typeof undeclaredVariable); // undefined
// console.log(typeof document.all) // undefined
// 对象
console.log(typeof null); // object
console.log(typeof []); // object
console.log(typeof {}); // object
console.log(typeof new Date()); // object
console.log(typeof /123/); // object
// 函数
console.log(typeof new Function()); // function
console.log(typeof (()=>{})); // function
console.log(typeof class A{}); // function

null与object: 在 JS 的最初版本中,还广泛使用着32位系统,JS 用32个二进制位标识值,其中低三位表示值的类型。

typeof通过判断存储的机器码的低三位来进行类型判断。

数据类型 机器码标识
对象(Object) 000
整数 1
浮点数 010
字符串 100
布尔 110
undefined -2^31(全为 1 的 32 位带符号整数)
null 全为0

null的机器码标识为全0,而对象的机器码低位标识为000。所以typeof null被误判为Object。

即使在 ES6 时有提案要修复这个bug,但因为兼容性问题被否决,这已经是一个**特性(feature)**不再被修复。

甚至后来出现了typeof null等于undefined的bug,被光速改回了原来的样子。

The history of typeof null

constructor

对象原型上的 constructor 属性,返回实例对象的构造函数的引用。

通过 constructor 判断对象数据类型,但这种方式并不可靠,因为可以通过更改原型链来更改 constructor 属性。

const arr = [1, 2, 3, 4, 5];
console.log(arr.constructor === Array); // true
console.log(arr.constructor === Object); // false
// 改写原型
const obj = {};
Object.setPrototypeOf(arr, obj);
console.log(arr.constructor === Array); // false
console.log(arr.constructor === Object); // true

instanceof

instanceof 运算符判断一个实例是否属于某种类型。

即某个构造函数的 prototype 属性(原型对象)是否出现在某个实例对象的原型链上。

class A {}
class B extends A {}
const b = new B();
console.log(b instanceof B); // true
console.log(b instanceof A); // true

继承形成了两条原型链:

  1. 实例的原型对象的 __proto__ 指向父类的原型对象,这是为了继承父类的方法(实例属性在创建对象时,直接作为对象自己的属性实例化,无需通过原型链继承,而实例方法,都在原型上,所以只有实例方法需要通过原型链继承,向上查找)
  2. 构造函数的 __proto__ 指向父类的构造函数,这是为了继承父类的静态属性和方法。
console.log(B.prototype.__proto__ === A.prototype); // true
console.log(B.__proto__ === A); // true

而 instanceof 就是通过判断实例的原型链上是否有某个构造函数的 prototype 属性(原型对象)来判断实例的类型。

原型链上最后一个原型对象是 Object.prototype,所以所有的实例都属于 Object 类型,也就是万物皆对象。

类本质是构造函数的语法糖,所以也是 Function 类型。

console.log(b instanceof Object); // true
console.log(B instanceof Function); // false
console.log(B instanceof Object); // true

之前的笔记 原型与原型链

手写instanceof

function myInstanceOf(Fn) {
if (!this) return false; // 如果this不存在,返回false
// 获取Fn的显示原型
const prototype = Fn.prototype;
// 获取this的隐式原型
let obj = Object.getPrototypeOf(this);
// 循环往原型链上查找,直到找到原型链尽头null
while (obj) {
if (obj === prototype) {
return true;
}
// 如果没有找到就继续向上查找
obj = Object.getPrototypeOf(obj);
}
return false;
}
// 挂载到所有对象的原型链上
Object.prototype.myInstanceOf = myInstanceOf;
class A {}
class B extends A {}
const b = new B();
class C {}
console.log(b.myInstanceOf(B)); // true
console.log(b.myInstanceOf(A)); // true
console.log(b.myInstanceOf(Object)); // true
console.log(b.myInstanceOf(C)); // false
console.log(B.myInstanceOf(Function)); // true
console.log(B.myInstanceOf(Object)); // true

基本包装类型

基本数据类型没有原型链,所以 instanceof 无法判断基本数据类型。

但通过对应基本包装类型创建的实例,可以通过 instanceof 判断。

三种基本包装类型:String, Number, Boolean。

const s = new String('111');
const n = new Number(111);
console.log(s instanceof String); // true
console.log(n instanceof Number); // true
const ss = '111';
const nn = 111;
console.log(ss instanceof String); // false
console.log(nn instanceof Number); // false

基本数据类型在调用属性和方法时,会进行装箱操作,把基本类型用它们相应的引用类型包装起来,使其具有对象的性质。会产生一些临时对象。

is方法

isPrototypeOf 判断一个对象是否是另一个对象的原型。通用会在原型链上查找。

class A {}
class B extends A {}
const b = new B();
const bp = Object.getPrototypeOf(b);
console.log(bp.isPrototypeOf(b)); // true
console.log(A.prototype.isPrototypeOf(b)); // true

其它基本包装类型和内置类型也提供了一些 is 方法

console.log(Array.isArray([])); // true
console.log(Array.isArray({})); // false
console.log(Number.isNaN(NaN)); // true
console.log(Number.isNaN(123)); // false
console.log(Number.isInteger(123)); // true
console.log(Number.isInteger(123.1)); // false
console.log(Number.isFinite(Infinity)) // false

isNaN 和 isFinite

全局和 Number 对象上都有 isNaN 和 isFinite 方法,用于判断是否是 NaN 和 有限数。但全局方法会进行隐式类型转换再判断,而 Number 对象上的方法则不会。

console.log(isNaN(NaN)) // true
console.log(isNaN({})) // true, Number({}) => NaN
console.log(isNaN('NaN')) // true, Number('NaN') => NaN
console.log(isNaN('123')) // false, Number('123') => 123
console.log(Number.isNaN(NaN)) // true
console.log(Number.isNaN({})) // false
console.log(Number.isNaN('NaN')) // false
console.log(Number.isNaN('123')) // false
console.log(isFinite({})) // false
console.log(isFinite(Infinity)) // false
console.log(isFinite('123')) // true, Number('123') => 123
console.log(Number.isFinite({})) // false
console.log(Number.isFinite(Infinity)) // false
console.log(Number.isFinite('123')) // false

toString

Object.prototype.toString.call() 以 [object xxx] 的字符串形式返回当前调用者的对象类型。是最稳妥的类型判断方式。

必须调用 Object 显式原型上的 toString 方法,因为其它数据类,都重写了 toString 方法,返回的不再是 [object xxx] 类型字符串。

function typeToString(any) {
return Object.prototype.toString.call(any).slice(8, -1);
}
console.log(typeToString([])); // Array
console.log(typeToString((()=>{}))); // Function
console.log(typeToString(123)); // Number
console.log(typeToString(Symbol('a'))) // Symbol

这种方法只能判断内置类型和基本包装类型,即 Arguments, Array, Boolean, Date, Error, Function, JSON, Math, Number, Object, RegExp 和 String。

const a = new class A {};
console.log(typeToString(a)); // Object

对于基本数据类型,会进行装箱操作,返回对应的引用类型。这会产生一些临时对象,所以通常需要配合 typeof 来判断类型,提高性能。

为什么Object.prototype.toString.call()可以如此准确的判断对象类型?

封装类型判断函数

  1. null 比较特殊,typeof 会返回 object,先严格比较后返回 null。
  2. 基本数据类型直接使用 typeof 获取类型,避免装箱操作,提高性能。
  3. 其它引用类型使用 Object.prototype.toString.call() 获取类型。
function typeJudge(val) {
if (val === null) return 'null';
if (typeof val !== "object") return typeof val;
return Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
}
console.log(typeJudge([])); // array
console.log(typeJudge({})); // object
console.log(typeJudge(new Date())); // date
console.log(typeJudge(null)); // null
console.log(typeJudge(undefined)); // undefined
console.log(typeJudge(123)); // number
console.log(typeJudge('123')); // string

Symbol.toStringTag

Object.prototype.toString 会读取一个对象的 Symbol.toStringTag 属性,该属性返回值将作为 [object xxx] 中的 xxx。用于创建对象的默认字符串描述。

应该返回一个字符串,否则会返回默认值,即 [object Object]。

可以用于标识自定义类型。

class A {
get [Symbol.toStringTag]() {
return this.constructor.name;
}
}
const a = new A();
console.log(Object.prototype.toString.call(a)); // [object A]

**注意:**该属性可以被 defineProperty 修改。

class A {
get [Symbol.toStringTag]() {
return this.constructor.name;
}
}
const a = new A();
console.log(Object.prototype.toString.call(a)); // [object A]
Object.defineProperty(a, Symbol.toStringTag, {
value: 'B',
});
console.log(Object.prototype.toString.call(a)); // [object B]

Promise

Promise 非常特殊,在 Promise A+ 规范并没有设计如何创建、解决和拒绝 Promise,而是专注于提供一个通用的 .then 方法,换而言之,只要一个对象具有 .then 方法,并且符合规范(具有特定参数、返回特定值),就可以称之为 Promise。

Promise A+ 早于 ES6,是社区规范,为了解决回调地狱和异步实现不统一的问题。 ES6 的 Promise 符合 Promise A+ 规范,是对该规范的实现。提供了 Promise 类(构造函数)去构建一个 Promise 对象。并提供了除了 .then 方法之外的一些方法。

许多早期的第三方库使用的并不是 Promise 类构建的 Promise 对象,而是自己实现的 Promise 对象。

下面实现 isPromise(),判断一个对象是否是 Promise 对象。

function isObject(val) {
return val !== null && (typeof val === "object" || typeof val === "function");
}
function isPromise(p) {
return p instanceof Promise || (isObject(p) && typeof p.then === "function");
}
const p = Promise.resolve();
const pp = { then: () => {} };
console.log(isPromise(p)); // true
console.log(isPromise(pp)); // true

等比较

对于 == 和 === 已经非常熟悉了。

  1. == 宽松相等,会进行隐式类型转换,并按照 IEEE754 标准对 NaN、-0 和 +0 进行特殊处理(故 NaN != NaN,且 -0 == +0),IsLooselyEqual
  2. === 严格相等,与 == 逻辑相同,但不会进行隐式类型转换。如果类型不同,则返回 false。IsStrictlyEqual

JavaScript 中的相等性判断-MDN

console.log(null == undefined) // true
console.log(null === undefined) // false
console.log(NaN == NaN) // false
console.log(+0 == -0) // true

Object.is() 方法判断两个值是否是相同的值。与 === 类似,但是对于 NaN 和 +0 和 -0 的判断有所不同。使用SameValue

  1. Object.is(NaN, NaN) 返回 true,而 NaN == NaN 返回 false
  2. Object.is(+0, -0) 返回 false,而 +0 == -0 返回 true
console.log(Object.is(NaN, NaN)) // true
console.log(Object.is(+0, -0)) // false

indexOf 和 includes

indexOf 无法判断 NaN,因为使用的是严格相等。而 includes 可以判断 NaN,使用 SameValueZero 算法,NaN等于NaN。

const arr = [NaN];
console.log(arr.indexOf(NaN)) // -1
console.log(arr.includes(NaN)) // true
console.log(arr.findIndex((val) => Object.is(val, NaN))) // 0

拷贝

拷贝,也就是复制数据,对于基本数据类型,直接赋值多个变量也互不影响。 但对于引用类型,赋值是浅拷贝,多个变量保存同一个引用,指向同一个堆空间,修改其中一个变量,会影响到其它变量。而深拷贝,就是完全复制一个对象,包括其内部的对象,开辟新的堆空间,多个变量互不影响。

直接赋值就是复制栈空间的值,对于基本类型,栈存放其数据值,而引用类型,栈存放其引用地址,指向实际存放对象数据的堆内存,所以直接赋值是浅拷贝。

总之,基本类型的浅拷贝复制的就是数据值,而引用类型的浅拷贝,复制的是引用地址。

浅拷贝

除了直接赋值,还有一些方法可以实现浅拷贝。

Object.assign

Object.assign() 用于将任意多个对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。

第一层基本类型的属性是将值本身复制一份,而引用类型的属性是浅拷贝。

const obj1 = {
name: '123',
data: {
val: 1
}
};
const obj2 = {};
Object.assign(obj2, obj1);
console.log(obj2); // { name: '123' }
obj2.name = '456';
console.log(obj1.name); // 123,基本类型,直接复制一份
obj2.data.val = 2;
console.log(obj1.data.val); // 2,引用类型的属性是浅拷贝,修改其中一个对象,会影响到另一个对象

展开运算符...

与 Object.assign 类似,也是浅拷贝。

const obj1 = {
name: '123',
data: {
val: 1
}
};
const obj2 = { ...obj1 };
obj2.data.val = 2;
console.log(obj1.data.val); // 2

Array.prototype.slice

slice 方法返回一个新的数组,返回原数组指定范围(左闭右开)元素的浅拷贝。

const arr1 = [1, 2, { val: 3 }];
const arr2 = arr1.slice();
arr2[2].val = 4;
console.log(arr1[2].val); // 4

Array.prototype.concat

concat 方法用于合并若干个数组,返回一个新数组,也是浅拷贝。

const arr1 = [{ val: 3 }];
const arr2 = [{ name: 'aaa' }];
const arr3 = arr1.concat(arr2);
console.log(arr3); // [ { val: 3 }, { name: 'aaa' } ]
arr3[0].val = 4;
arr3[1].name = 'bbb';
console.log(arr1[0].val); // 4
console.log(arr2[0].name); // bbb

手写函数

直接遍历对象属性,再添加给新对象。

function clone(target) {
let cloneTarget = {};
for (const key in target) {
cloneTarget[key] = target[key];
}
return cloneTarget;
};

深拷贝

浅拷贝是常见的,而为了实现引用数据类型的深拷贝,需要借助其它的方法。

JSON方法

JSON.parse(JSON.stringify()) 先将对象转为JSON字符串,再将JSON转为对象。

const obj = {
data: {
val: 1
}
}
const obj2 = JSON.parse(JSON.stringify(obj));
obj2.data.val = 2;
console.log(obj.data.val); // 1

缺点:

  1. 无法复制函数、正则、Date、undefined、Symbol等内置对象。
  2. 无法解析循环引用的对象,会报错。
const obj = {
a: new Date(),
b: /123/,
c: function() {},
d: Symbol('123'),
}
const obj2 = JSON.parse(JSON.stringify(obj));
console.log(obj2); // { a: '2024-03-13T02:34:16.846Z', b: {} }
console.log(obj2.a instanceof Date); // false
第二参数

真的没办法复制函数和其它数据类型吗?其实可以。 JSON.parse 和 JSON.stringify 可以传入第二个参数,是一个函数,用于自定义过滤和转换结果。

const obj1 = {
a: 1,
fn: function () {
console.log("123");
},
};
const obj2 = JSON.parse(JSON.stringify(obj1));
console.log(obj2); // { a: 1 }
// 传入第二个参数,对函数类型属性进行处理
const obj3 = JSON.parse(
JSON.stringify(obj1, (key, value) => {
if (typeof value === "function") {
// 将函数转为字符串,并且加上特殊标识
return value.toString() + '#function#';
}
return value;
}),
(key, value) => {
// 将函数字符串转为函数
if (typeof value === "string" && value.includes("#function#")) {
// 去掉特殊标识
value = value.replace("#function#", "");
return new Function(`return ${value}`)();
}
return value;
}
);
console.log(obj3); // { a: 1, fn: [Function (anonymous)] }
obj3.fn(); // 123

借助这第二个参数,完全可以实现任何对象的深拷贝。但为了更加灵活可控,还是需要手写递归。

手写递归

深拷贝需要找到对象多层嵌套的最深层的基础数据类型,很显然,需要用到递归。

function deepClone(target) {
// 值为空,或为基础类型,直接返回
if (!target || typeof target !== "object") return target;
// 对于对象、数组类型,则遍历其属性,递归深拷贝
let obj = Array.isArray(target) ? [] : {};
for (key in target){
obj[key] = deepClone(target[key]);
}
return obj;
}
const obj1 = {
a: {
b: 1,
c: [1,2,3]
},
}
const obj2 = deepClone(obj1);
obj2.a.b = 2;
obj2.a.c[0] = 2;
console.log(obj1); // { a: { b: 1, c: [ 1, 2, 3 ] } }
console.log(obj2); // { a: { b: 2, c: [ 2, 2, 3 ] } }
循环引用

手写深拷贝目的之一就是解决循环引用,例如原型链等就存在循环引用。

可以使用 hashMap 来存储已经拷贝过的对象,遇到循环引用时,直接返回 hashMap 中的对象。

在这里,map仅作为缓存,可以使用 WeakMap,其键必须是对象且是弱引用,不会阻止GC对作为键的对象的回收,无需手动清除Map属性,避免内存泄漏。

function deepClone(target, map = new WeakMap()) {
// 如果是对象,即引用类型
if(typeof target === 'object') {
// 判断是数组还是对象
let cloneTarget = Array.isArray(target) ? [] : {};
// 如果已经克隆过了,直接返回之前的对象
if(map.get(target)) {
return map.get(target);
}
// 没有克隆过,就将当前克隆对象存入map中
map.set(target, cloneTarget);
// 遍历key,对象是属性,数组是索引
for(let key in target) {
cloneTarget[key] = deepClone(target[key], map);
}
// 返回克隆对象
return cloneTarget;
} else {
// 不是对象直接返回
return target;
}
}
const obj1 = {
a: {
b: 1,
c: [1,2,3]
},
}
obj1.obj = obj1; // 循环引用
const obj2 = deepClone(obj1);
obj2.a.b = 2;
obj2.a.c[0] = 2;
console.log(obj1); // { a: { b: 1, c: [ 1, 2, 3 ] }, obj: [Circular *1] }
console.log(obj2); // { a: { b: 2, c: [ 2, 2, 3 ] }, obj: [Circular *1] }
console.log(obj1.obj === obj2.obj); // false
遍历性能优化

forEach 和传统的 for 循环性能差不多,而 for in 性能遥遥落后,while 循环性能最好。

const obj = {
a: {},
b: {},
c: {},
};
console.time();
for (const key in obj) {
console.log(key); // a b c
}
console.timeEnd(); // default: 8.696ms
console.time();
Object.keys(obj).forEach((key) => {
console.log(key); // a b c
});
console.timeEnd(); // default: 0.908ms
console.time();
const keys1 = Object.keys(obj);
const length1 = keys1.length;
for (let i = 0; i < length1; i++) {
console.log(keys1[i]); // a b c
}
console.timeEnd(); // default: 1.022ms
console.time();
let i = -1;
const keys2 = Object.keys(obj);
const length2 = keys2.length;
while (++i < length2) {
console.log(keys2[i]); // a b c
}
console.timeEnd(); // default: 0.68ms

封装一个通用的遍历函数,用于遍历对象和数组。

function forEach(obj, callback) {
// 无需区分数组和对象,因为数组也是对象,直接获取所有key
const keys = Object.keys(obj);
const length = keys.length;
let i = -1;
while (++i < length) {
// 回调函数传入当前遍历的值和索引
callback(obj[keys[i]], keys[i]);
}
}
const obj = {
a: {}, b: {}, c: {},
};
forEach(obj, (val, index) => {
console.log(val, index); // {} a, {} b, {} c
});
const arr = [1, 2, 3];
forEach(arr, (val, index) => {
console.log(val, index); // 1 0, 2 1, 3 2
});

修改原来的深拷贝函数,使用封装的 forEach 遍历对象。

function deepClone(target, map = new WeakMap()) {
// 如果是对象,即引用类型
if (typeof target === "object") {
// 判断是数组还是对象
let cloneTarget = Array.isArray(target) ? [] : {};
// 如果已经克隆过了,直接返回之前的对象
if (map.get(target)) {
return map.get(target);
}
// 没有克隆过,就将当前克隆对象存入map中
map.set(target, cloneTarget);
// 使用封装的forEach遍历
forEach(target, (val, key) => {
cloneTarget[key] = deepClone(val, map);
});
// 返回克隆对象
return cloneTarget;
} else {
// 不是对象直接返回
return target;
}
}
function forEach(obj, callback) {
// 无需区分数组和对象,因为数组也是对象,直接获取所有key
const keys = Object.keys(obj);
const length = keys.length;
let i = -1;
while (++i < length) {
// 回调函数传入当前遍历的值和索引
callback(obj[keys[i]], keys[i]);
}
}
其它数据类型

目前只处理了可遍历的对象和数组,还有一些内置对象,如函数、正则、Date、Map、Set等,需要根据其特性进行特殊处理

判断数据类型的函数,在前面已经封装过了,这里直接拿来用。

function typeJudge(val) {
if (val === null) return 'null';
if (typeof val !== "object") return typeof val;
return Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
}

想要处理其它数据类型,就需要掌握它们的特性,这也是为什么,本文先讲数据类型再讲拷贝的原因。

  1. **可继续遍历的类型:**Object、Array、Map、Set,对于这种类型,可以和之前一样继续遍历其属性,递归深拷贝。
  2. **不可继续遍历的类型:**Bool、Number、String、Date、Error、RegExp这几种类型可以直接用其构造函数和原始数据创建一个新对象。

需要注意的点:

  1. 由基本包装类型的构造函数 new 出来的对象,是 object 类型。
  2. Map 和 Set 虽然可以直接使用原数据 new 一个新的对象,但是其内部的数据可能是引用类型,所以还是需要递归深拷贝。
  3. 函数和错误类型通常不需要深拷贝,深拷贝没有意义,直接返回即可。
// 遍历函数
function forEach(obj, callback) {
// 无需区分数组和对象,因为数组也是对象,直接获取所有key
const keys = Object.keys(obj);
const length = keys.length;
let i = -1;
while (++i < length) {
// 回调函数传入当前遍历的值和索引
callback(obj[keys[i]], keys[i]);
}
return obj;
}
// 获取最准确的数据类型
function typeJudge(val) {
if (val === null) return "null";
if (typeof val !== "object") return typeof val;
return Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
}
// 判断是否是引用数据类型,也就是广泛意义上的对象
function isObject(val) {
return val !== null && typeof val === "object";
}
// 判断是否是函数
function isFunction(val) {
return typeof val === "function";
}
// 获取对象的构造函数
function getConstructor(val) {
return val.constructor;
}
// 判断是否是可继续遍历类型
function createCanTraverse() {
const deepType = ["object", "array", "map", "set", "weakmap", "weakset"];
return (type) => deepType.includes(type);
}
const canTraverse = createCanTraverse();
// 克隆可遍历对象
function createCloneCanTraverse(type, map) {
switch (type) {
case "set":
case "weakset":
return (cloneTarget, target) => {
target.forEach((val) => {
cloneTarget.add(deepClone(val, map));
});
};
case "map":
case "weakmap":
return (cloneTarget, target) => {
target.forEach((val, key) => {
cloneTarget.set(key, deepClone(val, map));
});
};
default:
return (cloneTarget, target) => {
forEach(target, (val, key) => {
cloneTarget[key] = deepClone(val, map);
});
};
}
}
// 克隆其它内置对象类型
function cloneOtherObject(val, type) {
// 获取构造函数
const Ctor = getConstructor(val);
switch (type) {
case "number":
case "string":
case "boolean":
case "regexp":
case "date":
return new Ctor(val);
case "error":
return val; // 错误对象无需克隆
default:
return null;
}
}
function deepClone(target, map = new WeakMap()) {
// 如果是对象,即引用类型
if (isObject(target)) {
// 如果已经克隆过了,直接返回之前的对象
if (map.get(target)) {
return map.get(target);
}
// 获取当前对象的类型
const type = typeJudge(target);
// 先尝试克隆其它对象类型
const result = cloneOtherObject(target, type);
if (result) {
return result;
}
// 如果result为null,说明是可继续遍历类型,或者修改了toString描述的自定义对象
let cloneTarget = {};
// 如果是可继续遍历类型,就创建一个对应的新的对象
if (canTraverse(type)) {
const Ctor = getConstructor(target);
cloneTarget = new Ctor();
}
// 没有克隆过,就将当前克隆对象存入map中
map.set(target, cloneTarget);
// 克隆可遍历对象
createCloneCanTraverse(type, map)(cloneTarget, target);
// 返回克隆对象
return cloneTarget;
}
if (isFunction(target)) {
// 如果是函数,直接返回,克隆也没什么意义
return target;
} else {
// 基本数据类型直接返回
return target;
}
}
const obj1 = {
a: {
b: 1,
c: [1, 2, 3],
reg: /123/,
date: new Date(),
fn: function () {},
sym: Symbol("123"),
},
set: new Set([1, 2, 3]),
map: new Map([
["a", 1],
["b", 2],
["c", { val: 3 }],
]),
};
obj1.obj = obj1; // 循环引用
const obj2 = deepClone(obj1);
obj2.a.b = 2;
obj2.a.c[0] = 2;
obj2.set.add(4);
obj2.map.set("d", 4);
obj2.map.get("c").val = 666;
console.log(obj1);
console.log(obj2);
/* <ref *1> {
a: {
b: 1,
c: [ 1, 2, 3 ],
reg: /123/,
date: 2024-03-13T06:41:58.179Z,
fn: [Function: fn],
sym: Symbol(123)
},
set: Set(3) { 1, 2, 3 },
map: Map(3) { 'a' => 1, 'b' => 2, 'c' => { val: 3 } },
obj: [Circular *1]
}
<ref *1> {
a: {
b: 2,
c: [ 2, 2, 3 ],
reg: /123/,
date: 2024-03-13T06:41:58.179Z,
fn: [Function: fn],
sym: Symbol(123)
},
set: Set(4) { 1, 2, 3, 4 },
map: Map(4) { 'a' => 1, 'b' => 2, 'c' => { val: 666 }, 'd' => 4 },
obj: [Circular *1]
} */

当然还有一些问题没解决:

  1. 对象的原型链没处理
  2. 对象的属性描述符没处理
  3. 如果是dom节点,应该使用 cloneNode 方法
  4. 对象不可枚举的属性也没处理

structuredClone

写递归太麻烦了,也许可以引入第三方库,比如lodash的_.cloneDeep方法。

JS本身也提供了深拷贝全局方法 structuredClone,且支持循环引用。需要注意兼容性,Chrome98、Node17 等以上才支持

具有两个参数:

  1. value 要克隆的对象:任意结构化可克隆类型。
  2. transfer 可转移的对象的数组:其中的可转移对象将被移动到新的对象,而不是克隆至新的对象。
const obj1 = {
date: new Date(),
reg: /123/,
a: {
b: 1
}
}
obj1.obj = obj1; // 循环引用
const obj2 = structuredClone(obj1);
console.log(obj2);
/* <ref *1> {
date: 2024-03-13T07:55:00.334Z,
reg: /123/,
a: { b: 1 },
obj: [Circular *1]
} */
obj2.a.b = 2;
console.log(obj1.a.b); // 1

structuredClone 使用结构化克隆算法,不能克隆函数、DOM节点、Symbol、不可枚举属性,对象的某些特定参数也不会被保留(属性描述符、setters、getters、原形链上的属性、RegExp对象的lastIndex字段)

MessageChannel

MessageChannel用于在不同的浏览器上下文,比如window.open()打开的窗口、iframe、多个work等之间建立通信管道,并通过两端的端口(port1和port2)以DOM Event的形式发送消息,为宏任务。兼容性非常好。

Vue的nextTick在2.5版本也使用了MessageChannel,setImmediate -> MessageChannel -> setTimeout 0

if (typeof MessageChannel === 'function') {
var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = nextHandler;
port.postMessage(1);
}
const mc = new MessageChannel();
const [p1, p2] = [mc.port1, mc.port2];
p2.postMessage(123);
p1.onmessage = (e) => {
console.log(e.data); // 123
p1.close();
};

close 方法断开该端口的连接,停止流向该端口的消息,也不再能发送消息。

可以使用 addEventListener 监听 message 事件,但要显式调用 start() 接收在端口上排队的消息,DOM 0级事件 onmessage 则会自动开始接收消息。在开始接收消息前,另一个端口发送的消息会进入缓冲区。

消息在发送和接收的过程需要序列化和反序列化,可以实现深拷贝,同时也意味着消息只能基本类型或结构化可克隆对象。

const mc = new MessageChannel();
const [p1, p2] = [mc.port1, mc.port2];
const obj1 = {
a: { b: 1 },
};
p1.postMessage(obj1);
p2.onmessage = (e) => {
let obj2 = e.data;
obj2.a.b = 2;
console.log(obj1.a.b); // 1
p2.close();
};

通过 promise 封装深拷贝函数。

function deepClone(obj) {
return new Promise((resolve) => {
const mc = new MessageChannel();
mc.port2.postMessage(obj);
mc.port1.onmessage = (e) => {
mc.port1.close();
resolve(e.data);
};
});
}
const obj = {
a: { b: 1 },
};
obj.obj = obj; // 循环引用
deepClone(obj).then((res) => {
console.log(res === obj); // false
console.log(res); // <ref *1> { a: { b: 1 }, obj: [Circular *1] }
});

评论

快速前往