JavaScript 深浅拷贝全解析:从 JSON 到 structuredClone

文章来源声明: 原文作者:vipxieliang; 来源站点:掘金; 原文链接:https://juejin.cn/post/7701269925755961359; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

知识脉络清晰、方案对比表格直观,适合前端开发者系统补齐拷贝认知。选型决策与实践建议可直接落地,尤其适合处理接口数据、树形结构与老项目迁移场景。

目录 --
  1. 引言:为什么需要区分赋值与拷贝

在 JavaScript 里,const obj2 = obj1 这行代码几乎人人写过,但很多人对它的语义存在误解:它并没有"复制"对象,只是让两个变量指向同一块内存。于是修改 obj2 的属性,obj1 也跟着变,这类 bug 在业务代码里屡见不鲜。

真正要复制一个对象,就要面对"浅"与"深"的选择:浅拷贝只复制第一层,嵌套对象仍然共享引用;深拷贝则递归复制整棵对象树,代价更高但彼此独立。

本文从赋值与拷贝的本质区别讲起,依次梳理浅拷贝的常见方案、深拷贝的几种实现(含 structuredClone),再讨论循环引用与性能取舍,最后给出一份可直接落地的选型建议。

下面这张图概括了全文的知识脉络,方便先建立整体印象:

flowchart TD
  A["赋值 vs 拷贝"] --> B["赋值:共享同一引用"]
  A --> C["拷贝:创建新对象"]
  C --> D["浅拷贝:只复制第一层"]
  C --> E["深拷贝:递归复制整棵树"]
  D --> D1["展开运算符 / Object.assign / slice"]
  E --> E1["JSON 方案"]
  E --> E2["手写递归 + WeakMap"]
  E --> E3["structuredClone"]
  E --> E4["lodash.cloneDeep"]
  E2 --> F["循环引用处理"]
  E3 --> F
  F --> G["性能与选型建议"]

  1. 赋值与拷贝的本质区别

2.1 变量存的是引用

JavaScript 中的对象、数组、函数、Map、Set 等都属于引用类型。变量里保存的不是对象本身,而是指向堆内存的地址。

const a = { name: "Tom", tags: ["js"] };
const b = a; // 赋值:b 与 a 指向同一个对象

b.name = "Jerry";
console.log(a.name); // "Jerry" —— a 也被改了
console.log(a === b); // true

2.2 拷贝意味着新建对象

拷贝的核心是"创建一个新对象,再把原对象的内容搬过去"。新对象与原对象 === 比较为 false,修改互不影响(浅拷贝只保证第一层互不影响)。

const a = { name: "Tom", tags: ["js"] };
const b = { ...a }; // 浅拷贝

b.name = "Jerry";
console.log(a.name); // "Tom" —— 第一层互不影响

b.tags.push("ts");
console.log(a.tags); // ["js", "ts"] —— 嵌套数组仍然共享

2.3 一张图看清三种关系

flowchart LR
  subgraph S1["赋值"]
    A1["变量 a"] --> O1["对象 {name, tags}"]
    B1["变量 b"] --> O1
  end
  subgraph S2["浅拷贝"]
    A2["变量 a"] --> O2["对象 {name, tags}"]
    B2["变量 b"] --> O3["新对象 {name, tags}"]
    O2 --> T2["tags 数组"]
    O3 --> T2
  end
  subgraph S3["深拷贝"]
    A3["变量 a"] --> O4["对象 {name, tags}"]
    B3["变量 b"] --> O5["新对象 {name, tags}"]
    O4 --> T4["tags 数组 A"]
    O5 --> T5["tags 数组 B"]
  end

  1. 浅拷贝的常见方案

浅拷贝只处理对象的第一层属性,嵌套的引用类型仍然共享。日常开发中以下几种写法最常见。

3.1 展开运算符

const source = { a: 1, b: { c: 2 } };
const copy = { ...source };

copy.a = 100;
console.log(source.a); // 1,第一层独立

copy.b.c = 200;
console.log(source.b.c); // 200,嵌套对象共享

3.2 Object.assign

const source = { a: 1, b: { c: 2 } };
const copy = Object.assign({}, source);

注意 Object.assign 会触发源对象的 getter,并且只复制可枚举的自有属性,Symbol 属性也会被复制(这一点比 JSON 方案更完整)。

3.3 数组的 slice / concat / 展开

const arr = [1, { x: 2 }];

const c1 = arr.slice();
const c2 = arr.concat();
const c3 = [...arr];

c1[0] = 99;
console.log(arr[0]); // 1,基本类型独立

