JS数组直接赋值为什么不是复制?理解引用与拷贝的区别
深入解析JavaScript中数组直接赋值为什么不是复制,详细拆解栈内存指针与堆内存对象模型,对比浅拷贝与深拷贝的实现方式与适用边界。
let b = a;),并没有发生真正的数据复制,而仅仅是将该数组在内存中的“引用地址”(Reference Pointer)复制了一份。JavaScript 的数组属于复合引用类型,其实体数据动态存储在堆内存(Heap)中,变量自身仅在栈内存(Stack)中保存指向该堆地址的指针。直接赋值让两个变量持有完全相同的物理地址,本质上如同给同一套房产配了两把钥匙。要获得彼此独立、修改互不连带影响的新数组,必须显式地进行浅拷贝或深拷贝。
一、现象还原:直接赋值引发的“连带污染”
初学者在处理数组时,常常会遇到修改新变量却意外破坏了原有数据的 Bug。请看下面这段极具代表性的场景:
// 声明一个待处理的水果列表
let fruitsOriginal = ["苹果", "香蕉", "橙子"];
// 意图:制作一份副本来进行操作,避免修改原数据
let fruitsCopy = fruitsOriginal;
// 向“副本”中追加一个新元素
fruitsCopy.push("葡萄");
// 查看运行结果
console.log("fruitsCopy:", fruitsCopy);
// 输出: ["苹果", "香蕉", "橙子", "葡萄"]
console.log("fruitsOriginal:", fruitsOriginal);
// 意外输出: ["苹果", "香蕉", "橙子", "葡萄"](原数组被隐蔽篡改!)
这种现象在基本数据类型(如 Number、String、Boolean)中是绝不会发生的。当执行 let y = x; 且 x = 100 时,y 在栈内存中开辟了完全独立的存储槽,后续修改 y = 200,x 仍然稳固保持 100。为什么数组与对象会有截然不同的表现?关键就在于 JavaScript 引擎底层的内存模型设计。
二、底层模型剖析:栈内存与堆内存的运作原理
现代 JavaScript 运行环境(如 V8、JavaScriptCore)将运行期内存划分为主要的两大区域:
- 栈内存(Stack Memory):用于存放执行上下文中的局部变量标识符和固定大小的基本类型值。它的存取速度极快,由系统自动管理生命周期。
- 堆内存(Heap Memory):用于存放结构复杂、大小动态可变的对象(包括 Object、Array、Function、Map 等)。堆内存空间大,但分配与垃圾回收开销相对较高。
数组的大小是不可预知的(可以随时调用 push、pop 动态扩缩容),因此 JavaScript 引擎将具体的元素队列存放在堆内存中,并在栈中为变量只分配一个固定大小(通常是 8 字节)的内存地址指针(如 0x004F2A)。
如图 1 所示,当执行 fruitsCopy = fruitsOriginal 时,JavaScript 引擎做的动作极其简单:把 fruitsOriginal 在栈中的 8 字节指针值直接复制给 fruitsCopy。堆内存中自始至终只存在一个数组实例。无论通过哪个变量名调用变异方法(Mutating Methods,如 splice、sort、push),修改的都是 0x004F2A 这块唯一的物理内存。
fruitsOriginal === fruitsCopy 返回 true,这并不是因为两个数组的内容碰巧一致,而是因为 === 比较的是它们在栈中的引用指针是否完全相同。
三、实现真正的一维复制:浅拷贝(Shallow Copy)
如果希望得到一个内容相同、但拥有独立内存地址的新数组,最直接的方式是使用浅拷贝。浅拷贝会在堆内存中开辟一个全新的数组空间,并将原数组的第一层所有元素逐一复制进去。
1. ES6 扩展运算符(Spread Syntax,最推荐)
const source = [1, 2, 3];
const shallowCopy = [...source];
shallowCopy.push(4);
console.log(source); // 输出: [1, 2, 3](原数组受到完整保护)
console.log(shallowCopy); // 输出: [1, 2, 3, 4]
console.log(source === shallowCopy); // false(指向不同的堆地址)
2. slice() 方法(经典兼容方案)
const source = ["A", "B", "C"];
const shallowCopy = source.slice(); // 不传参数截取全量
3. Array.from() 方法
const source = [10, 20, 30];
const shallowCopy = Array.from(source);
浅拷贝的关键局限:无法隔离嵌套对象
浅拷贝之所以被称为“浅”,是因为它只拷贝最外层的一维结构。如果数组元素中包含对象、子数组等引用类型,浅拷贝复制进新数组的仍然是那些子对象的引用指针:
const users = [{ id: 1, name: "张三" }, { id: 2, name: "李四" }];
const clonedUsers = [...users];
// 增删顶层元素:互不干扰
clonedUsers.push({ id: 3, name: "王五" });
console.log(users.length); // 依然是 2
// 修改已有元素的属性:发生渗透污染!
clonedUsers[0].name = "张三丰";
console.log(users[0].name); // 输出: "张三丰"(原数据被修改)
四、嵌套隔离方案:深拷贝(Deep Copy)的可靠途径与边界
当数组内包含对象、多维数组等复合数据,且业务需要保证完全的数据安全性时,必须采用深拷贝。深拷贝会递归遍历数据树中的每一个节点,将所有的层级全部重新实例化。
1. 现代化官方标准:structuredClone()
从 ECMAScript 规范及 HTML Living Standard 开始,structuredClone() 已成为现代浏览器(Chrome 98+、Safari 15.4+、Firefox 94+)和 Node.js(v17.0.0+)的原生全局方法。它是目前处理多维数据深拷贝的最优解:
const complexArray = [
{ id: 101, meta: { role: "admin" } },
[1, 2, [3, 4]],
new Date(),
new Set([10, 20])
];
// 原生执行深拷贝
const deepSafe = structuredClone(complexArray);
// 深层修改完全隔离
deepSafe[0].meta.role = "guest";
console.log(complexArray[0].meta.role); // 输出: "admin"(毫发无损)
- 能够完美克隆
Date、RegExp、Map、Set、ArrayBuffer等内建类型。 - 能够处理循环引用(Circular References)而不会导致栈溢出。
- 限制条件:无法克隆函数(Function)、DOM 节点以及 Symbol 属性,遇到这类值会抛出
DataCloneError异常。
2. 传统方案的缺陷警告:JSON 序列化反序列化
很多工程项目中曾广泛使用 JSON.parse(JSON.stringify(arr))。虽然对纯粹的 JSON 字典数据有效,但在工业级代码中需要特别警惕以下隐式静默数据丢失:
- 数组内的
undefined、Symbol或函数会被静默转换为null或直接被丢弃。 Date对象会被转换成普通的 ISO 字符串,丢失Date实例原型方法。NaN和Infinity会被强制变更为null。- 如果数组内存在相互引用,会直接抛出
Uncaught TypeError: Converting circular structure to JSON崩溃报错。
五、实操选型:四步排查与验证推导流程
在日常业务开发中,并不是所有场景都要无脑使用深拷贝。无节制使用深拷贝会带来不必要的内存分配与垃圾回收损耗。通过以下逻辑链路可以快速做出合理选型:
按照图 3 所示的决策路径,选型步骤分解如下:
- 步骤一(识别结构维度):排查待操作的数组是扁平的基元集合(纯数字、字符串、布尔值)还是包含引用类型的多层复合集合。纯基元数组毫不犹豫选择
[...arr]。 - 步骤二(识别特殊成员):若包含嵌套对象,检查是否包含函数等非结构化数据。若无特殊函数,优先调用原生
structuredClone(arr)。 - 步骤三(验证与断言):编写针对性的单元测试断言,验证顶层与深层操作对原数组无副作用。
六、高频场景与工程避坑法则
1. 现代前端框架(React / Vue 3)中的不可变状态
在 React 中,组件的重渲染依赖于 Object.is 比较前后 State 的引用地址:
// ❌ 严重错误写法:直接修改原数组
items.push("新数据");
setItems(items);
// React 比较发现前后 items 的指针地址完全一致,判定无变化,组件拒绝更新视图!
// ✔ 正确标准写法:生成新引用
setItems([...items, "新数据"]);
2. Object.freeze() 的局限:它同样只是浅冻结
为防止数组被随意修改,可以使用 Object.freeze(arr)。但必须注意,Object.freeze 同样只有一维防御效果:
const list = Object.freeze([{ status: "pending" }]);
// 试图 push 会在严格模式下报错
// list.push(2); // TypeError: Cannot add property...
// 但内部对象的属性依然能够被篡改!
list[0].status = "success";
console.log(list[0].status); // 输出: "success"
结语
“直接赋值不是复制”是每一位 JavaScript 开发者走向规范工程化编码的必修课。理解这点的核心,在于跳出语法表象,透视栈内存存储地址指针、堆内存维护数据实体的底层结构。在日常编码中:对于一维纯数据,熟练使用 [...arr] 保持代码简洁与高性能;对于多维复杂嵌套结构,坚定拥抱原生的 structuredClone(),彻底终结引用污染引发的隐蔽隐患。
JS大数值转字符串出现科学计数法怎么办
2026-11-05 17:09:21
JS split切割后出现空字符串怎么过滤
2026-11-05 16:00:30
在 React 或 Vue 项目中封装使用 JS 节流防抖
2026-11-05 15:27:29
JavaScript中怎么将带小数的字符串转为整数?
2026-11-05 14:32:38
怎么用JavaScript策略模式重构电商促销计算逻辑?
2026-11-05 14:01:09
相关标签:







![JS创建二维数组:为什么Array(n).fill([])会导致数据联动](https://img.javascript.cn/tit/articles/13236/20261102/f618c991891ff37efb12df6af2ce0ccb0da8779a907fe1d2f7275d60c84fdd77.webp)
