JS 浅拷贝实现:Object.assign 与展开运算符的局限
深入解析 JavaScript 中 Object.assign 与展开运算符实现浅拷贝的核心原理、内存表现及五大局限性,帮助开发者避开数据共享污染隐患并合理选择拷贝策略。
Object.assign() 与对象展开运算符(...)是现代前端开发中最常用的两种原生浅拷贝实现,但它们在深层数据隔离、原型继承、属性描述符维护等方面存在本质局限。本文将通过代码实例与底层内存模型,拆解两者的运作方式与隐藏风险,并提供安全合理的拷贝选型方案。
一、浅拷贝的核心定义与内存模型
在 JavaScript 中,对象的属性按数据类型分为两类:原始类型(如 number、string、boolean、null、undefined、symbol、bigint)与引用类型(如 Object、Array、Function、Date)。
浅拷贝(Shallow Copy)的核心特征可以概括为两句话:
- 对于第一层的原始类型属性:直接在栈内存中开辟新空间并复制具体数值,原对象与新对象的该属性完全独立,互不影响;
- 对于第一层的引用类型属性:仅复制保存在对象中的堆内存地址(Pointer)。因此,原对象与新对象的嵌套属性指向同一个实际内存块,任意一方对其进行深层修改,都会同步影响另一方。
我们可以通过下面的代码直观验证这一行为:
// 构造包含原始值和嵌套引用对象的源数据
const original = {
id: 101,
title: "JavaScript 浅拷贝实战",
author: {
name: "Alice",
level: "Expert"
}
};
// 使用展开运算符实现浅拷贝
const copyObj = { ...original };
// 1. 修改第一层的原始类型属性
copyObj.id = 202;
console.log(original.id); // 输出: 101 (互不影响,各自独立)
// 2. 修改第二层嵌套对象的属性
copyObj.author.name = "Bob";
console.log(original.author.name); // 输出: "Bob" (原对象被同时串改!)
为什么修改 copyObj.id 互不干扰,而修改 copyObj.author.name 却导致原对象也被改变?下图展示了这段代码在执行时底层的栈堆内存分布:
从图 1 可以清楚地看到,original 和 copyObj 在栈内存中各自拥有独立的堆地址(0x1001 与 0x1002)。但这两个堆对象内部的 author 属性,存储的均是同一个嵌套对象地址 0x3005。这就是浅拷贝导致数据意外串改的根本原因。
二、Object.assign 与展开运算符的操作步骤与细微差异
在实际编码中,实现浅拷贝最常用的语法是 Object.assign() 与 ES6 对象展开运算符 ...。虽然二者的最终效果在绝大多数日常场景中一致,但在底层规范和执行步骤上仍有明确差异。
1. Object.assign 的使用与规范行为
Object.assign(target, ...sources) 方法将所有可枚举的自有属性从一个或多个源对象复制到目标对象,并返回修改后的目标对象:
const target = { a: 1 };
const source = { b: 2, c: 3 };
const result = Object.assign(target, source);
console.log(result); // { a: 1, b: 2, c: 3 }
console.log(result === target); // true (target 本身被原地修改)
注意:如果希望获得全新独立的对象,必须将第一个参数显式传入空对象 {},即 Object.assign({}, source)。
2. 对象展开运算符(...)的简洁表达
对象字面量展开运算符不仅写法更加简洁,而且在语义上始终创建全新的字面量对象:
const source = { b: 2, c: 3 };
const clone = { ...source }; // 总是创建一个全新的字面量对象
3. 内部执行步骤与 Setter 触发差异
尽管两者表面相似,但它们将属性写入目标对象时调用的内部方法不同:
Object.assign()在写入属性时使用的是内部的[[Set]]操作符。这意味着如果目标对象或其原型链上存在同名属性的setter,该setter将被触发执行。- 展开运算符
{ ...source }使用的是内部的[[DefineOwnProperty]]操作符。它直接在新建对象上定义自身属性,完全忽略原型上的setter。
通过为目标对象定义 setter,可以清晰观察到两者的分歧:
// 目标对象原型上包含 setter
const proto = {
set val(v) {
console.log("Setter 被调用,值为:", v);
}
};
const target = Object.create(proto);
// 方式 A: Object.assign 触发了原型上的 setter
Object.assign(target, { val: 42 });
// 控制台输出: "Setter 被调用,值为: 42"
// target 自身并没有生成 val 属性,它被 setter 拦截消费了
// 方式 B: 展开运算符直接定义自有属性
const clone = { ...target, val: 42 };
// 控制台无 setter 输出,clone 自身成功拥有 val 数据属性
三、深度剖析:浅拷贝的五大核心局限
在面对复杂业务实体或状态树时,依赖浅拷贝会带来诸多边界隐患。以下系统梳理 Object.assign 与展开运算符的五大核心局限。
局限 1:无法实现多层嵌套对象与数组的真实隔离
这是浅拷贝最常引发线上故障的缺陷。在 Redux、Pinia 或 React 的状态流转中,如果直接对含有嵌套对象的状态进行展开拷贝,看似生成了新对象,但深层数据依然是同一份引用:
const state = {
user: { name: "Carol", roles: ["admin", "editor"] },
theme: "dark"
};
// 仅在顶层展开
const nextState = { ...state };
nextState.user.roles.push("guest");
// 导致原 state 内部数据被同步破坏,违背了状态不可变性(Immutability)原则
console.log(state.user.roles); // ["admin", "editor", "guest"]
局限 2:丢失原型链与继承方法
Object.assign 与展开运算符只会抓取源对象自身的属性,而完全不会保留源对象由构造函数或类实例继承来的原型链:
class UserAccount {
constructor(name) {
this.name = name;
}
sayHello() {
return `Hello, ${this.name}`;
}
}
const alice = new UserAccount("Alice");
const copyAlice = { ...alice };
console.log(copyAlice.name); // "Alice"
console.log(copyAlice instanceof UserAccount); // false (原型链已断裂)
console.log(typeof copyAlice.sayHello); // "undefined" (类方法未被继承)
局限 3:无法复制属性描述符(Getter/Setter 与元属性)
源对象如果通过 Object.defineProperty 定义了 getter 访问器属性,浅拷贝在读取该属性时会执行 [[Get]],求得当前运行时的计算值,然后将该静态值赋给新对象。原本的动态 getter/setter 逻辑就此遗失:
const pricing = {
price: 100,
taxRate: 0.1,
get total() {
return this.price * (1 + this.taxRate);
}
};
const copyPricing = { ...pricing };
console.log(copyPricing.total); // 110 (此处已被固化为静态数值)
copyPricing.price = 200;
console.log(copyPricing.total); // 依然是 110!getter 已经不存在了,不会再动态计算
局限 4:无法复制不可枚举属性
规范明确要求浅拷贝操作仅提取源对象的自有可枚举属性(enumerable: true)。如果某些核心库或数据结构通过设置 enumerable: false 来隐藏属性,这些属性在浅拷贝过程中将被完全忽略:
const secureData = { id: 1 };
Object.defineProperty(secureData, "secretKey", {
value: "SECRET_999",
enumerable: false, // 不可枚举
writable: true
});
const shallowCopy = { ...secureData };
console.log(shallowCopy.secretKey); // undefined (不可枚举属性被丢弃)
局限 5:对循环引用的处理与后续崩溃隐患
若对象内部存在自引用(即 obj.self = obj),直接执行 { ...obj } 虽然在顶层浅拷贝一步不会死循环,但新对象的 self 属性指向的仍旧是原对象,循环链并未被正确闭合为新对象的自引用;并且一旦后续开发者尝试对该对象执行 JSON.stringify(),将立刻抛出 TypeError: Converting circular structure to JSON 异常。
四、对象拷贝的技术选型与安全替代方案
了解了浅拷贝的局限之后,在日常开发中如何根据具体的数据场景选择最合适的复制手段?
1. 什么时候应该使用浅拷贝?
浅拷贝并非一无是处,相反,它是高频轻量操作的首选方案:
- 扁平数据配置合并:函数入参与默认配置项对象的合并(如
options = { ...defaultOptions, ...customOptions }); - 单层不可变状态更新:在 React 状态管理中,若数据仅有一层(如
setState(prev => ({ ...prev, count: prev.count + 1 }))),浅拷贝不仅简单直观,而且运行性能最高,无需深层递归的开销。
2. 遇到嵌套数据时:优先选择原生 structuredClone()
在现代浏览器和 Node.js 17+ 环境中,ECMAScript 宿主环境正式标准化了 structuredClone() 原生深拷贝 API。它基于 HTML 规范的结构化克隆算法(Structured Clone Algorithm),相比老旧的 JSON.parse(JSON.stringify(obj)) 拥有压倒性优势:
- 支持循环引用:天然追踪已克隆对象的引用拓扑,不会产生栈溢出;
- 原生支持常见内置类型:完美保留
Date、RegExp、Map、Set、ArrayBuffer等; - 高性能:由底层 C++ 引擎直接执行,性能远胜手写递归。
// structuredClone 原生深拷贝使用示例
const deepSource = {
name: "Project",
tags: new Set(["frontend", "javascript"]),
created: new Date("2026-01-01"),
nested: { count: 10 }
};
// 创建自循环引用
deepSource.self = deepSource;
// 执行原生深拷贝
const deepClone = structuredClone(deepSource);
deepClone.nested.count = 99;
console.log(deepSource.nested.count); // 10 (完全深度隔离!)
console.log(deepClone.tags instanceof Set); // true (保留 Set 实例)
console.log(deepClone.self === deepClone); // true (自引用正确封闭在新对象自身)
structuredClone() 不支持克隆包含函数(Function)、DOM 节点以及 Symbol 键的对象,若传入这些类型会直接抛出 DataCloneError。如果业务对象中不可避免地挂载了方法或自定义类原型,应选用 lodash.cloneDeep 或手写带原型保持的深拷贝函数。
3. 方案对比总结表
| 拷贝方式 | 拷贝深度 | 循环引用支持 | 特殊类型(Date/Map等) | 原型链保留 | 执行开销 |
|---|---|---|---|---|---|
| 展开运算符 / Object.assign | 仅浅层(1 层) | 无深层处理 | 直接复用指针 | 否(Object.prototype) | 极低(纳秒级) |
| JSON.parse / stringify | 深层 | 报错崩溃 | 丢失(Date变字符串,Set丢失) | 否 | 中等 |
| structuredClone() | 深层 | 原生完美支持 | 原生完美支持(函数除外) | 否 | 较低(原生实现) |
| Lodash cloneDeep | 深层 | 支持 | 全面支持 | 可通过扩展支持 | 高(需载入库文件) |
五、结语
Object.assign 与展开运算符是 JavaScript 语法中优雅而高频的特性,其本质是对对象自身可枚举属性的浅层取值与赋值。理解它们在深层引用、原型链及属性描述符上的局限,是写出健壮前端代码的关键基本功。
在面对单层扁平对象与简单配置合并时,坚决使用展开运算符保持代码简洁;当对象涉及复杂嵌套、状态传递或数据树持久化时,明确意识到浅拷贝的边界,果断选用 structuredClone() 或专业深拷贝工具,方能从根本上消除数据引用共享带来的副作用隐患。
JavaScript异步发送HTTP请求时怎么优雅地处理错误
2026-11-05 17:10:44
5款主流JS在线运行工具对比:CodePen、JSFiddle与StackBlitz怎么选
2026-11-05 17:04:04
JavaScript入门书籍避坑:这些书不适合初学者
2026-11-05 16:31:15
JS使用Math.round实现保留两位小数的原理与代码
2026-11-05 15:35:55
JavaScript 原生 Date 对象怎么实现日期加减
2026-11-05 14:30:00
相关标签:








