JS对象拷贝

为什么对象会有拷贝的概念?

与原始类型不同,引用类型是共享的。如果直接将一个引用值赋给一个变量,得到的值是该引用类型在堆内存中的"内存地址引用"。

而对象属于引用类型。

浅拷贝

若对象 obj2 是对象 obj1 的浅拷贝,则同时满足以下4个条件:

  1. 它们不是同一个对象,即 obj1 !== obj2
  2. obj1obj2 的属性具有相同的名称且顺序相同。
  3. 它们的属性值相等。
  4. 它们的原型链相等。

浅拷贝的方式

传统遍历

选取合适的方式 forfor...infor...ofArray.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.createObject.getOwnPropertyDescriptors 可实现对象浅拷贝。

  • 完美保留原型
  • 若存在 gettersetter 也可完整保留
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

lodashclone 方法专门用于实现浅拷贝。

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个条件:

  1. 它们不是同一个对象,即 obj1 !== obj2
  2. obj1obj2 的属性具有相同的名称且顺序相同。
  3. 它们对应的属性值也是彼此的深拷贝。
  4. 它们的原型链是结构等价的。

深拷贝的方式

JSON.parse + JSON.stringify

JSON.parse(JSON.stringify(value)) 通过先转换为 JSON 字符串 再解析为 JSON 对象的方式实现深拷贝。

这种方式写法及其简单,语法支持度也高。

缺点也十分明显:

1、支持的类型有限,由于是 JSON 的静态方法,所以只支持 JSON 格式的数据:

  1. Null
  2. String
  3. NumberNaNInfinity 除外)
  4. Boolean
  5. Array(值类型仅包含其他5种类型,且不支持挂载属性
  6. Object(值类型仅包含其他5种类型,键类型仅支持 String,不支持 Symbol

2、其他类型的值均不支持,具体情况如下:

  1. Object:如果 类型是 Symbol 类型是 undefinedSymbolFunction,会被直接忽略不拷贝。

  2. Array:如果值类型是 undefinedSymbolFunction,会被强制转为 null

  3. Date 会被强制转为 ISO 时间字符串。

  4. RegExpMapSetError 会被强制转为空对象 {}

  5. NaNInfinity 会被强制转为 null

  6. BigInt 无法处理,会直接报错。

    typescript 复制代码
    const obj = {
      bigInt: 9007199254740991n,
    };
    
    JSON.parse(JSON.stringify(obj)); // ❌ Uncaught TypeError: Do not know how to serialize a BigInt
  7. DOM:简单节点,比如自建的 Div 会被强制转为空对象 {};复杂节点,带循环引用的会直接报错。

    typescript 复制代码
    const div = document.createElement('div');
    div.id = 'my-div';
    div.className = 'container';
    div.textContent = 'Hello World';
    const obj = {
      div,
    };
    
    JSON.parse(JSON.stringify(obj)); // { div: {} }
    typescript 复制代码
    const 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 对象。它通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环。 调用 WorkerpostMessage() 或使用 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 不支持拷贝的数据类型:

  1. Symbol 类型;
  2. 函数类型,真的没有必要也没法拷贝,因为一涉及到作用域和闭包就哑火;
  3. DOM 类型,节点十分庞杂轻易也不要碰;
  4. 对象类型的某些情况:
    • RegExplastIndex 字段不会被保留,不保留那默认值就是0
    • 属性描述符定义的属性会被重置为普通属性 ,这样会带来两个变化:
      1. writable、enumerable、configurable会被重置为 true
      2. 若是有 getter、setter,则会调用 getter 的执行结果作为新属性的值;
    • 原形链丢失;

cloneDeep

cloneDeep 通过配置传参 的方式调用内部方法 baseClone 实现。其它3个拷贝方法 clonecloneWithcloneDeepWith 也均为通过 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 的实现:

预备工作

  1. 定义容器变量 result
  2. 确定拷贝策略:
    1. 是否深度拷贝;
    2. 是否拷贝继承属性;(注:cloneDeep 只拷贝自有属性
    3. 是否拷贝 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种原始类型值(UndefinedNullStringNumberBooleanBigIntSymbol)无需拷贝,直接返回原值。

javascript 复制代码
function baseClone(value, bitmask, customizer, key, object, stack) {
  // 其他逻辑...

  if (!isObject(value)) {
    return value;
  }
  
  // 其他逻辑...
}

容器初始化

根据类型实行不同的初始化策略:

数组:

  • 初始化一个同等长度的空数组数组
  • 若是正则的 match 数组,还会追加 indexinout 两个挂载属性。
  • 若是浅拷贝,则直接执行拷贝并返回结果。
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));
}

