为什么对象会有拷贝的概念?
与原始类型不同,引用类型是共享的。如果直接将一个引用值赋给一个变量,得到的值是该引用类型在堆内存中的"内存地址引用"。
而对象属于引用类型。
浅拷贝
若对象 obj2 是对象 obj1 的浅拷贝,则同时满足以下4个条件:
- 它们不是同一个对象,即
obj1 !== obj2。 obj1和obj2的属性具有相同的名称且顺序相同。- 它们的属性值相等。
- 它们的原型链相等。
浅拷贝的方式
传统遍历
选取合适的方式 for、for...in、for...of、Array.prototype.forEach 遍历填值即可。
typescript
const obj = { /*...*/ };
const arr = [ /*...*/ ];
const cloneObj = {};
const cloneArr = [];
for (let k in obj) {/*...*/}
for (let i = 0; i < arr.length; i += 1) {/*...*/}
...(展开语法)
展开语法 ... 语法简洁,在浅拷贝对象时非常直观。
- 数组接收可迭代数据,数组、字符串均可,但对象不行。
- 对象接收可遍历数据,对象、数组、字符串均可。
typescript
const obj = { /*...*/ };
const arr = [ /*...*/ ];
const str = '/*...*/';
const cloneObj = { ...arr, ...obj, ...str };
const cloneArr = [ ...arr, ...str ];
Object.assign
利用 Object.assign 将空对象 {} 填入 source 位,实现浅拷贝。
typescript
const obj = { /*...*/ };
Object.assign({}, obj);
Object.create
利用 Object.create 和 Object.getOwnPropertyDescriptors 可实现对象浅拷贝。
- 完美保留原型
- 若存在
getter、setter也可完整保留
javascript
const obj = { /*...*/ };
const clone = Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj)
);
Array.prototype.slice
利用 slice 方法不指定下标的方式实现浅拷贝。
- 支持稀疏数组
- 支持类数组对象
javascript
const arr = [ /*...*/ ];
const clone = arr.slice();
Array.prototype.concat
利用 concat 方法与空数组 {} 的方式实现浅拷贝。
- 支持稀疏数组
- 支持类数组对象
javascript
const arr = [ /*...*/ ];
const clone = [].concat(arr);
Array.from
从可迭代数据 或类数组对象上创建数组浅拷贝。
javascript
const arr = [ /*...*/ ];
const clone = Array.from(arr);
clone
lodash 的 clone 方法专门用于实现浅拷贝。
javascript
const obj = { /*...*/ };
const clone = _.clone(obj);
clone 通过配置传参 的方式调用内部方法 baseClone 实现。 这里通过位运算的方式传递了一个配置:1、需要拷贝 Symbol 类型的键。
javascript
function clone(value) {
return baseClone(value, CLONE_SYMBOLS_FLAG);
}
baseClone 的具体实现原理会在下文中与[cloneDeep 的实现](#cloneDeep 的实现 "#cloneDeep")一同讲解。
深拷贝
若对象 obj2 是对象 obj1 的深拷贝,则同时满足以下4个条件:
- 它们不是同一个对象,即
obj1 !== obj2。 obj1和obj2的属性具有相同的名称且顺序相同。- 它们对应的属性值也是彼此的深拷贝。
- 它们的原型链是结构等价的。
深拷贝的方式
JSON.parse + JSON.stringify
JSON.parse(JSON.stringify(value)) 通过先转换为 JSON 字符串 再解析为 JSON 对象的方式实现深拷贝。
这种方式写法及其简单,语法支持度也高。
缺点也十分明显:
1、支持的类型有限,由于是 JSON 的静态方法,所以只支持 JSON 格式的数据:
NullStringNumber(NaN、Infinity除外)BooleanArray(值类型仅包含其他5种类型,且不支持挂载属性)Object(值类型仅包含其他5种类型,键类型仅支持String,不支持Symbol)
2、其他类型的值均不支持,具体情况如下:
-
Object:如果键 类型是Symbol,值 类型是undefined、Symbol、Function,会被直接忽略不拷贝。 -
Array:如果值类型是undefined、Symbol、Function,会被强制转为null。 -
Date会被强制转为ISO时间字符串。 -
RegExp、Map、Set、Error会被强制转为空对象{}。 -
NaN、Infinity会被强制转为null。 -
BigInt无法处理,会直接报错。typescriptconst obj = { bigInt: 9007199254740991n, }; JSON.parse(JSON.stringify(obj)); // ❌ Uncaught TypeError: Do not know how to serialize a BigInt -
DOM:简单节点,比如自建的Div会被强制转为空对象{};复杂节点,带循环引用的会直接报错。typescriptconst div = document.createElement('div'); div.id = 'my-div'; div.className = 'container'; div.textContent = 'Hello World'; const obj = { div, }; JSON.parse(JSON.stringify(obj)); // { div: {} }typescriptconst body = document.getElementsByTagName('body')[0]; const obj = { body, }; JSON.parse(JSON.stringify(obj)); // ❌ Uncaught TypeError: Converting circular structure to JSON
3、无法处理循环引用,会直接报错。
typescript
const obj = {};
obj.self = obj;
JSON.parse(JSON.stringify(obj)); // ❌ Uncaught TypeError: Converting circular structure to JSON
4、原形链丢失,实例化来的会退化为普通对象。 5、由于转来转去,在处理庞大数据时性能明显落后。
structuredClone
结构化克隆算法 存在于底层引擎,用于复制复杂 JavaScript 对象。它通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环。 调用 Worker 的 postMessage() 或使用 IndexedDB 存储对象时在内部就会调用结构化克隆算法。
后来才暴露为 structuredClone 方法供JS开发者使用。目前主流的JS运行环境(Browser、Nodejs、Deno 等)均已支持。
由于是底层实现,执行效率极高,在处理体积庞大结构复杂的对象时优势明显。
structuredClone(value, options),接收两个参数:value是需要深拷贝的对象,options配置对象目前仅支持transfer:用于存放可转移对象的数组。 并不是所有的对象都能转移。只有实现了 Transferable 接口的特殊二进制或底层资源对象(ArrayBuffer、TypedArray.buffer、MessagePort、ReadableStream、WritableStream、TransformStream、ImageBitmap)才叫可转移对象。
强如 structuredClone 也不是所有类型都支持,让我们看一下此方法对各数据类型的支持情况:
| 数据类型 | 是否支持 |
|---|---|
| 原始类型 | ✅ 仅Symbol不支持,其余Null、Undefined、String、Number、Boolean、BigInt均支持 |
| Object | ✅ 仅支持简单对象(如使用对象字面量创建的) |
| Function | ❌ 不支持,会抛出DATA_CLONE_ERR异常 |
| DOM | ❌ 不支持,会抛出DATA_CLONE_ERR异常 |
| Array | ✅ 支持 |
| Map | ✅ 支持 |
| Set | ✅ 支持 |
| Date | ✅ 支持 |
| RegExp | ✅ 支持,但lastIndex字段不会被保留 |
| Error | ✅ 支持,包括:Error、EvalError、RangeError、ReferenceError、SyntaxError、TypeError、URIError、AggregateError等 |
| ArrayBuffer | ✅ 支持 |
| DataView | ✅ 支持 |
| TypedArray | ✅ 支持 |
总结一下 structuredClone 不支持拷贝的数据类型:
Symbol类型;- 函数类型,真的没有必要也没法拷贝,因为一涉及到作用域和闭包就哑火;
DOM类型,节点十分庞杂轻易也不要碰;- 对象类型的某些情况:
RegExp的lastIndex字段不会被保留,不保留那默认值就是0;- 属性描述符定义的属性会被重置为普通属性 ,这样会带来两个变化:
writable、enumerable、configurable会被重置为true;- 若是有
getter、setter,则会调用getter的执行结果作为新属性的值;
- 原形链丢失;
cloneDeep
cloneDeep 通过配置传参 的方式调用内部方法 baseClone 实现。其它3个拷贝方法 clone、cloneWith、cloneDeepWith 也均为通过 baseClone 传参实现。
这里 cloneDeep 通过位运算的方式传递了两个配置:1、需要深度拷贝;2、需要拷贝 Symbol 类型的键。
javascript
function cloneDeep(value) {
return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG);
}
baseClone 完整源码,方便回看:
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
var result,
isDeep = bitmask & CLONE_DEEP_FLAG,
isFlat = bitmask & CLONE_FLAT_FLAG,
isFull = bitmask & CLONE_SYMBOLS_FLAG;
if (customizer) {
result = object ? customizer(value, key, object, stack) : customizer(value);
}
if (result !== undefined) {
return result;
}
if (!isObject(value)) {
return value;
}
var isArr = isArray(value);
if (isArr) {
result = initCloneArray(value);
if (!isDeep) {
return copyArray(value, result);
}
} else {
var tag = getTag(value),
isFunc = tag == funcTag || tag == genTag;
if (isBuffer(value)) {
return cloneBuffer(value, isDeep);
}
if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
result = (isFlat || isFunc) ? {} : initCloneObject(value);
if (!isDeep) {
return isFlat
? copySymbolsIn(value, baseAssignIn(result, value))
: copySymbols(value, baseAssign(result, value));
}
} else {
if (!cloneableTags[tag]) {
return object ? value : {};
}
result = initCloneByTag(value, tag, isDeep);
}
}
// Check for circular references and return its corresponding clone.
stack || (stack = new Stack);
var stacked = stack.get(value);
if (stacked) {
return stacked;
}
stack.set(value, result);
if (isSet(value)) {
value.forEach(function(subValue) {
result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack));
});
} else if (isMap(value)) {
value.forEach(function(subValue, key) {
result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack));
});
}
var keysFunc = isFull
? (isFlat ? getAllKeysIn : getAllKeys)
: (isFlat ? keysIn : keys);
var props = isArr ? undefined : keysFunc(value);
arrayEach(props || value, function(subValue, key) {
if (props) {
key = subValue;
subValue = value[key];
}
// Recursively populate clone (susceptible to call stack limits).
assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack));
});
return result;
}
接下来让我们重点分析一下内部方法 baseClone 的实现:
预备工作
- 定义容器变量
result。 - 确定拷贝策略:
- 是否深度拷贝;
- 是否拷贝继承属性;(注:
cloneDeep只拷贝自有属性) - 是否拷贝
Symbol类型的键。
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
var result,
isDeep = bitmask & CLONE_DEEP_FLAG,
isFlat = bitmask & CLONE_FLAT_FLAG,
isFull = bitmask & CLONE_SYMBOLS_FLAG;
// 其他逻辑...
}
自定义拷贝逻辑
(注:cloneDeep 不涉及此部分)
支持使用自定义拷贝函数 customizer "截胡":
- 若执行结果有值则使用并返回
- 否则依然执行默认拷贝逻辑
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
// 其他逻辑...
if (customizer) {
result = object ? customizer(value, key, object, stack) : customizer(value);
}
if (result !== undefined) {
return result;
}
// 其他逻辑...
}
原始类型的处理
7种原始类型值(Undefined、Null、String、Number、Boolean、BigInt、Symbol)无需拷贝,直接返回原值。
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
// 其他逻辑...
if (!isObject(value)) {
return value;
}
// 其他逻辑...
}
容器初始化
根据类型实行不同的初始化策略:
数组:
- 初始化一个同等长度的空数组数组
- 若是正则的
match数组,还会追加index和inout两个挂载属性。 - 若是浅拷贝,则直接执行拷贝并返回结果。
javascript
var isArr = isArray(value);
if (isArr) {
result = initCloneArray(value);
if (!isDeep) {
return copyArray(value, result);
}
}
Buffer 对象:
- 判断方式特殊,
Object.prototype.toString无法准确识别,使用内部Buffer.isBuffer判断。 - 克隆方式特殊,无论深浅克隆均使用内部方法完成,所以这里完成之后就直接返回不再往下进行了。
javascript
if (isBuffer(value)) {
return cloneBuffer(value, isDeep);
}
Function 类型:
- 独立拷贝时,初始化为空对象
{},用于后续拷贝挂载的属性。 - 作为属性拷贝时,直接返回原引用,拷贝结束。
javascript
if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
result = (isFlat || isFunc) ? {} : initCloneObject(value);
if (!isDeep) {
return isFlat
? copySymbolsIn(value, baseAssignIn(result, value))
: copySymbols(value, baseAssign(result, value));
}
} else {
if (!cloneableTags[tag]) {
return object ? value : {};
}
result = initCloneByTag(value, tag, isDeep);
}
普通对象和 Arguments 对象:
- 初始化空对象并正确设置
prototype属性。 - 若是浅拷贝,则直接执行拷贝并返回结果。
javascript
result = (isFlat || isFunc) ? {} : initCloneObject(value);
if (!isDeep) {
return isFlat
? copySymbolsIn(value, baseAssignIn(result, value))
: copySymbols(value, baseAssign(result, value));
}
不支持拷贝的类型:
包括但不限于:Function、Error、WeakMap、WeakSet、DOM。 只能这么表述,因为源码给的是白名单,不在白名单的都是不支持拷贝的类型,而数据类型是动态可扩充的。
- 独立拷贝时,直接返回空对象
{},拷贝结束。 - 作为属性拷贝时,直接返回原引用,拷贝结束。
javascript
if (!cloneableTags[tag]) {
return object ? value : {};
}
其余可拷贝的类型:
调用相应构造器初始化容器,包括:Date、RegExp、Map、Set、ArrayBuffer、DataView、TypedArray、包装对象(String、Number、Boolean、Symbol)。
javascript
result = initCloneByTag(value, tag, isDeep);
注意 :这里有个很特殊的点,就是 Symbol 类型的包装对象。我们都知道 Symbol 不支持 new 构造,但可以通过 Object(Symbol('foo')) 的方式创建。
大家可以思考这么几个问题:
- 为什么用
Array.isArray先判断数组,为什么不一起用tag统一判断? Buffer既然要特殊处理,也没有使用到tag值,为什么不提到外面去?cloneableTags的黑名单里没有WeakSet和AsyncFunction,会不会出问题?
完整容器初始化代码:
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
// 其他逻辑...
var isArr = isArray(value);
if (isArr) {
result = initCloneArray(value);
if (!isDeep) {
return copyArray(value, result);
}
} else {
var tag = getTag(value),
isFunc = tag == funcTag || tag == genTag;
if (isBuffer(value)) {
return cloneBuffer(value, isDeep);
}
if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
result = (isFlat || isFunc) ? {} : initCloneObject(value);
if (!isDeep) {
return isFlat
? copySymbolsIn(value, baseAssignIn(result, value))
: copySymbols(value, baseAssign(result, value));
}
} else {
if (!cloneableTags[tag]) {
return object ? value : {};
}
result = initCloneByTag(value, tag, isDeep);
}
}
// 其他逻辑...
}
循环引用的处理
用一个缓存对象 stack 记录拷贝过的引用类型,后续递归时若命中则直接使用并返回,以此解决循环引用的问题。
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
// 其他逻辑...
stack || (stack = new Stack);
var stacked = stack.get(value);
if (stacked) {
return stacked;
}
stack.set(value, result);
// 其他逻辑...
}
Map 和 Set 的处理
Map 和 Set 结构特殊,需单独处理。先用自身的 foreach 方法去遍历,再用 add/set 方法去添加递归拷贝值。
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
// 其他逻辑...
if (isSet(value)) {
value.forEach(function(subValue) {
result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack));
});
} else if (isMap(value)) {
value.forEach(function(subValue, key) {
result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack));
});
}
// 其他逻辑...
}
注意 :这里处理完毕之后并没有返回而是继续往下执行,因为 Map 和 Set 属于广义对象,同样可以挂载自定义属性。继续执行后面的逻辑就是为了拷贝挂载的自定义属性。
遍历属性递归拷贝
1、确定要遍历的键 根据 isFull 和 isFlat 的取值共有4种提取策略:
keys:仅自身String类型键;keysIn:自身和原型链继承的String类型键;getAllKeys:自身String和Symbol类型键;getAllKeysIn:自身和原型链继承的String和Symbol类型键;
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
// 其他逻辑...
var keysFunc = isFull
? (isFlat ? getAllKeysIn : getAllKeys)
: (isFlat ? keysIn : keys);
// 其他逻辑...
}
2、提取为键数组,用于接下来遍历。
这里 Array 类型没有提取,因为提取大量数字下标没有意义,不如直接去遍历数组。
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
// 其他逻辑...
var props = isArr ? undefined : keysFunc(value);
// 其他逻辑...
}
3、执行遍历并递归赋值
这里针对对象类型做了 key、value 的修正,assignValue 是内部一个安全赋值的方法。 注释提示我们,递归受限于栈深度,如果对象层级过深,就会报栈溢出错误。
javascript
function baseClone(value, bitmask, customizer, key, object, stack) {
// 其他逻辑...
arrayEach(props || value, function(subValue, key) {
if (props) {
key = subValue;
subValue = value[key];
}
// Recursively populate clone (susceptible to call stack limits).
assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack));
});
// 其他逻辑...
}
注意:由于数组类型的遍历没有提取键这一步,所以挂载在数组上的属性就等于被忽略,无法被复制。
纯净版本
最后贴一个删去了与 cloneDeep 逻辑无关代码的纯净版本。
javascript
function cloneDeep(value, key, object, stack) {
var result;
if (!isObject(value)) {
return value;
}
var isArr = isArray(value);
if (isArr) {
result = initCloneArray(value);
} else {
var tag = getTag(value),
isFunc = tag == funcTag || tag == genTag;
if (isBuffer(value)) {
return cloneBuffer(value, true);
}
if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
result = isFunc ? {} : initCloneObject(value);
} else {
if (!cloneableTags[tag]) {
return object ? value : {};
}
result = initCloneByTag(value, tag, true);
}
}
// Check for circular references and return its corresponding clone.
stack || (stack = new Stack);
var stacked = stack.get(value);
if (stacked) {
return stacked;
}
stack.set(value, result);
if (isSet(value)) {
value.forEach(function(subValue) {
result.add(cloneDeep(subValue, subValue, value, stack));
});
} else if (isMap(value)) {
value.forEach(function(subValue, key) {
result.set(key, cloneDeep(subValue, key, value, stack));
});
}
var props = isArr ? undefined : getAllKeys(value);
arrayEach(props || value, function(subValue, key) {
if (props) {
key = subValue;
subValue = value[key];
}
// Recursively populate clone (susceptible to call stack limits).
assignValue(result, key, cloneDeep(subValue, key, value, stack));
});
return result;
}
三种方法简单对比
| 方法 | _.deepClone(obj) | structuredClone(obj) | JSON.parse(JSON.stringify(obj)) |
|---|---|---|---|
| 循环引用 | 支持 ✅ | 支持 ✅ | 不支持 ❌ |
| 原型链 | 支持 ✅ | 不支持 ❌ | 不支持 ❌ |
| 函数 | 不支持 ❌ | 不支持,会报错 ❌ | 不支持 ❌ |
| Symbol | 支持 ✅ | 不支持 ❌ | 不支持 ❌ |
| DOM | 不支持 ❌ | 不支持,会报错 ❌ | 不支持,会报错 ❌ |
| BigInt | 支持 ✅ | 支持 ✅ | 不支持,会报错 ❌ |
手撕自实现
上述三种深拷贝的方法虽各有长短,只要使用得当,是可以覆盖我们日常的使用场景的。
那我们还要手撕的意义是什么呢,有大致两方面的原因:
- 通过手撕的方式加深对JS各种数据类型的理解,包括初始化方式、遍历方式、循环引用等
- 部分面试要求,借此考察知识深度及边界
感兴趣的朋友可以点击这里查看我写的一版对象深拷贝的自实现。