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

目录

  • [1. 引言:为什么需要区分赋值与拷贝](#1. 引言:为什么需要区分赋值与拷贝 "#1-%E5%BC%95%E8%A8%80%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E5%8C%BA%E5%88%86%E8%B5%8B%E5%80%BC%E4%B8%8E%E6%8B%B7%E8%B4%9D")
  • [2. 赋值与拷贝的本质区别](#2. 赋值与拷贝的本质区别 "#2-%E8%B5%8B%E5%80%BC%E4%B8%8E%E6%8B%B7%E8%B4%9D%E7%9A%84%E6%9C%AC%E8%B4%A8%E5%8C%BA%E5%88%AB")
    • [2.1 变量存的是引用](#2.1 变量存的是引用 "#21-%E5%8F%98%E9%87%8F%E5%AD%98%E7%9A%84%E6%98%AF%E5%BC%95%E7%94%A8")
    • [2.2 拷贝意味着新建对象](#2.2 拷贝意味着新建对象 "#22-%E6%8B%B7%E8%B4%9D%E6%84%8F%E5%91%B3%E7%9D%80%E6%96%B0%E5%BB%BA%E5%AF%B9%E8%B1%A1")
    • [2.3 一张图看清三种关系](#2.3 一张图看清三种关系 "#23-%E4%B8%80%E5%BC%A0%E5%9B%BE%E7%9C%8B%E6%B8%85%E4%B8%89%E7%A7%8D%E5%85%B3%E7%B3%BB")
  • [3. 浅拷贝的常见方案](#3. 浅拷贝的常见方案 "#3-%E6%B5%85%E6%8B%B7%E8%B4%9D%E7%9A%84%E5%B8%B8%E8%A7%81%E6%96%B9%E6%A1%88")
    • [3.1 展开运算符](#3.1 展开运算符 "#31-%E5%B1%95%E5%BC%80%E8%BF%90%E7%AE%97%E7%AC%A6")
    • [3.2 Object.assign](#3.2 Object.assign "#32-objectassign")
    • [3.3 数组的 slice / concat / 展开](#3.3 数组的 slice / concat / 展开 "#33-%E6%95%B0%E7%BB%84%E7%9A%84-slice--concat--%E5%B1%95%E5%BC%80")
    • [3.4 浅拷贝的局限](#3.4 浅拷贝的局限 "#34-%E6%B5%85%E6%8B%B7%E8%B4%9D%E7%9A%84%E5%B1%80%E9%99%90")
  • [4. 深拷贝的常见方案](#4. 深拷贝的常见方案 "#4-%E6%B7%B1%E6%8B%B7%E8%B4%9D%E7%9A%84%E5%B8%B8%E8%A7%81%E6%96%B9%E6%A1%88")
    • [4.1 JSON.parse(JSON.stringify(obj))](#4.1 JSON.parse(JSON.stringify(obj)) "#41-jsonparsejsonstringifyobj")
    • [4.2 手写递归深拷贝](#4.2 手写递归深拷贝 "#42-%E6%89%8B%E5%86%99%E9%80%92%E5%BD%92%E6%B7%B1%E6%8B%B7%E8%B4%9D")
    • [4.3 structuredClone](#4.3 structuredClone "#43-structuredclone")
    • [4.4 第三方库:lodash.cloneDeep](#4.4 第三方库:lodash.cloneDeep "#44-%E7%AC%AC%E4%B8%89%E6%96%B9%E5%BA%93lodashclonedeep")
    • [4.5 方案对比](#4.5 方案对比 "#45-%E6%96%B9%E6%A1%88%E5%AF%B9%E6%AF%94")
  • [5. 循环引用:深拷贝绕不开的坑](#5. 循环引用:深拷贝绕不开的坑 "#5-%E5%BE%AA%E7%8E%AF%E5%BC%95%E7%94%A8%E6%B7%B1%E6%8B%B7%E8%B4%9D%E7%BB%95%E4%B8%8D%E5%BC%80%E7%9A%84%E5%9D%91")
    • [5.1 循环引用是怎么产生的](#5.1 循环引用是怎么产生的 "#51-%E5%BE%AA%E7%8E%AF%E5%BC%95%E7%94%A8%E6%98%AF%E6%80%8E%E4%B9%88%E4%BA%A7%E7%94%9F%E7%9A%84")
    • [5.2 为什么 JSON 方案会崩](#5.2 为什么 JSON 方案会崩 "#52-%E4%B8%BA%E4%BB%80%E4%B9%88-json-%E6%96%B9%E6%A1%88%E4%BC%9A%E5%B4%A9")
    • [5.3 WeakMap 解决循环引用](#5.3 WeakMap 解决循环引用 "#53-weakmap-%E8%A7%A3%E5%86%B3%E5%BE%AA%E7%8E%AF%E5%BC%95%E7%94%A8")
    • [5.4 用 structuredClone 更省心](#5.4 用 structuredClone 更省心 "#54-%E7%94%A8-structuredclone-%E6%9B%B4%E7%9C%81%E5%BF%83")
  • [6. 性能与选型建议](#6. 性能与选型建议 "#6-%E6%80%A7%E8%83%BD%E4%B8%8E%E9%80%89%E5%9E%8B%E5%BB%BA%E8%AE%AE")
    • [6.1 性能量级参考](#6.1 性能量级参考 "#61-%E6%80%A7%E8%83%BD%E9%87%8F%E7%BA%A7%E5%8F%82%E8%80%83")
    • [6.2 选型决策](#6.2 选型决策 "#62-%E9%80%89%E5%9E%8B%E5%86%B3%E7%AD%96")
    • [6.3 实践建议](#6.3 实践建议 "#63-%E5%AE%9E%E8%B7%B5%E5%BB%BA%E8%AE%AE")
  • [7. 总结](#7. 总结 "#7-%E6%80%BB%E7%BB%93")

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["性能与选型建议"]

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

2.1 变量存的是引用

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

js 复制代码
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,修改互不影响(浅拷贝只保证第一层互不影响)。

js 复制代码
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

3. 浅拷贝的常见方案

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

3.1 展开运算符

js 复制代码
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

js 复制代码
const source = { a: 1, b: { c: 2 } };
const copy = Object.assign({}, source);

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

3.3 数组的 slice / concat / 展开

js 复制代码
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

4. 深拷贝的常见方案

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

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

js 复制代码
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 手写递归深拷贝

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

js 复制代码
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 方案强得多。

js 复制代码
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

它天然支持循环引用:

js 复制代码
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

js 复制代码
import cloneDeep from "lodash/cloneDeep";

const copy = cloneDeep(source);

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

4.5 方案对比

方案 循环引用 Date/RegExp Map/Set 函数/Symbol 原型链 体积
JSON 方案 ❌ 抛错 ❌ 变字符串 ❌ 变 {} ❌ 丢失 ❌ 0
手写递归 ✅ ✅ 需手动处理 ✅ 需手动处理 ✅ 可保留引用 ✅ 0
structuredClone ✅ ✅ ✅ ❌ 抛错 ❌ 0
lodash.cloneDeep ✅ ✅ ✅ ✅ 保留引用 ✅ 较大

5. 循环引用:深拷贝绕不开的坑

5.1 循环引用是怎么产生的

js 复制代码
const parent = { name: "parent" };
const child = { name: "child", parent };

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

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

5.2 为什么 JSON 方案会崩

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

text 复制代码
TypeError: Converting circular structure to JSON

5.3 WeakMap 解决循环引用

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

js 复制代码
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 更省心

js 复制代码
const copy = structuredClone(parent);
console.log(copy.self === copy); // true

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

6. 性能与选型建议

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 或干脆改成不可变数据结构。

7. 总结

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

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

相关推荐
计算机魔术师40 分钟前
AI证伪了数学家信了几十年的假设,但物理那道题只答了一半
前端
jerrywus43 分钟前
DeepSeek Harness 和 Pi Agent 怎么选?先想想你愿意折腾什么
前端·agent·deepseek
货拉拉技术1 小时前
一句话叫车:货拉拉小程序 AI 叫车实战
前端·agent·ai编程
yunyi1 小时前
给桌面客户端做下载加速:从「桶直接公开」到「边缘优先、源站兜底」的三代演进
前端·后端
world78112261 小时前
【C++】set 和 map
java·前端·c++·set·map
程序猿乐锅1 小时前
Streamlit 实现 AI 聊天 Agent:角色提示词、流式输出与会话管理
开发语言·前端·笔记·后端·python·学习
niucloud-admin1 小时前
NIUCLOUD PHP SAAS上门家政使用文档:客户报表
前端·人工智能·ai编程
漫步人生走在路上1 小时前
外贸B2B行业GEO优化白皮书:从0到1完整落地流程
前端·人工智能·搜索引擎·chatgpt·twitter
程序猿乐锅2 小时前
面向对象练习:图书借阅系统里的封装、抽象类与多态
开发语言·前端·数据库·后端·python·学习·1024程序员节