一、概述
在编程语言中,“值传递”(pass by value)和“引用传递”(pass by reference)是两种常见的参数传递方式:
- 值传递:实参的值被复制一份,传给形参。函数内部修改形参,不会影响实参。
- 引用传递:实参的地址或引用被传递给形参,函数内部通过形参修改内存中同一个对象,从而影响实参。
不同语言可能支持其中一种或两种。比如 C 语义默认值传递,若要“引用传递”需要传递指针;C++ 支持两者;Python 一切都是对象引用的“值传递”(亦即“传递引用的副本”);Java 中基本类型值传递,对象也是“传递引用的副本”;而 JavaScript 的传参机制常被误解为也有引用传递,严格意义上来说 JavaScript 只有“值传递”,但对于对象类型,所传递的“值”是一个“指向对象的引用”,这导致看似像“引用传递”的效果。
二、JavaScript 的内存模型
首先了解一下 JS 的内存管理与内存布局。
-
栈(Stack)
存放局部变量、函数调用时的上下文(execution context)、基本类型的值等。栈区空间小且分配回收快。 -
堆(Heap)
存放对象、数组、函数等“引用类型”数据。堆区空间大,但分配回收相对慢。
比如:
let a = 10;
let obj = { x: 1, y: 2 };
- a 的值 10 直接存储在栈上;
- obj 存在堆上,栈上保存的其实是一个指向堆中对象的 引用(或称指针)。
三、值传递与引用传递的定义
3.1 值传递(Pass by Value)
- 将 实参的值 复制给 形参。
- 形参与实参在内存中各占独立位置,互不干扰。
- 修改形参不会影响实参。
function foo(x) {
x = 100;
}
let a = 1;
foo(a);
console.log(a); // 1 —— a 未被修改
a 和 x 各自存储在不同的位置,函数内部对 x 的修改,不会反映到 a。
3.2 引用传递(Pass by Reference)
- 将 实参的引用(地址) 直接传递给形参。
- 形参和实参指向同一个内存地址。
- 通过形参修改内存,会影响实参。
示例:支持引用传递的 C++
void foo(int &x) {
x = 100;
}
int a = 1;
foo(a);
cout << a; // 100 —— a 被修改
C++ 中用 & 声明引用参数,就能直接修改实参。
四、JavaScript 只有“值传递”
4.1 基本类型 —— 绝对的值传递
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。
4.2 引用类型 —— 传递的是“引用的值”
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 中没有实参“地址传递给形参”,而是“地址的值”被复制给形参。这里的“引用传递”说法,只是因为复制的是对象的引用而已,函数内部也只是在该引用上做操作。如果改写引用本身,不会反向影响。
五、细分场景
5.1 基本类型
function foo(s) {
s += ' world';
console.log('内部 s:', s);
}
let str = 'hello';
foo(str); // 内部 s: hello world
console.log(str); // 外部 str: hello
- 形参 s 拷贝了外部 str 的值 'hello'。
- 函数内部修改 s,不影响外部 str。
5.2 对象类型修改属性
function mutate(o) {
o.age = o.age + 1;
}
let person = { name: 'Alice', age: 20 };
mutate(person);
console.log(person.age); // 21 —— 属性修改被保留
- 形参 o 拷贝了 person 的引用(值)。
- 两者指向同一个对象,修改属性生效。
5.3 对象整体重新赋值
function replace(o) {
o = { name: 'Bob', age: 30 };
}
let person = { name: 'Alice', age: 20 };
replace(person);
console.log(person.name); // Alice —— 外部对象不变
- o = {...} 只是改变了形参 o 的引用指向,原对象保持不动。
5.4 数组示例
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]
- arr.push(4):在原数组末尾添加 4;外部数组变化。
- arr = [1,2]:形参指向新数组,后续 push(3) 只影响新数组,外部无感知。
六、值传递 vs 引用传递的对比
特征 | 值传递 | “引用传递”(误解) |
---|---|---|
传递内容 | 基本类型值,或引用类型的引用值 | 直接传递对象内存地址(C++ 引用语义) |
修改效果 | 不影响外部 | 修改属性会影响外部,但重赋值不影响 |
内存操作 | 复制独立值 | 复制指针/引用 |
是否支持 | JS 全部传参模式 | JS 只支持前者,但复制的是引用的值,易混淆 |
关键:JS 中的每一次函数调用都只做“值拷贝”,不管是基本类型还是引用类型,形参拿到的都是一份拷贝。但如果拷贝的是“引用指针”,则通过该指针操作到同一块堆内存,就会有“修改可见”的效果。
6.1 常见误区
-
误区:JS 支持引用传递
真相:JS 传参始终是值传递,只是“值”可能是指向对象的引用。 -
误区:对象传参修改属性等价于引用传递
虽然看起来像引用传递,但形参本质仍是指针的拷贝,若重写指针则不会影响外部。 -
误区:函数内部 arguments 改变会影响外部形参
ES5 严格模式下已分离,非严格模式下 arguments[i] 与形参同名会关联,但这属于语言特殊行为,与传参语义不同。
6.2 深拷贝与浅拷贝
既然传递的是引用,那么如何避免“函数体内无意修改对象属性”带来副作用?通常会先对对象或数组做浅拷贝或深拷贝,再传入函数。
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、深拷贝
- 将所有层级都克隆一份,避免共享任何引用。
- 方法:JSON.parse(JSON.stringify(obj))、手写递归、使用 lodash.cloneDeep
let obj = { a: 1, b: { c: 2 } };
let deep = JSON.parse(JSON.stringify(obj));
deep.b.c = 100;
console.log(obj.b.c); // 2 —— 原对象保持不变
6.3 函数式编程与不可变思想
在需要高可靠性的项目中,常常提倡“不可变数据”和“纯函数”:
- 纯函数:相同输入,必定输出相同;且无任何副作用(不修改外部状态)。
- 对于对象或数组参数,应在函数内部做拷贝后再处理,并返回新值。
function addItem(arr, item) {
// 不修改传入的 arr,而是返回新数组
return [...arr, item];
}
如此,即可彻底避免因“引用”导致的意外修改。
七、“引用传递”的效果
在 ES 模块(import/export)或 CommonJS (require/module.exports) 里,看似传递的是「值」,但对于引用类型,获取到的是同一个对象的引用,并且模块只会初始化一次、结果会被缓存并在多个模块间共享。这就产生了“引用传递”的效果——在任意一个地方修改了这个对象,别的地方都能感知到。
7.1 为什么会这样?
-
模块只会执行并初始化一次
当第一次 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 始终是“按值传参”,但当值本身是一个引用(对象/数组/函数),函数内修改其属性会反映到外部。
-
模块导入导出时,导出的引用类型在各个模块间共享同一份实例,这就像把引用“传”给每个模块,修改同样会被全局可见。
所以,我们所看到的“引用传递”,实际上是模块系统的 “单例缓存 + 共享引用” 机制在起作用。
8. 总结
场景 | 是否“引用传递” | 实际发生了什么 |
---|---|---|
函数参数传递 | 不是引用传递 | 始终是值传递,但引用类型的值是指针副本,因此函数内部能改对象内部属性,不能替换整个引用。 |
模块 import/export | 表现像引用传递 | 模块导出的是同一个对象的引用,多处导入会共享这份引用,修改会同步反映。 |
对象赋值 let b = a | 表现像引用传递 | 赋值的是引用的副本,修改对象内部属性会影响原对象。 |
原始类型(string, number, boolean)传参或赋值 | 值传递 | 完全复制一份值,互不影响。 |
JS 永远是值传递,但引用类型的“值”本质是一个“指向对象的地址”。
所以可以改对象内容,却无法改变原引用的绑定。
模块导出时共享引用,看起来像引用传递,但那是模块缓存机制的结果。