JavaScript 传值机制全解:为什么对象是"引用按值传递"——jstips 44 深度解读
JavaScript 传值机制全解:为什么对象是"引用按值传递"——jstips #44 深度解读
导读:本文以 jstips 仓库第 44 期 tip《了解传值机制》为骨架,剖析 JavaScript 中"按值传递"与"按引用传递"的经典争论。通过两个对照实例,你将彻底理解"对象传的是引用的值"(即 call-by-sharing)这一本质,并掌握它在实际编码中如何影响函数内外数据的一致性,从而写出行为可预期的代码。
一、先亮结论:JavaScript 在技术上"只按值传递"
在讨论参数传递机制时,计算机科学里有两个经典术语:
- 按值传递(pass-by-value):调用函数时,把实参的值复制一份交给形参,函数内对形参的任何改动都不会影响外部变量。
- 按引用传递(pass-by-reference):把实参的内存地址交给形参,函数内对形参的操作会直接作用于外部变量本身。
JavaScript 的传参行为,用任何一个术语去"生搬硬套"都会出错。正如本 tip 开头所说:
JavaScript 技术上只按值传递(pass-by-value);对对象(引用)类型而言,传递的是引用值本身,而引用值也是按值传递的。
这句话听起来绕,但用下面两个实例可以完全讲清楚。原始讨论参见 中文版文档 与 英文原版文档。
二、实例 1:函数内重新赋值,外部对象纹丝不动
var me = { // 1
'partOf' : 'A Team'
};
function myTeam(me) { // 2
me = { // 3
'belongsTo' : 'A Group'
};
}
myTeam(me);
console.log(me); // 4 : {'partOf' : 'A Team'}
逐行拆解:
- 第 1 行:
me是一个对象字面量,其值是一个指向堆内存中对象{partOf: 'A Team'}的引用。 - 第 2 行:调用
myTeam(me)时,JavaScript 把me这个引用值复制了一份传给函数内部的形参。此时函数内外各有一个引用变量,但它们指向同一个对象。这里内外都叫me很有迷惑性——它给人"只有一个引用"的错觉,实际上它们是两个相互独立的引用变量。 - 第 3 行:函数内部把形参
me重新赋值为一个全新的对象{belongsTo: 'A Group'}。这只是在函数作用域内改写了这个引用的值(让它指向新对象),完全没有触碰外部变量me所持有的引用。 - 第 4 行:因此外部打印的仍是原始对象:
{'partOf' : 'A Team'}。
结论:引用变量本身是独立的;函数内给形参"换个新对象",影响不了外部。
三、实例 2:函数内修改属性,外部对象同步变化
var me = { // 1
'partOf' : 'A Team'
};
function myGroup(me) { // 2
me.partOf = 'A Group'; // 3
}
myGroup(me);
console.log(me); // 4 : {'partOf' : 'A Group'}
逐行拆解:
- 第 1 行:同样的对象字面量,外部持有其引用。
- 第 2 行:
myGroup(me)同样把引用值复制进函数。 - 第 3 行:关键区别来了——这里没有给形参重新赋值,而是通过形参持有的引用访问并修改了对象上的属性
me.partOf。 - 第 4 行:因为内外两个引用仍然指向同一个对象,所以通过函数内引用修改的属性,外部打印时同样能看到:
{'partOf' : 'A Group'}。
结论:修改对象内容(而非替换引用)会"穿透"函数边界,直接影响调用方看到的对象。
四、那 JavaScript 是按引用传递吗?——不,是"引用按值传递"
实例 2 很容易让人得出"JavaScript 对象按引用传递"的结论,但本 tip 明确否定了这一点:
请记住:如果是对象的话,JavaScript 将引用按值传递。
两种实例放在一起对比,本质一目了然:
| 行为 | 实例 1(重新赋值) | 实例 2(修改属性) |
|---|---|---|
| 函数内对形参做什么 | me = {...} 替换引用 |
me.partOf = ... 修改对象内容 |
| 外部对象是否受影响 | 否,外部引用仍指向原对象 | 是,内外引用指向同一对象 |
| 原因 | 形参只是引用的副本,改副本不影响原件 | 副本与原件指向同一堆对象,改对象内容双方都可见 |
如果真的是"按引用传递",实例 1 里函数内把形参指向新对象后,外部变量 me 也应该跟着变成新对象——但它没有。这正是"引用按值传递"的铁证:传进函数的不是对象本身,也不是能改写外部变量的"引用变量",而只是引用值的副本。
由于引用值被共享(副本和原件指向同一目标),但又确实是通过"拷贝"进入函数,有人把这种机制称为 call-by-sharing(共享调用 / 按共享传递),这是对 JavaScript 传参机制更精确的描述。
五、从内存模型看本质:引用是什么,拷贝的是什么
要彻底消化上面的结论,可以借助简化的内存模型来理解:
- 原始类型(primitive):
number、string、boolean、null、undefined、symbol、bigint直接存值。传参时拷贝的就是值本身,函数内任何修改都不会外泄。这是纯粹的 pass-by-value。 - 对象类型(object/reference):变量里存的不是对象数据,而是一个"指向堆中对象的引用"(可类比为门牌号)。传参时拷贝的是门牌号,函数内外两个变量持有一模一样的门牌号,都通向同一间房。
于是"房间里的家具"(对象属性)可以被任何持有门牌号的人改动;但"把门牌号换成另一间房的门牌号"(重新赋值形参),只改变持号人自己,别人手里的门牌号不受影响。
这正是本 tip 两个实例在内存层面的唯一差别:实例 1 换了门牌号,实例 2 进了房间搬家具。
六、仓库内相关 tips 的互相印证
jstips 仓库中还有其他若干 tips 与传参机制互为补充,可以加深理解:
1. 传参副本的代价:arguments 泄漏会杀死 V8 优化
既然参数以"值/引用的副本"进入函数,那么函数内部对 arguments 对象的使用方式也会影响引擎优化。在 tip 31:避免把 arguments 传给其他函数 中明确指出:把 arguments 直接传给其他函数(称为 leaking arguments)会导致 Chrome 与 Node 使用的 V8 引擎跳过该函数的优化,显著拖慢性能。该 tip 给出的替代方案是显式拷贝:
var args = new Array(arguments.length);
for(var i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}
这从另一个侧面印证了:在 JavaScript 中"拷贝值"是绕不开的日常操作,理解拷贝的是值还是引用,直接关系到性能与正确性。
2. 共享引用的风险:为什么需要不可变对象
正因为对象按共享传递、函数内修改属性会外泄,仓库中多个 tips 都在讨论如何规避这种副作用:
- tip 74:用原生 JavaScript 创建不可变对象 介绍了
Object.preventExtensions()、Object.seal()、Object.freeze()三级防线,并在严格模式下让"修改不可变对象"直接抛错; - tip 78:不可变结构与克隆 讨论了在复杂对象场景下通过克隆/不可变结构保护"上一份状态"。
当你把对象传给函数时,如果不想让它被改,Object.freeze() 是最简单直接的防御手段;这恰恰是对"共享引用"这一特性的反向利用。
3. 传参配合闭包:把"值"与"引用"用到位
tip 16:向回调函数传参 展示了另一种传参思路:通过闭包捕获变量,或借助 bind 预先绑定参数。闭包捕获的是变量与引用的"可达性",与本文的传值机制配合使用,可以写出既灵活又行为确定的回调代码。
七、实战建议:把传值机制变成你的心智模型
把本 tip 的核心结论落到日常编码中,可以总结出几条可直接使用的准则:
- 判断一段代码会不会"外泄"改动,看它操作的是引用还是对象内容:
param = newValue→ 只影响函数内;param.prop = newValue/param.method()→ 会影响外部共享对象。
- 不想让函数改动传入对象:要么传副本(浅拷贝/深拷贝),要么对入参做
Object.freeze()防护,或设计成纯函数(返回新对象而非就地修改)。 - 警惕"改形参"与"改对象"混用:在大型项目中,同一个对象被多处函数共享时,任何一处"改属性"都可能产生跨模块的连锁影响;这是按共享传递机制下最常见的隐式耦合来源。
- 面试与基础复盘时用两个实例自测:能否在不运行代码的情况下,准确说出上面两个
console.log的输出?能说对,说明你对"引用按值传递"的理解已经到位。
八、结语
JavaScript 的传参机制可以一句话概括:原始类型按值传递,对象类型"引用按值传递",本质是 call-by-sharing。这一机制决定了"重新赋值形参"与"修改对象属性"在行为上的天壤之别,也解释了为什么不可变数据结构、纯函数等范式在 JS 生态中如此重要。掌握它,你就能准确预测函数调用的一切副作用。
延伸阅读:本文整理自 jstips 仓库第 44 期,原始多语言版本可在 中文版、英文版、繁体版 与 西语版 中查阅;更多同类技巧可浏览仓库 README.md 中的完整 tip 列表。