不支持拷贝的类型:

包括但不限于:FunctionErrorWeakMapWeakSetDOM。 只能这么表述,因为源码给的是白名单,不在白名单的都是不支持拷贝的类型,而数据类型是动态可扩充的。

  • 独立拷贝时,直接返回空对象 {},拷贝结束。
  • 作为属性拷贝时,直接返回原引用,拷贝结束。
javascript 复制代码
if (!cloneableTags[tag]) {
  return object ? value : {};
}

其余可拷贝的类型:

调用相应构造器初始化容器,包括:DateRegExpMapSetArrayBufferDataViewTypedArray、包装对象(StringNumberBooleanSymbol)。

javascript 复制代码
result = initCloneByTag(value, tag, isDeep);

注意 :这里有个很特殊的点,就是 Symbol 类型的包装对象。我们都知道 Symbol 不支持 new 构造,但可以通过 Object(Symbol('foo')) 的方式创建。

大家可以思考这么几个问题:

  1. 为什么用 Array.isArray 先判断数组,为什么不一起用 tag 统一判断?
  2. Buffer 既然要特殊处理,也没有使用到 tag 值,为什么不提到外面去?
  3. cloneableTags 的黑名单里没有 WeakSetAsyncFunction,会不会出问题?

完整容器初始化代码:

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);
  
  // 其他逻辑...
}

MapSet 的处理

MapSet 结构特殊,需单独处理。先用自身的 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));
    });
  }
  
  // 其他逻辑...
}

注意 :这里处理完毕之后并没有返回而是继续往下执行,因为 MapSet 属于广义对象,同样可以挂载自定义属性。继续执行后面的逻辑就是为了拷贝挂载的自定义属性

遍历属性递归拷贝

1、确定要遍历的键 根据 isFullisFlat 的取值共有4种提取策略:

  1. keys:仅自身String类型键;
  2. keysIn:自身和原型链继承的String类型键;
  3. getAllKeys:自身StringSymbol类型键;
  4. getAllKeysIn:自身和原型链继承的StringSymbol类型键;
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、执行遍历并递归赋值

这里针对对象类型做了 keyvalue 的修正,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各种数据类型的理解,包括初始化方式、遍历方式、循环引用等
  • 部分面试要求,借此考察知识深度及边界

感兴趣的朋友可以点击这里查看我写的一版对象深拷贝的自实现。

相关推荐
一心只读圣贤书1 小时前
AI 辅助前端组件文档治理:从 Props 说明到交互示例生成
前端
晴殇i2 小时前
最近在 Github 名字叫“马尾辫”,这个真的很有趣看到头像
前端·后端·开源
10mAh2 小时前
【PaddleOCR】扫描版 PDF 无法复制、RAG 检索为空怎么解决?——OCR 解析与版面还原实战
前端·pdf·ocr
IMPYLH2 小时前
HTML 的 <embed> 元素
前端·数据库·html
IT_陈寒2 小时前
Vite打包时静态资源404?加个斜杠就能解决
前端·人工智能·后端
沐土Arvin2 小时前
音频录制sdk开发
前端·微信
anyup2 小时前
【2026年8月】uView Pro 千星,还拿到了 GVP
前端·uni-app·github
必须会一定会2 小时前
用纯 HTML/JS 做一个 AI 需求澄清器:把模糊想法转换成可执行任务书
开发语言·前端·javascript·人工智能·html·ai编程
10mAh3 小时前
【Linux】error while loading shared libraries 怎么解决?——ldd、RPATH 与动态链接排错
java·linux·前端