在编程语言中,“值传递”(pass by value)和“引用传递”(pass by reference)是两种常见的参数传递方式:
不同语言可能支持其中一种或两种。比如 C 语义默认值传递,若要“引用传递”需要传递指针;C++ 支持两者;Python 一切都是对象引用的“值传递”(亦即“传递引用的副本”);Java 中基本类型值传递,对象也是“传递引用的副本”;而 JavaScript 的传参机制常被误解为也有引用传递,严格意义上来说 JavaScript 只有“值传递”,但对于对象类型,所传递的“值”是一个“指向对象的引用”,这导致看似像“引用传递”的效果。
首先了解一下 JS 的内存管理与内存布局。
栈(Stack)
存放局部变量、函数调用时的上下文(execution context)、基本类型的值等。栈区空间小且分配回收快。
堆(Heap)
存放对象、数组、函数等“引用类型”数据。堆区空间大,但分配回收相对慢。
比如:
let a = 10;
let obj = { x: 1, y: 2 };
function foo(x) {
x = 100;
}
let a = 1;
foo(a);
console.log(a); // 1 —— a 未被修改
a 和 x 各自存储在不同的位置,函数内部对 x 的修改,不会反映到 a。
示例:支持引用传递的 C++
void foo(int &x) {
x = 100;
}
int a = 1;
foo(a);
cout << a; // 100 —— a 被修改
C++ 中用 & 声明引用参数,就能直接修改实参。
JS 的基本类型包括:undefined、null、boolean、number、string、symbol、bigint。这些类型的变量在赋值、传参时,都是将值本身复制一份。
function changeNum(n) {
n = 99;
}
let num = 10;
changeNum(num);
console.log(num); // 10 —— 仍然是 10
无论在函数内如何修改 n,都无法影响外部的 num。
JS 的引用类型包括:Object、Array、Function、Date、RegExp 等。它们存在堆上,栈上保存的是一个“引用”(内存地址)的值。
当将引用类型作为参数传递时,复制的是这个 引用本身的值,并非直接复制对象。因此:
function changeObj(o) {
o.x = 100; // 修改同一对象的属性
o = { x: 200 }; // 重新给 o 赋了一个新对象
}
let obj = { x: 1 };
changeObj(obj);
console.log(obj.x); // 100 —— 改变属性生效,但重新赋值不影响外部引用
简单分析一下:
1、创建 obj
let obj = { x: 1 };
内存结构如下:
变量名 | 值
--------|-----------------
obj | 指向 --> { x: 1 }(对象)
2、调用 changeObj(obj)
changeObj(obj);
现在 obj 的值(即引用地址)被复制一份传给函数形参 o。可以理解为:
o = obj(引用地址被复制了)
此时内存情况如下:
变量名 | 值
--------|-----------------
obj | --> { x: 1 }
o | --> { x: 1 } (和 obj 指向同一个对象)
3、o.x = 100
修改了对象的属性:{ x: 100 },形参 o 和外部 obj 引用同一个对象,故属性修改影响可见。
现在内存为:
变量名 | 值
--------|-----------------
obj | --> { x: 100 }
o | --> { x: 100 } (仍然是同一个对象)
4、o = {x: 200}
这一行的关键点是:
只是给 o 这个局部变量重新赋值,指向了一个新对象,它和原来的 obj 再也没有关系!此时外部 obj 仍指向旧对象,不受影响。
现在内存状态是:
变量名 | 值
--------|-----------------
obj | --> { x: 100 }
o | --> { x: 200 }(一个新的对象)
也就是说,只是“切断”了 o 和 obj 的连接,但 obj 依旧指向旧的对象。
想要影响外部变量 obj 本身的引用(让它指向新对象),JS 是做不到的(因为无法通过函数改变调用者作用域中的变量绑定)。
延伸思考:那如果想改变外部引用呢?
那就需要 返回一个新对象:
function changeObj(o) {
return { x: 200 };
}
let obj = { x: 1 };
obj = changeObj(obj); // 手动接受返回值
console.log(obj.x); // 200
总结:
JS 中没有实参“地址传递给形参”,而是“地址的值”被复制给形参。这里的“引用传递”说法,只是因为复制的是对象的引用而已,函数内部也只是在该引用上做操作。如果改写引用本身,不会反向影响。
function foo(s) {
s += ' world';
console.log('内部 s:', s);
}
let str = 'hello';
foo(str); // 内部 s: hello world
console.log(str); // 外部 str: hello
function mutate(o) {
o.age = o.age + 1;
}
let person = { name: 'Alice', age: 20 };
mutate(person);
console.log(person.age); // 21 —— 属性修改被保留
function replace(o) {
o = { name: 'Bob', age: 30 };
}
let person = { name: 'Alice', age: 20 };
replace(person);
console.log(person.name); // Alice —— 外部对象不变
function append(arr) {
arr.push(4);
arr = [1, 2];
arr.push(3);
console.log('内部 arr:', arr);
}
let a = [1, 2, 3];
append(a); // 内部 arr: [1,2,3]
console.log(a); // 外部 a: [1,2,3,4]
特征 | 值传递 | “引用传递”(误解) |
---|---|---|
传递内容 | 基本类型值,或引用类型的引用值 | 直接传递对象内存地址(C++ 引用语义) |
修改效果 | 不影响外部 | 修改属性会影响外部,但重赋值不影响 |
内存操作 | 复制独立值 | 复制指针/引用 |
是否支持 | JS 全部传参模式 | JS 只支持前者,但复制的是引用的值,易混淆 |
关键:JS 中的每一次函数调用都只做“值拷贝”,不管是基本类型还是引用类型,形参拿到的都是一份拷贝。但如果拷贝的是“引用指针”,则通过该指针操作到同一块堆内存,就会有“修改可见”的效果。
误区:JS 支持引用传递
真相:JS 传参始终是值传递,只是“值”可能是指向对象的引用。
误区:对象传参修改属性等价于引用传递
虽然看起来像引用传递,但形参本质仍是指针的拷贝,若重写指针则不会影响外部。
误区:函数内部 arguments 改变会影响外部形参
ES5 严格模式下已分离,非严格模式下 arguments[i] 与形参同名会关联,但这属于语言特殊行为,与传参语义不同。
既然传递的是引用,那么如何避免“函数体内无意修改对象属性”带来副作用?通常会先对对象或数组做浅拷贝或深拷贝,再传入函数。
1、浅拷贝
对象浅拷贝:只拷贝一层属性,若属性值仍为引用类型,则拷贝的是内部引用。
let obj = { a: 1, b: { c: 2 } };
let copy = { ...obj }; // 或 Object.assign({}, obj)
copy.a = 9;
copy.b.c = 99;
console.log(obj.b.c); // 99 —— 内部对象仍被修改
数组浅拷贝:arr.slice()、[...arr]。
2、深拷贝
let obj = { a: 1, b: { c: 2 } };
let deep = JSON.parse(JSON.stringify(obj));
deep.b.c = 100;
console.log(obj.b.c); // 2 —— 原对象保持不变
在需要高可靠性的项目中,常常提倡“不可变数据”和“纯函数”:
function addItem(arr, item) {
// 不修改传入的 arr,而是返回新数组
return [...arr, item];
}
如此,即可彻底避免因“引用”导致的意外修改。
在 ES 模块(import/export)或 CommonJS (require/module.exports) 里,看似传递的是「值」,但对于引用类型,获取到的是同一个对象的引用,并且模块只会初始化一次、结果会被缓存并在多个模块间共享。这就产生了“引用传递”的效果——在任意一个地方修改了这个对象,别的地方都能感知到。
模块只会执行并初始化一次
当第一次 import 或 require 一个模块时,模块文件里的代码会被执行,导出的对象/变量就生成并存放在模块缓存里。后续所有对这个模块的导入,都是从缓存里拿到同一个“实例”。
导出的是对同一份对象的引用
对象、数组、函数 等引用类型,导出时并不会把它“复制”一份给每个导入者,而是把同一个对象的引用挂到每个导入模块里。
即便 ES 模块对 变量 本身采用“活绑定”(live binding),也不会把对象内容克隆一次。
共享缓存带来“引用”效果
// a.js
export const settings = { mode: 'light' };
// b.js
import { settings } from './a.js';
settings.mode = 'dark'; // 改变了 a.js 中同一个对象
// c.js
import { settings } from './a.js';
console.log(settings.mode); // 'dark'
无论在 b.js 还是 c.js 中修改 settings,因为它们都是指向同一个对象,所以彼此可见。
CommonJS(Node.js)的类似行为
对于 require,也是同样道理:
// config.js
module.exports = { url: 'https://api.example.com' };
// service.js
const cfg = require('./config');
cfg.url = 'https://api.dev'; // 改变了缓存里的对象
// index.js
const cfg1 = require('./config');
console.log(cfg1.url); // 'https://api.dev'
require 会缓存 module.exports,后续 require 拿到的永远是同一个对象。
小结:
函数调用时,JS 始终是“按值传参”,但当值本身是一个引用(对象/数组/函数),函数内修改其属性会反映到外部。
模块导入导出时,导出的引用类型在各个模块间共享同一份实例,这就像把引用“传”给每个模块,修改同样会被全局可见。
所以,我们所看到的“引用传递”,实际上是模块系统的 “单例缓存 + 共享引用” 机制在起作用。
场景 | 是否“引用传递” | 实际发生了什么 |
---|---|---|
函数参数传递 | 不是引用传递 | 始终是值传递,但引用类型的值是指针副本,因此函数内部能改对象内部属性,不能替换整个引用。 |
模块 import/export | 表现像引用传递 | 模块导出的是同一个对象的引用,多处导入会共享这份引用,修改会同步反映。 |
对象赋值 let b = a | 表现像引用传递 | 赋值的是引用的副本,修改对象内部属性会影响原对象。 |
原始类型(string, number, boolean)传参或赋值 | 值传递 | 完全复制一份值,互不影响。 |
JS 永远是值传递,但引用类型的“值”本质是一个“指向对象的地址”。
所以可以改对象内容,却无法改变原引用的绑定。
模块导出时共享引用,看起来像引用传递,但那是模块缓存机制的结果。