现代 JavaScript 教程:对象引用与复制(浅拷贝 / 深拷贝完整指南)
现代 JavaScript 教程:对象引用与复制(浅拷贝 / 深拷贝完整指南)
对象与原始类型(字符串、数字、布尔值等)之间存在一个根本区别:原始类型总是"作为一个整体"被复制,而对象是"通过引用"被存储和复制的。理解这一区别,是掌握 JavaScript 中对象赋值、函数传参、克隆与合并的前提。本文以《现代 JavaScript 教程》(zh.javascript.info)中 对象引用和复制 一章为核心,结合仓库中 Rest 参数与 Spread 语法、Symbol 类型 等章节的源码级佐证,系统讲解引用复制、按引用比较、Object.assign 浅拷贝、spread 语法复制以及递归深拷贝,读完你将能在实际项目中正确选择拷贝方案并避开共享引用带来的坑。
原始类型按值复制,对象按引用存储
先从原始类型看起。当把一个字符串变量复制给另一个变量时,得到的是两个完全独立的变量,各自存储着相同的值:
let message = "Hello!";
let phrase = message;
这时 message 和 phrase 是两个互不相干的存储单元,修改其中任何一个都不会影响另一个。
但对象完全不同。赋值了对象的变量存储的不是对象本身,而是该对象"在内存中的地址"——也就是对该对象的"引用"。 例如:
let user = {
name: "John"
};
如上图所示:对象被存储在内存中的某个位置(右侧),而变量 user(左侧)保存的只是一个指向它的"引用"。可以把对象变量想象成一张写着对象地址的纸条:当你执行 user.name 这样的操作时,JavaScript 引擎会顺着地址找到真正的对象再执行操作。
复制对象变量 = 复制引用,而非对象
这是本节最关键的一点:当一个对象变量被复制时,复制的是引用,对象本身并没有被复制。
let user = { name: "John" };
let admin = user; // 复制引用
此时内存中仍然只有一个对象,但有两个变量同时引用它。因此,通过任意一个变量修改对象,另一个变量都能看到变化:
let user = { name: 'John' };
let admin = user;
admin.name = 'Pete'; // 通过 "admin" 引用来修改
alert(user.name); // 'Pete',修改能通过 "user" 引用看到
这就像一间带两把钥匙的柜子:用 admin 这把钥匙打开柜子改了里面的东西,之后用 user 这把钥匙打开,看到的仍然是修改后的内容。
这一特性同样适用于函数传参:把对象作为参数传入函数,传递的也是引用,函数内部对对象的修改会反映到函数外部。
通过引用比较:== 与 ===
仅当两个变量引用同一个对象时,它们才相等:
let a = {};
let b = a; // 复制引用
alert( a == b ); // true,都引用同一对象
alert( a === b ); // true
而两个独立创建的对象,即使结构完全相同(例如都是空对象 {}),也不相等:
let a = {};
let b = {}; // 两个独立的对象
alert( a == b ); // false
至于 obj1 > obj2 这类比较,或 obj == 5 这种对象与原始值的比较,对象会被自动转换为原始值后再参与运算。这部分转换规则在仓库的 对象 —— 原始值转换 一章中有完整讲解——那里明确指出,对象与数字进行 == 比较时会走 "default" hint 转换。不过实践中这类比较很少见,出现时多半是编程错误。
浅拷贝:for..in 循环与 Object.assign
如果想复制一个对象本身(而不是复制引用),就需要创建新对象并逐属性拷贝。最简单的方式是用 for..in 遍历:
let user = {
name: "John",
age: 30
};
let clone = {}; // 新的空对象
// 将 user 中所有的属性拷贝到其中
for (let key in user) {
clone[key] = user[key];
}
// 现在 clone 是带有相同内容的完全独立的对象
clone.name = "Pete"; // 改变了其中的数据
alert( user.name ); // 原来的对象中的 name 属性依然是 John
更常用的方式是使用 Object.assign,其语法为:
Object.assign(dest, [src1, src2, src3...])
- 第一个参数
dest是目标对象; - 后面的参数
src1, ..., srcN(可按需传多个)是源对象; - 该方法把所有源对象的属性拷贝到目标对象
dest中,即从第二个参数开始的所有属性都被拷贝到第一个参数指向的对象里; - 调用结果返回
dest。
用它合并多个对象非常方便:
let user = { name: "John" };
let permissions1 = { canView: true };
let permissions2 = { canEdit: true };
// 将 permissions1 和 permissions2 中的所有属性都拷贝到 user 中
Object.assign(user, permissions1, permissions2);
// 现在 user = { name: "John", canView: true, canEdit: true }
如果源对象中某个属性名在目标对象中已存在,该属性值会被覆盖:
let user = { name: "John" };
Object.assign(user, { name: "Pete" });
alert(user.name); // 现在 user = { name: "Pete" }
用 Object.assign 代替 for..in 可以一行完成简单克隆:
let user = {
name: "John",
age: 30
};
let clone = Object.assign({}, user);
它将 user 的所有属性拷贝到空对象中并返回新对象。
关于 Object.assign,仓库 Symbol 类型 一章还补充了一个容易忽略的细节:Object.assign 会同时复制字符串键属性和 symbol 键属性,而 for..in 循环和 Object.keys 都会忽略 symbol 属性。也就是说,当克隆或合并对象时,Object.assign 会连 symbol 属性一起带走,这正是"拷贝时通常希望所有属性都被复制"的设计意图。
用 spread 语法做浅拷贝
除了 Object.assign,还可以用 spread 语法 {...user} 完成同样的浅拷贝。仓库 Rest 参数与 Spread 语法 的"复制 array/object"一节给出了完整示例:
let obj = { a: 1, b: 2, c: 3 };
let objCopy = { ...obj }; // 将对象 spread 到参数列表中
// 然后将结果返回到一个新对象
// 两个对象中的内容相同吗?
alert(JSON.stringify(obj) === JSON.stringify(objCopy)); // true
// 两个对象相等吗?
alert(obj === objCopy); // false(引用不同)
// 修改初始对象不会修改副本:
obj.d = 4;
alert(JSON.stringify(obj)); // {"a":1,"b":2,"c":3,"d":4}
alert(JSON.stringify(objCopy)); // {"a":1,"b":2,"c":3}
同理,数组也可以用 [...arr] 复制,并且副本与原件引用不同、互不影响。正如该章节总结的:{...obj} 比 Object.assign({}, obj) 更简洁,只要情况允许,优先使用 spread 语法。
深拷贝:嵌套对象是浅拷贝的盲区
前面所有拷贝方式都假设对象的属性均为原始类型。一旦属性本身是对其他对象的引用,浅拷贝就会出问题:
let user = {
name: "John",
sizes: {
height: 182,
width: 50
}
};
alert( user.sizes.height ); // 182
此时执行 clone.sizes = user.sizes 是不够的——user.sizes 是对象,会被按引用复制,导致 clone 和 user 共用同一个 sizes 子对象:
let user = {
name: "John",
sizes: {
height: 182,
width: 50
}
};
let clone = Object.assign({}, user);
alert( user.sizes === clone.sizes ); // true,同一个对象
// user 和 clone 分享同一个 sizes
user.sizes.width++; // 通过其中一个改变属性值
alert(clone.sizes.width); // 51,能从另外一个获取到变更后的结果
也就是说,Object.assign 和 spread 语法做的都是"浅拷贝":只复制一层属性,嵌套对象仍然共享引用。
要让 user 和 clone 成为完全独立的对象,必须做"深拷贝":遍历 user[key] 的每个值,如果某个值本身是对象,就递归地复制它的结构。典型的递归实现思路如下(这是教学示例,演示深拷贝的核心逻辑):
function deepClone(obj) {
if (obj === null || typeof obj !== "object") {
return obj; // 原始类型直接返回
}
let clone = {};
for (let key in obj) {
clone[key] = deepClone(obj[key]); // 递归复制嵌套对象
}
return clone;
}
在实际项目中,也可以直接采用成熟库的现成实现,例如 lodash 的 _.cloneDeep(obj),避免重复造轮子——这一点在原文档中也有明确建议。
补充:const 声明的对象仍然可以被修改
按引用存储对象有一个重要的副作用:声明为 const 的对象也可以被修改。
const user = {
name: "John"
};
user.name = "Pete"; // (*)
alert(user.name); // Pete
(*) 这行并不会报错,因为 user 的值(即引用)是常量,它必须始终指向同一个对象,但该对象的属性可以被自由修改。只有尝试整体赋值 user = ... 时,const user 才会报错。若确实需要创建不可变的"常量对象属性",需要使用完全不同的机制——属性标志与属性描述符,详见仓库 属性标志和属性描述符 一章。
总结
- 对象通过引用被赋值和拷贝:变量保存的不是对象本身,而是指向对象的内存地址;拷贝变量或将其作为函数参数传递时,拷贝的都是引用而非对象。
- 所有通过被拷贝引用执行的操作(添加、删除、修改属性)都作用在同一个对象上,任一引用都能看到变化。
- 对象比较看引用:只有两个变量引用同一对象时
==/===才为 true;与原始值比较时对象会先被转换。 - 浅拷贝:
Object.assign({}, obj)、{...obj}或for..in循环只复制顶层属性,嵌套对象仍共享引用;Object.assign会连 symbol 属性一起复制。 - 深拷贝:需要递归复制所有嵌套对象结构,可自实现递归函数,或直接使用
_.cloneDeep(obj)等现成实现。 const只锁定引用:const对象的属性仍可修改,const仅阻止重新赋值整个变量。