c1[1].x = 99;
console.log(arr[1].x); // 99,对象元素共享

3.4 浅拷贝的局限

  • 嵌套对象、数组仍然共享引用;
  • Date、RegExp、Map、Set 等特殊对象在展开时会被"降级"为普通对象或丢失内部状态;
  • 原型链不会被复制,class 实例拷贝后变成普通对象。

用一张图总结浅拷贝的边界:第一层被复制,第二层及以下仍然指向同一块内存。

flowchart LR
  subgraph SRC["原对象 source"]
    S1["a: 1(基本类型)"]
    S2["b: { c: 2 }(引用类型)"]
  end
  subgraph CP["浅拷贝 copy"]
    C1["a: 1(独立副本)"]
    C2["b: 同一引用"]
  end
  S2 --> SHARED["嵌套对象 { c: 2 }"]
  C2 --> SHARED

  1. 深拷贝的常见方案

4.1 JSON.parse(JSON.stringify(obj))

最"偷懒"的方案,一行搞定,但坑非常多。

const source = {
  a: 1,
  b: { c: 2 },
  d: new Date("2024-01-01"),
  e: undefined,
  f: () => {},
  g: Symbol("s"),
  h: NaN,
  i: Infinity,
};

const copy = JSON.parse(JSON.stringify(source));
console.log(copy);
// { a: 1, b: { c: 2 }, d: "2024-01-01T00:00:00.000Z", h: null, i: null }

可以看到:

  • undefined、函数、Symbol 属性被直接丢弃;
  • Date 变成字符串;
  • NaN、Infinity 变成 null;
  • Map、Set、RegExp 变成 {};
  • 遇到循环引用直接抛 TypeError。

适用场景:纯数据(JSON 可序列化)的配置对象、接口返回体,且确定没有特殊类型和循环引用。

4.2 手写递归深拷贝

面试常考,也是理解深拷贝原理的最好方式。

function deepClone(target, map = new WeakMap()) {
  // 基本类型直接返回
  if (target === null || typeof target !== "object") return target;

  // 处理循环引用
  if (map.has(target)) return map.get(target);

  // 处理特殊对象
  if (target instanceof Date) return new Date(target);
  if (target instanceof RegExp) return new RegExp(target.source, target.flags);

  // 保持原型,兼容 class 实例
  const clone = Array.isArray(target)
    ? []
    : Object.create(Object.getPrototypeOf(target));

  map.set(target, clone);

  // 只复制自有可枚举属性(含 Symbol)
  Reflect.ownKeys(target).forEach((key) => {
    clone[key] = deepClone(target[key], map);
  });

  return clone;
}

要点说明:

  • 用 WeakMap 记录"原对象 → 克隆对象"的映射,既解决循环引用,也避免同一对象被重复克隆;
  • Object.create(Object.getPrototypeOf(target)) 保留原型链;
  • Reflect.ownKeys 能拿到字符串键和 Symbol 键;
  • 如需支持 Map / Set,要单独分支处理。

4.3 structuredClone

structuredClone 是浏览器与 Node.js(17+)内置的全局函数,基于结构化克隆算法,能力比 JSON 方案强得多。

const source = {
  a: 1,
  b: { c: 2 },
  d: new Date("2024-01-01"),
  e: new Map([["k", "v"]]),
  f: new Set([1, 2, 3]),
  g: /abc/gi,
};

const copy = structuredClone(source);

console.log(copy.d instanceof Date); // true
console.log(copy.e instanceof Map); // true
console.log(copy.f instanceof Set); // true
console.log(copy.g instanceof RegExp); // true
console.log(copy.b === source.b); // false

它天然支持循环引用:

const obj = { name: "root" };
obj.self = obj;

const copy = structuredClone(obj);
console.log(copy.self === copy); // true
console.log(copy === obj); // false

限制:

  • 不能克隆函数、Symbol、DOM 节点;
  • 不能克隆 class 实例的自定义原型(会退化为普通对象);
  • 遇到不可克隆的值会抛 DataCloneError。

4.4 第三方库:lodash.cloneDeep

import cloneDeep from "lodash/cloneDeep";

const copy = cloneDeep(source);

lodash.cloneDeep 兼容性好、支持 Map / Set / Date / RegExp / 循环引用,还能处理部分自定义类型,是老项目的稳妥选择。缺点是会增加包体积,按需引入即可。

4.5 方案对比

方案循环引用Date/RegExpMap/Set函数/Symbol原型链体积
JSON 方案❌ 抛错❌ 变字符串❌ 变 `{}`❌ 丢失❌0
手写递归✅✅ 需手动处理✅ 需手动处理✅ 可保留引用✅0
structuredClone✅✅✅❌ 抛错❌0
lodash.cloneDeep✅✅✅✅ 保留引用✅较大
  1. 循环引用:深拷贝绕不开的坑

