先看题目:
const objA = new Object();
objA.atr = 'valueA';
const fn = (o) => {
o.atr = 'valueB';
o = new Object();
o.atr = 'valueC';
};
fn(objA);
console.log(objA.atr); // Output: valueB
问题是:为什么最后输出的是 valueB,而不是 valueC?
答案的关键只有一句话:JavaScript 只有值传递。对象作为参数时,传递的是对象引用的副本。
先拆解执行过程
第一步:创建对象
const objA = new Object();
objA.atr = 'valueA';
此时 objA 保存的是一个引用,指向堆上的对象:
objA ───> { atr: 'valueA' }
new Object() 等价于 {},这里只是创建一个普通对象并添加了一个属性。
第二步:调用函数,复制引用
fn(objA);
进入 fn 时,参数 o 接收的是 objA 的副本。
注意,复制的是引用本身,不是对象内容:
objA ─┐
├──> { atr: 'valueA' }
o ────┘
这时 objA 和 o 指向同一个对象。通过任何一个引用修改对象,另一个引用都能看到变化。
第三步:修改属性
o.atr = 'valueB';
这条语句没有让 o 指向新对象,而是沿着引用找到了那个共享对象,把它的 atr 属性改成 valueB。
因为 objA 也指向这个对象,所以外部看到的 objA.atr 也变成了 valueB:
objA ─┐
├──> { atr: 'valueB' }
o ────┘
第四步:重新赋值参数
o = new Object();
这一步很容易被误解。
o = new Object() 并没有修改原来的对象,而是让局部变量 o 指向了一个全新的对象:
objA ───> { atr: 'valueB' }
o ──────> { } // 新对象
从这一刻开始,o 和 objA 已经指向两个不同的对象。它们之间的“连接”断了。
第五步:修改新对象
o.atr = 'valueC';
这行代码修改的是新对象:
objA ───> { atr: 'valueB' }
o ──────> { atr: 'valueC' }
函数执行结束后,局部变量 o 被销毁,但 objA 仍然指向第一个对象。所以:
console.log(objA.atr); // valueB
如果函数内部在最后打印 o.atr,会得到 valueC。两个结果并不矛盾,因为它们看的根本不是同一个对象。
核心:值传递,还是引用传递?
这是这道题最容易答错的点。
很多人会说:“对象是引用传递,所以函数内部修改会影响外部。”
这个说法不准确。
JavaScript 的参数传递规则是:
- 原始类型:复制值。
- 引用类型:复制引用值。
也就是说,对象参数传的是“指向对象的地址”的副本。函数拿到的是同一把钥匙,但不是同一个变量槽位。
可以把它理解为:
调用者变量 objA ──保存──> 地址 0x100
函数参数 o ──保存──> 地址 0x100 的副本
因此:
- 通过
o修改对象内容,会影响objA,因为两者指向同一个对象。 - 让
o指向新对象,不会影响objA,因为只是修改了函数内部的局部变量。
更准确的说法是 call by sharing,也叫“共享传递”或“对象引用按值传递”。它不是传统意义上的引用传递。
和原始类型对比
原始类型更直观:
let count = 1;
function changeCount(n) {
n = 2;
}
changeCount(count);
console.log(count); // 1
n 只是 count 的副本,修改 n 不会影响外部的 count。
对象的不同之处在于:副本不是整个对象,而是引用。所以你能通过这个引用修改对象内部,但不能通过重新赋值参数来改变外部变量指向的对象。
再做一个对比实验
const obj = { value: 1 };
function mutate(o) {
o.value = 2;
}
function reassign(o) {
o = { value: 3 };
}
mutate(obj);
console.log(obj.value); // 2
reassign(obj);
console.log(obj.value); // 2
mutate 修改的是共享对象,所以外部能看到变化。
reassign 只是让参数 o 指向新对象,外部的 obj 完全不受影响。
这两个函数放在一起,基本就把“修改对象”和“重新赋值参数”的区别讲清楚了。
如果想让外部变成 valueC,怎么办?
有两种常见方式。
方式一:直接修改原对象
const objA = { atr: 'valueA' };
function fn(o) {
o.atr = 'valueC';
}
fn(objA);
console.log(objA.atr); // valueC
这里没有重新赋值 o,而是一直沿着原引用修改。
也可以用 Object.assign:
Object.assign(o, { atr: 'valueC' });
方式二:返回新对象,由调用者重新赋值
let objA = { atr: 'valueA' };
function fn(o) {
return { ...o, atr: 'valueC' };
}
objA = fn(objA);
console.log(objA.atr); // valueC
注意这里 objA 必须是 let。如果使用 const,可以修改对象属性,但不能把 objA 重新赋值为另一个对象。
const obj = { value: 1 };
obj.value = 2; // 可以
obj = { value: 3 }; // 报错
const 限制的是变量绑定,不是对象内容。这一点也经常和参数传递一起被问到。
面试时怎么回答
可以按这个顺序说:
- 输出是
valueB。 - JavaScript 是值传递。对象参数传递的是引用的副本,不是对象本身。
o.atr = 'valueB'通过共享引用修改了原对象,所以objA能看到变化。o = new Object()让局部参数指向新对象,切断了和objA的关联。- 后续
o.atr = 'valueC'只修改新对象,函数结束后局部变量o被销毁,objA仍然指向原对象。
如果再补一句“这通常叫 call by sharing,而不是引用传递”,基本就能把这道题答完整。
最后
这道题不难,但它把两个概念放在了一起:
- 对象属性可以被修改,因为引用指向的是同一块对象。
- 参数重新赋值不会影响外部,因为函数拿到的是引用的副本。
记住一句就够:能改对象,不能换对象。 通过参数可以修改共享对象的内容,但重新给参数赋值,只会改变函数内部的局部变量。