- 引言:为什么需要区分赋值与拷贝
在 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.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
- 浅拷贝的常见方案
浅拷贝只处理对象的第一层属性,嵌套的引用类型仍然共享。日常开发中以下几种写法最常见。
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
- 深拷贝的常见方案
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/RegExp | Map/Set | 函数/Symbol | 原型链 | 体积 |
|---|---|---|---|---|---|---|
| JSON 方案 | ❌ 抛错 | ❌ 变字符串 | ❌ 变 `{}` | ❌ 丢失 | ❌ | 0 |
| 手写递归 | ✅ | ✅ 需手动处理 | ✅ 需手动处理 | ✅ 可保留引用 | ✅ | 0 |
| structuredClone | ✅ | ✅ | ✅ | ❌ 抛错 | ❌ | 0 |
| lodash.cloneDeep | ✅ | ✅ | ✅ | ✅ 保留引用 | ✅ | 较大 |
- 循环引用:深拷贝绕不开的坑
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 是处理循环引用最省事的方式。
- 性能与选型建议
6.1 性能量级参考
不同方案的开销差异明显,下面是经验性的排序(同一数据规模下):
- 浅拷贝(展开 /
Object.assign):最快,只遍历第一层; - structuredClone:原生实现,通常快于手写递归,尤其是深层嵌套数据;
- 手写递归:受实现细节影响大,属性多、层级深时明显变慢;
- JSON 方案:需要序列化 + 反序列化两次遍历,且会丢失类型,通常不是性能最优解;
- 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或干脆改成不可变数据结构。
- 总结
- 赋值只是复制引用,两个变量指向同一对象;拷贝才会创建新对象。
- 浅拷贝(展开、
Object.assign、slice)只复制第一层,嵌套引用仍共享。 - 深拷贝方案各有取舍:JSON 方案简单但丢类型、怕循环引用;手写递归灵活但需自己处理边界;
structuredClone原生、支持循环引用与多数内置类型,是当下的首选;lodash.cloneDeep功能最全,适合复杂老项目。 - 循环引用用
WeakMap登记已克隆对象即可解决,structuredClone则天然支持。 - 性能上先问"是否真的需要深拷贝",再按数据类型选择方案,并在真实数据上实测。
理解这些差异后,再看到 const b = a 时,你会先问一句:我要的是同一个对象,还是一个副本?
知识脉络清晰、方案对比表格直观,适合前端开发者系统补齐拷贝认知。选型决策与实践建议可直接落地,尤其适合处理接口数据、树形结构与老项目迁移场景。