5.1 循环引用是怎么产生的

const parent = { name: "parent" };
const child = { name: "child", parent };

parent.child = child;
// parent.child.parent === parent,形成环

树形结构(菜单、组织架构、评论回复)在父子互相引用时非常容易形成环。

5.2 为什么 JSON 方案会崩

JSON.stringify 在序列化时会递归遍历对象,遇到环就无限递归,最终抛出:

TypeError: Converting circular structure to JSON

5.3 WeakMap 解决循环引用

核心思路:克隆前先查表,如果这个对象已经克隆过,直接返回已有的克隆结果,而不是继续递归。

function deepClone(target, map = new WeakMap()) {
  if (target === null || typeof target !== "object") return target;
  if (map.has(target)) return map.get(target);

  const clone = Array.isArray(target) ? [] : {};
  map.set(target, clone); // 先登记,再递归

  for (const key in target) {
    if (Object.prototype.hasOwnProperty.call(target, key)) {
      clone[key] = deepClone(target[key], map);
    }
  }
  return clone;
}

const parent = { name: "parent" };
parent.self = parent;

const copy = deepClone(parent);
console.log(copy.self === copy); // true
console.log(copy === parent); // false

注意 map.set(target, clone) 必须在递归子属性之前执行,否则环仍然会被无限展开。

5.4 用 structuredClone 更省心

const copy = structuredClone(parent);
console.log(copy.self === copy); // true

只要数据里没有函数、Symbol、DOM 节点,structuredClone 是处理循环引用最省事的方式。

  1. 性能与选型建议

6.1 性能量级参考

不同方案的开销差异明显,下面是经验性的排序(同一数据规模下):

  1. 浅拷贝(展开 / Object.assign):最快,只遍历第一层;
  2. structuredClone:原生实现,通常快于手写递归,尤其是深层嵌套数据;
  3. 手写递归:受实现细节影响大,属性多、层级深时明显变慢;
  4. JSON 方案:需要序列化 + 反序列化两次遍历,且会丢失类型,通常不是性能最优解;
  5. lodash.cloneDeep:功能最全,但函数调用与类型判断开销较大。

具体数值随数据结构和运行环境变化很大,建议在真实数据上用 console.time / performance.now() 实测,而不是背结论。

6.2 选型决策

flowchart TD
  A["需要复制对象吗"] -->|"否"| Z["直接用引用"]
  A -->|"是"| B{"只改第一层?"}
  B -->|"是"| C["浅拷贝: 展开 / Object.assign"]
  B -->|"否"| D{"含函数 / Symbol / class 实例?"}
  D -->|"否"| E{"含循环引用?"}
  E -->|"是或不确定"| F["structuredClone"]
  E -->|"否"| G["structuredClone 或 JSON 方案"]
  D -->|"是"| H["lodash.cloneDeep 或手写递归"]

6.3 实践建议

  • 优先浅拷贝:很多"需要深拷贝"的场景,其实只是改了一个字段,用不可变更新({ ...state, field: newValue })就够了;
  • 纯数据用 structuredClone:接口数据、配置对象、postMessage 传输,优先用它,代码短且不易出错;
  • 避免 JSON 方案处理业务对象:Date 变字符串、undefined 丢失,往往在很久之后才暴露成 bug;
  • 需要保留函数或原型时手写或上 lodash:明确写出支持的类型分支,别指望一个函数通吃;
  • 大对象谨慎深拷贝:几 MB 的树形数据递归克隆会阻塞主线程,必要时考虑分片、requestIdleCallback 或干脆改成不可变数据结构。
  1. 总结

  • 赋值只是复制引用,两个变量指向同一对象;拷贝才会创建新对象。
  • 浅拷贝(展开、Object.assign、slice)只复制第一层,嵌套引用仍共享。
  • 深拷贝方案各有取舍:JSON 方案简单但丢类型、怕循环引用;手写递归灵活但需自己处理边界;structuredClone 原生、支持循环引用与多数内置类型,是当下的首选;lodash.cloneDeep 功能最全,适合复杂老项目。
  • 循环引用用 WeakMap 登记已克隆对象即可解决,structuredClone 则天然支持。
  • 性能上先问"是否真的需要深拷贝",再按数据类型选择方案,并在真实数据上实测。

理解这些差异后,再看到 const b = a 时,你会先问一句:我要的是同一个对象,还是一个副本?