在 JavaScript 中,对象(包括数组、函数、普通对象等)属于引用类型。这意味着变量存储的并不是对象本身,而是对象在内存堆中的地址。当我们将一个引用类型的变量赋值给另一个变量时,传递的是地址,而不是值的副本。这是导致大量隐蔽副作用的根源。
问题本质:共享同一份数据
const original = { name: 'Alice', skills: ['JavaScript', 'React'] };
const copy = original;
copy.name = 'Bob';
copy.skills.push('Node.js');
console.log(original.name); // 'Bob' —— 原对象被修改了
console.log(original.skills); // ['JavaScript', 'React', 'Node.js']
这里 copy 和 original 指向内存中的同一个对象,通过 copy 修改属性,original 也会受到影响。数组同理:
const arr1 = [1, 2, 3];
const arr2 = arr1;
arr2.push(4);
console.log(arr1); // [1, 2, 3, 4]
很多开发者以为赋值操作会创建一个独立副本,结果在修改“副本”时无意中污染了原始数据,尤其是当数据在多个函数或组件间传递时,这类副作用极难排查。
常见踩坑场景
1. 函数参数传递的是引用
function addTask(tasks, newTask) {
tasks.push(newTask); // 直接修改了传入的数组
}
const myTasks = ['代码审查'];
addTask(myTasks, '写测试');
console.log(myTasks); // ['代码审查', '写测试'] —— 外部数组被改变
函数内部直接修改引用类型参数,副作用会“泄漏”到外部,破坏数据不可变性,容易引发状态错乱(尤其在 React、Vue 等框架中)。
2. 嵌套对象/数组只做了浅拷贝
const user = {
name: '张三',
address: { city: '北京', district: '朝阳区' }
};
const shallowCopy = { ...user };
shallowCopy.address.city = '上海';
console.log(user.address.city); // '上海' —— 原对象也被修改了
扩展运算符 ...、Object.assign()、数组的 slice() 和 concat() 都只能做浅拷贝。如果对象内部还嵌套着其他引用类型,内层对象仍然是共享引用。
3. 状态管理中的意外修改
在 Vuex、Redux、Pinia 等状态管理方案中,要求不能直接修改状态,必须通过提交 mutation 或生成新对象来更新。很多 bug 都是因为直接对 state 中的数组或对象进行 push、splice、属性赋值等操作导致的。
解决方案:从浅拷贝到深拷贝
浅拷贝 —— 适合只有一层结构的对象
// 对象浅拷贝
const copy1 = Object.assign({}, original);
const copy2 = { ...original };
// 数组浅拷贝
const arrCopy1 = arr.slice();
const arrCopy2 = [ ...arr ];
但如果对象存在嵌套结构,浅拷贝无能为力。
深拷贝 —— 完全独立的副本
- JSON 方法(有局限)
const deepCopy = JSON.parse(JSON.stringify(original));
这个方法简便,但缺点明显:
- 无法拷贝
undefined、Function、Symbol类型的属性 - 遇到循环引用会直接报错
- 日期对象会被转为字符串,正则、Map、Set 会丢失
- 手写深拷贝(递归 + 类型判断)
手写深拷贝是面试高频题,也是理解引用问题的绝佳练习。基础版递归遍历所有属性,对于数组和普通对象分别处理,并利用 WeakMap 解决循环引用。更完善版本还需处理 Map、Set、Date、RegExp 等特殊对象。
- 使用工具库
生产环境中推荐使用 Lodash 的 cloneDeep 等经过大量测试的成熟方案,避免在边界情况上踩坑。
防御性编程建议
- 保持数据不可变:不直接修改原数组/对象,而是返回新数组/对象(如使用
concat、filter、map、展开运算符等)。 - 明确函数副作用:如果函数内部需要修改传入的引用类型,应在文档或命名上清晰标注,或干脆改为返回新值。
- 使用 TypeScript:类型系统虽不能彻底杜绝引用问题,但
readonly、ReadonlyArray等工具类型能在编译期捕获大量意外修改。 - 组件间传递数据时:若非有意共享状态,传递深拷贝,或者使用状态管理库统一控制数据流。
数组与对象的引用赋值问题几乎每个 JavaScript 开发者都会遇到,它不是一个可以绕开的“语法坑”,而是由语言核心的引用机制决定的固有特性。养成主动思考数据是否被共享的习惯,能避免相当一部分难以追踪的状态错误。