我复制了一个对象去改副本,结果原对象也跟着被改了、数据莫名其妙被污染,我对着 JavaScript 的浅拷贝和引用共享排查了大半天的复盘
那是我用 JavaScript 写的一段表单逻辑。我有一份"原始数据",想在不动原数据的前提下,复制一份出来、在副本上做修改(比如做"编辑预览")。我用了看起来很标准的复制方式(展开运算符 ...)。结果诡异的事情发生了:我明明只改了副本,可"原始数据"竟然也跟着变了!导致页面上"原始"和"修改后"显示成了一样的,数据莫名其妙被污染。我盯着代码反复看:我都已经 {...原对象} 复制一份了,改副本怎么会影响原对象?排查了大半天,我才真正理解了 JavaScript 的两个核心概念:引用类型 和 浅拷贝。这篇就把这场"改副本污染了原对象"的事故,从头复盘一遍。
故障现场:只改了副本,原对象却也变了
先看现场。问题就藏在"浅拷贝只复制了一层"这个细节里:
const original = {
name: "张三",
address: { city: "北京", street: "长安街" }, // ← 嵌套对象
tags: ["vip", "old"], // ← 嵌套数组
};
// 我用展开运算符"复制"一份(以为是完整副本)
const copy = { ...original }; // 浅拷贝(只复制一层!)
// 改副本的"顶层"属性: 没问题, 原对象不受影响
copy.name = "李四";
console.log(original.name); // "张三" ✓ 原对象没变(顶层是值复制)
// ✗✗ 改副本的"嵌套对象"属性: 原对象也变了!
copy.address.city = "上海";
console.log(original.address.city); // "上海" ✗✗ 原对象也被改了!
copy.tags.push("new");
console.log(original.tags); // ["vip","old","new"] ✗✗ 原对象也变了!
// 为什么? 因为 {...original} 是"浅拷贝":
// 1. JS 的对象/数组是"引用类型": 变量存的不是对象本身, 而是指向对象的"引用(地址)"。
// 2. 浅拷贝({...} / Object.assign): 只复制"第一层"。
// - 顶层的"值类型"属性(name字符串): 复制了一份新的值 → 改副本不影响原。
// - 顶层的"引用类型"属性(address对象、tags数组): 只复制了"引用(地址)"!
// → copy.address 和 original.address 【指向同一个对象】!
// → 改 copy.address.city, 改的就是那个"两者共享的同一个对象" → 原对象也变。
// 3. 所以: 浅拷贝后, 嵌套的对象/数组, 副本和原对象还是"共享"的。
// 现象拼图:
// - JS 对象是引用类型, 变量存的是"引用", 赋值/复制默认复制的是"引用"。
// - 浅拷贝只复制第一层: 顶层值类型独立了, 但嵌套的引用类型还是共享的。
// - 改副本的嵌套属性 = 改那个"共享的对象" = 原对象也被改。
// - ★ 根因: 我以为 {...original} 是"完整的、独立的副本", 但它只是浅拷贝,
// 嵌套的对象/数组依然和原对象共享同一份。
看清真相后,我恍然大悟。问题的根源,是 JavaScript 的对象/数组是"引用类型",而 {...original} 只是"浅拷贝"。JS 的对象/数组是引用类型:变量存的不是对象本身,而是指向对象的"引用(地址)"。而浅拷贝({...}/Object.assign)只复制第一层:顶层的值类型属性(name 字符串)复制了新值(改副本不影响原),但顶层的引用类型属性(address 对象、tags 数组)只复制了"引用(地址)"——copy.address 和 original.address 指向同一个对象。所以,改 copy.address.city,改的就是那个"两者共享的同一个对象",原对象自然也变了。根因是:我以为 {...original} 是"完整的、独立的副本",但它只是浅拷贝,嵌套的对象/数组依然和原对象共享同一份。
第一件事:搞懂值类型、引用类型与深浅拷贝
要解决它,得先彻底搞懂 JS 的值类型、引用类型,以及深拷贝和浅拷贝的区别。
值类型 vs 引用类型, 深拷贝 vs 浅拷贝
# 一、值类型 vs 引用类型(JS数据类型的根本区分)
# - 值类型(原始类型): number, string, boolean, null, undefined, symbol, bigint
# * 变量直接存"值本身"。赋值/传参 = 复制一份新的值。
# * a = 1; b = a; b = 2; → a 还是 1(b是a的值的副本, 互不影响)。
# - 引用类型: object, array, function(本质都是对象)
# * 变量存的是"指向对象的引用(地址)", 不是对象本身。
# * 赋值/传参 = 复制"引用(地址)" → 两个变量指向【同一个对象】!
# * a = {x:1}; b = a; b.x = 2; → a.x 也变成2(b和a指向同一对象)!
# 二、浅拷贝(shallow copy): 只复制"第一层"
# - {...obj} / Object.assign({}, obj) / arr.slice() / [...arr]
# - 第一层的值类型属性: 复制了新值(独立)。
# - 第一层的引用类型属性: 只复制了引用 → 嵌套对象/数组还是共享的!
# - 所以: 改副本的嵌套属性 → 原对象也变(本文的坑)。
# 三、深拷贝(deep copy): 递归复制"所有层"
# - 把对象及其所有嵌套的对象/数组, 都【完整地复制一份新的】。
# - 副本和原对象【完全独立】, 改任何一层都互不影响。
# - 代价: 比浅拷贝慢(要递归复制所有层)、占更多内存。
# 四、什么时候用哪个?
# - 只有第一层、或只改顶层属性: 浅拷贝够用(快)。
# - 有嵌套、且要改嵌套属性又不想影响原对象: 必须深拷贝。
# - 不确定: 看你"会不会改到嵌套属性"——会改就深拷贝。
# 核心: JS引用类型变量存的是引用、复制默认复制引用(指向同一对象); 浅拷贝只复制第一层
# (嵌套对象/数组仍共享)、深拷贝递归复制所有层(完全独立); 要改嵌套又不影响原就用深拷贝。
想透值类型、引用类型和深浅拷贝,这个坑就清楚了。一、值类型 vs 引用类型(JS 数据类型的根本区分):值类型(number/string/boolean/null/undefined/symbol/bigint)变量直接存值本身,赋值=复制新值、互不影响;引用类型(object/array/function)变量存的是指向对象的引用,赋值=复制引用、两个变量指向同一个对象(改一个另一个也变)。二、浅拷贝:只复制第一层——{...obj}/Object.assign/slice/[...arr],第一层值类型复制新值(独立),但第一层引用类型只复制引用(嵌套对象/数组还共享),所以改副本的嵌套属性原对象也变(本文的坑)。三、深拷贝:递归复制所有层——把对象及其所有嵌套都完整复制一份新的,副本和原对象完全独立、改任何一层都互不影响;代价是比浅拷贝慢、占更多内存。四、什么时候用哪个?——只改顶层属性用浅拷贝(快);有嵌套且要改嵌套属性又不想影响原对象必须深拷贝;不确定就看"会不会改到嵌套属性"。
第二件事:正解——按需深拷贝,或不可变更新
搞懂了原理,正解就清晰了:要改嵌套又不影响原对象就深拷贝(structuredClone 首选);或用"不可变更新"创建新对象而非改原对象。
const original = {
name: "张三",
address: { city: "北京", street: "长安街" },
tags: ["vip", "old"],
};
// ====== 正解一(推荐, 现代浏览器/Node17+): structuredClone 深拷贝 ======
const copy = structuredClone(original); // ✓ 内置的深拷贝, 完整独立副本
copy.address.city = "上海";
console.log(original.address.city); // "北京" ✓ 原对象不受影响!
// → structuredClone 是浏览器/Node内置的深拷贝API, 首选(能处理嵌套、循环引用、
// Date/Map/Set等; 但不能拷贝函数)。
// ====== 正解二: JSON 深拷贝(简单但有局限)======
const copy2 = JSON.parse(JSON.stringify(original)); // 深拷贝
// ✓ 简单, 但有坑: 丢失 undefined/函数/Symbol, Date变字符串, 不能处理循环引用!
// → 数据是"纯JSON能表示的"(对象/数组/字符串/数字/布尔/null)时可用, 否则别用。
// ====== 正解三: 用库的深拷贝(复杂场景)======
// lodash 的 cloneDeep(处理各种边界, 老项目常用):
// import { cloneDeep } from "lodash";
// const copy3 = cloneDeep(original);
// ====== 正解四(推荐思路): 不可变更新 —— 不改原对象, 而是创建新对象 ======
// 与其"拷贝后改副本", 不如"基于原对象创建一个改了某处的新对象"(函数式风格):
const updated = {
...original, // 复制顶层
address: { ...original.address, city: "上海" }, // 嵌套的也展开+改
};
console.log(original.address.city); // "北京" ✓ 原对象完全没动!
// → 这是 React/Redux 等推崇的"不可变更新": 永不修改原数据, 总是创建新数据。
// 好处: 原数据始终不变(可预测、易追踪变化、利于状态管理)。
// ====== 正解五: 数组的不可变操作 ======
// ✗ 会改原数组的: push/pop/splice/sort/reverse(原地修改!)
// ✓ 返回新数组不改原的: map/filter/slice/concat/[...arr, x]
const newTags = [...original.tags, "new"]; // ✓ 新数组, 不改原
const sorted = [...original.tags].sort(); // ✓ 先复制再sort, 不改原
// (注意: sort 会原地改, 想不改原数组要先 [...arr] 复制一份)
// 核心: 要改嵌套又不影响原对象, 用 structuredClone 深拷贝(首选)/JSON深拷贝(有局限)/
// lodash cloneDeep; 更推荐"不可变更新"(创建新对象而非改原); 数组用map/filter/展开不改原。
修复的核心,是"要么真正深拷贝(完整独立),要么用不可变更新(不改原对象)"。正解一(推荐):structuredClone 深拷贝——浏览器/Node17+ 内置的深拷贝 API,能处理嵌套、循环引用、Date/Map/Set,首选(但不能拷贝函数)。正解二:JSON 深拷贝(JSON.parse(JSON.stringify()))——简单但有局限:丢失 undefined/函数/Symbol、Date 变字符串、不能处理循环引用,数据是纯 JSON 时可用。正解三:用库的深拷贝(lodash cloneDeep,复杂场景)。正解四(推荐思路):不可变更新——不改原对象,而是基于原对象创建一个改了某处的新对象(嵌套的也要逐层展开 { ...original.address, city: "上海" });这是 React/Redux 推崇的方式,原数据始终不变、可预测、易追踪。正解五:数组的不可变操作——会改原数组的(push/splice/sort/reverse 原地修改)、返回新数组不改原的(map/filter/slice/concat/[...arr, x]);想不改原数组排序要先 [...arr] 复制。归根结底:要改嵌套又不影响原对象用 structuredClone(首选)/JSON(有局限)/cloneDeep;更推荐不可变更新(创建新对象);数组用 map/filter/展开不改原。
第三件事:引用共享导致的其他常见坑
排查后我意识到,"引用共享"这个坑远不止浅拷贝一处,它在很多地方都会出现。我系统梳理了一遍。
引用共享导致的常见坑
# 1. 浅拷贝后改嵌套(本文): copy=={...orig}, 改copy.nested影响orig。
# 2. 直接赋值就以为是复制:
# const b = a; // a是对象 → b和a是同一个对象! 改b就是改a。
# → 要副本必须拷贝(浅/深), 不能直接赋值。
# 3. 函数参数传对象 = 传引用, 函数内改参数会影响外面:
# function f(obj) { obj.x = 1; } // 改的是外面传进来的那个对象!
# f(myObj); // myObj.x 被改了!
# → 不想影响外面: 函数内先拷贝, 或用不可变方式。
# 4. 多个变量/数据结构共享同一个对象:
# const shared = {x:1};
# const list = [shared, shared]; // list[0]和list[1]是同一个对象!
# list[0].x = 2; // list[1].x 也变成2!
# 5. 数组的 fill 用对象填充:
# Array(3).fill({}); // 3个元素是【同一个】对象! 改一个全变!
# → 要独立对象: Array.from({length:3}, () => ({}))
# 6. 默认参数/状态用了共享的对象(类似Python可变默认参数):
# 多个实例共享了同一个默认对象 → 一个改了影响所有。
# 共同根源: JS对象是引用类型, 凡是"看起来在复制/传递对象"的地方, 默认传的都是
# 引用(共享同一对象), 而非独立副本; 不警觉就会"改一处、影响多处"。
# 核心: 引用共享的坑无处不在(浅拷贝/直接赋值/传参/共享对象/fill对象);根源是JS对象是引用类型、
# 默认传引用而非副本; 凡"复制/传递对象"处都要警觉是否共享, 要独立副本就显式拷贝。
排查让我看到,"引用共享"的坑远不止浅拷贝一处。除了浅拷贝改嵌套(本文),还有:直接赋值就以为是复制(const b = a 是同一个对象)、函数参数传对象是传引用(函数内改参数影响外面)、多个变量/数据结构共享同一对象([shared, shared] 是同一个)、Array(3).fill({}) 三个元素是同一个对象、默认参数用了共享对象(类似 Python 可变默认参数)。它们的共同根源是:JS 对象是引用类型,凡是"看起来在复制/传递对象"的地方,默认传的都是引用(共享同一对象),而非独立副本;不警觉就会"改一处、影响多处"。归根结底:引用共享的坑无处不在;根源是 JS 对象是引用类型、默认传引用而非副本;凡"复制/传递对象"处都要警觉是否共享,要独立副本就显式拷贝。下面这张图,是这次改副本污染原对象的成因与解法:
第四件事:几种拷贝方式对比速查
这次踩坑后,我把 JS 几种"拷贝对象"的方式整理成一张表,需要复制时对照着选。
| 方式 | 深/浅 | 能拷嵌套吗 | 局限 |
|---|---|---|---|
| const b = a | 不拷贝(共享引用) | — | 根本没复制,是同一个对象 |
| {...a} / Object.assign | 浅拷贝 | ✗ 嵌套共享 | 只复制第一层 |
| [...arr] / arr.slice() | 浅拷贝(数组) | ✗ 元素若是对象则共享 | 只复制第一层 |
| structuredClone(a) | 深拷贝 | ✓ 完整 | 不能拷函数;需较新环境 |
| JSON.parse(JSON.stringify) | 深拷贝 | ✓ 但有损 | 丢undefined/函数,Date变字符串,循环引用报错 |
| lodash cloneDeep | 深拷贝 | ✓ 完整 | 需引库 |
这张表,把各种"拷贝"方式的深浅和局限摆清了。关键认知:const b = a 根本没复制(是同一对象)、{...} 是浅拷贝(嵌套共享)、要完整独立副本用 structuredClone(首选)或 cloneDeep、JSON 法有损要小心。它给我的启发是:"复制一个对象"这件看似简单的事,在 JS 里竟有这么多种方式、且"复制的程度"(没复制/浅/深)各不相同;而它们最大的区别——"嵌套的部分是共享还是独立"——恰恰是肉眼在代码里看不出来的(都长得像"复制了"),只有运行时改动数据才会暴露。它给我的最大启发是:JS 的"引用类型"机制,让"复制"和"共享"的界线变得微妙而隐蔽;而很多 bug,正源于"我以为我复制了一份独立的,实际却共享着同一份"这种对"独立 vs 共享"的误判。这让我养成一个习惯:每当我"复制"一个对象/数组、并打算修改副本时,都会停下来确认一句:"我这个复制,真的让副本和原对象完全独立了吗?嵌套的部分还共享吗?"——想清楚"独立到了哪一层",才能避开这类隐蔽的引用共享 bug。
第五件事:不可变(immutable)思维的价值
这次事故让我体会到"不可变更新"思维的好处。我把可变和不可变两种风格对比了一下。
| 维度 | 可变(直接改对象) | 不可变(创建新对象) |
|---|---|---|
| 引用共享坑 | 易踩(改了影响别处) | 避开(原对象永不变) |
| 追踪变化 | 难(对象被谁改了不知道) | 易(新旧对象比较即可) |
| 状态管理 | 状态变化难预测 | 可预测(React/Redux 依赖) |
| 并发安全 | 共享可变状态易竞态 | 不可变天然更安全 |
| 性能/内存 | 原地改省内存 | 创建新对象有开销 |
| 适用 | 局部、性能敏感 | 状态管理、可预测性优先 |
这张表,让我看到了"不可变更新"的价值。核心是:不可变(永不修改原对象,总是创建新对象)虽然有创建新对象的开销,但它从根上避开了引用共享的坑、让变化可追踪、状态可预测、并发更安全。它给我的最大启发是:这次踩的"引用共享"坑,根子在于"可变(mutation)"——我去"修改"了一个被共享的对象,才导致了意外的连锁影响;而如果我遵循"不可变"原则(永不修改、总是创建新的),这个坑就从源头上不会发生。这呼应了现代前端(React 的不可变状态)、函数式编程、乃至 Rust(默认不可变)等越来越拥抱的一个理念:"共享可变状态(shared mutable state)"是大量 bug(引用污染、竞态条件、难以追踪的变化)的万恶之源;而对抗它的有力武器,就是"不可变性"——让数据一旦创建就不再改变,需要变化时就创建新的数据。这让我领悟到一个提升代码健壮性的思维转变:默认倾向于"不可变"——把数据当成"只读的",需要"修改"时就基于它创建一份新的、改过的数据,而不是去"原地修改"它;这种思维,能从源头上消除一大类由"共享 + 可变"引发的、最隐蔽难查的 bug。
第六件事:复制/传递对象时,我现在的判断习惯
现在每当我要复制或传递一个对象,我都会按这张图先想清楚"要不要独立、独立到几层":
这张图的精髓,是"复制/传对象前,先想清楚要不要独立、独立到几层"。第一问 "要不要独立的副本":不需要(就是要共享/只读)直接用但别误改;需要独立再看会不会改嵌套。需要独立时:只改顶层用浅拷贝({...})够用;会改嵌套必须深拷贝(纯 JSON 用 structuredClone/JSON,有函数/Date/复杂用 structuredClone/cloneDeep);或干脆用不可变更新创建新对象。传函数参数时还要警觉函数会不会改它。最后改完验证原对象真的没被影响。这套习惯,让我处理对象时,从"展开一下就以为是独立副本"变成了"想清楚独立到几层、并验证原对象没被污染"——核心始终是:JS 对象是引用类型,复制默认是浅的/共享的;要改嵌套又不影响原对象,必须深拷贝或不可变更新。
我立下的几条规矩
这场"改副本污染原对象"的事故,换来了我写 JavaScript 时,刻进骨子里的几条铁律:
- JS 对象/数组是引用类型。变量存的是引用,赋值/传参默认复制引用(共享同一对象)。
- {...} 和 Object.assign 是浅拷贝。只复制第一层,嵌套对象/数组仍和原对象共享。
- 要改嵌套又不影响原对象,必须深拷贝。structuredClone 首选,或 lodash cloneDeep。
- JSON 深拷贝有损。丢 undefined/函数、Date 变字符串、循环引用报错,纯 JSON 才用。
- 优先用不可变更新。不改原对象、创建新对象,从根上避开引用共享坑。
- 数组分清改原和不改原的方法。push/splice/sort 改原,map/filter/展开不改原。
- 传对象给函数,警觉它会不会改你的对象。不想被改就传副本或用不可变。
附:一段亲眼看清浅拷贝/深拷贝差别的实验
口说无凭。下面这段代码,把直接赋值、浅拷贝、深拷贝对"嵌套属性"的不同影响,一一跑出来:
const original = {
name: "张三",
address: { city: "北京" },
tags: ["a", "b"],
};
// ====== 1. 直接赋值: 根本没复制(同一对象)======
const assigned = original;
assigned.name = "改了";
console.log("直接赋值后 original.name:", original.name); // "改了" (同一对象!)
original.name = "张三"; // 改回去
// ====== 2. 浅拷贝: 顶层独立, 嵌套共享 ======
const shallow = { ...original };
shallow.name = "李四"; // 改顶层
console.log("浅拷贝改顶层 original.name:", original.name); // "张三" ✓ 不受影响
shallow.address.city = "上海"; // 改嵌套
console.log("浅拷贝改嵌套 original.address.city:", original.address.city);
// "上海" ✗ 原对象的嵌套也变了! (嵌套共享)
original.address.city = "北京"; // 改回去
// ====== 3. 深拷贝(structuredClone): 完全独立 ======
const deep = structuredClone(original);
deep.address.city = "广州"; // 改嵌套
console.log("深拷贝改嵌套 original.address.city:", original.address.city);
// "北京" ✓ 原对象完全不受影响! (完全独立)
// ====== 4. 验证"是不是同一个对象"(引用相等)======
console.log("浅拷贝 address 是同一个吗:", shallow.address === original.address);
// true ← 浅拷贝: 嵌套对象是同一个(共享引用)
console.log("深拷贝 address 是同一个吗:", deep.address === original.address);
// false ← 深拷贝: 嵌套对象是不同的(独立)
/* 输出:
直接赋值后 original.name: 改了 ← 没复制, 同一对象
浅拷贝改顶层 original.name: 张三 ← 顶层独立 ✓
浅拷贝改嵌套 original.address.city: 上海 ← 嵌套共享! ✗(本文的坑)
深拷贝改嵌套 original.address.city: 北京 ← 完全独立 ✓
浅拷贝 address 是同一个吗: true ← 浅拷贝嵌套共享
深拷贝 address 是同一个吗: false ← 深拷贝嵌套独立
*/
// 核心: 直接赋值=同一对象; 浅拷贝顶层独立但嵌套共享(===为true); 深拷贝完全独立(===为false);
// 用 === 验证"是不是同一个对象", 跑一遍, 三种复制的差别一目了然。
这段实验代码,把"浅拷贝和深拷贝到底差在哪"变成了可以亲眼对比的输出。它用同样的"改嵌套属性"操作,试了三种复制方式,输出清清楚楚地显示:直接赋值是同一对象(改了 original 也变)、浅拷贝顶层独立但嵌套共享(改 address.city 原对象也变,正是本文的坑)、深拷贝完全独立(改任何层原对象都不变)。尤其最后那个用 === 验证"是不是同一个对象"的对比——浅拷贝的 shallow.address === original.address 是 true(嵌套共享同一个),深拷贝的是 false(嵌套是不同的)——这一个布尔值,直接、确凿地揭示了"共享还是独立"这个肉眼在代码里看不出来的关键区别。这,正是我想用这段代码,留给每个 JS 开发者的最后一课:对于"引用共享 vs 独立副本"这种"看不见、却决定行为"的关系,有一个强大的"检测工具"——用 === 判断两个变量"是不是指向同一个对象"。当你对"这两个东西到底是共享还是独立"拿不准时,一个 a === b,就能让那根"看不见的引用"现出原形(true=同一个/共享,false=不同/独立)。这也再次印证了我整个系列复盘的核心方法论:对那些抽象的、看不见的、容易混淆的概念(引用共享、深浅拷贝),与其在脑子里苦苦推演,不如写个小实验、用一个能"显形"的探针(这里是 ===),把它的真实状态直接跑给你看。让看不见的变得看得见——这是理解一切隐蔽机制最可靠、也最朴素的办法。
写在最后
回头看,这场由"浅拷贝/引用共享"引发的、改副本污染原对象的事故,真正教给我的,远不止"用 structuredClone 深拷贝"这一个技巧。它让我对"看不见的连接"有了一次深刻的体会。我栽跟头,是因为我看着两个不同的变量名(original 和 copy),就想当然地以为它们是"两个独立的、互不相干的东西"。可实际上,在浅拷贝之后,它们的嵌套部分,还通过一根"看不见的引用",紧紧地连在同一个对象上——我以为我在动 copy,实际上我动的是那个"它俩共享的"对象,于是 original 也跟着动了。这让我领悟到一个理解程序(乃至理解系统)的深刻视角:"变量名/标识符"只是我们给事物贴的"标签",两个不同的标签,完全可能指向同一个东西;而"它们看起来是分开的"(不同的名字),不代表"它们实际上是独立的"(可能共享同一个底层对象)。程序里这种"表面分离、实则相连"的"隐藏的引用关系",正是很多"诡异的、牵一发动全身"的 bug 的根源(改了 A,B 却莫名其妙变了)。所以,这件事给我的最大启发是:当遇到"我改了这里、那里却也变了"这种诡异现象时,要立刻想到"它们之间是不是有一根我没注意到的、共享的引用";而要写出可靠的代码,就要对"什么时候是共享引用、什么时候是独立副本"始终保持清醒,在需要独立时,确保真正地切断了那根共享的引用(深拷贝/不可变)。看清那些"看不见的连接"、分清"共享"与"独立"——这,是我用一次"改副本污染原对象"的事故,换来的、关于 JavaScript、也关于"引用与共享"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次复制对象去改副本时,先想想"嵌套的部分还和原对象共享吗",那我对着那个被莫名污染的原对象熬的这大半天,就值了。
—— 别看了 · 2026