JS 中到底存不存在“引用传递”?

一、概述

在编程语言中,“值传递”(pass by value)和“引用传递”(pass by reference)是两种常见的参数传递方式:

  • 值传递:实参的值被复制一份,传给形参。函数内部修改形参,不会影响实参。
  • 引用传递:实参的地址或引用被传递给形参,函数内部通过形参修改内存中同一个对象,从而影响实参。

不同语言可能支持其中一种或两种。比如 C 语义默认值传递,若要“引用传递”需要传递指针;C++ 支持两者;Python 一切都是对象引用的“值传递”(亦即“传递引用的副本”);Java 中基本类型值传递,对象也是“传递引用的副本”;而 JavaScript 的传参机制常被误解为也有引用传递,严格意义上来说 JavaScript 只有“值传递”,但对于对象类型,所传递的“值”是一个“指向对象的引用”,这导致看似像“引用传递”的效果。

二、JavaScript 的内存模型

首先了解一下 JS 的内存管理与内存布局。

  1. 栈(Stack)
    存放局部变量、函数调用时的上下文(execution context)、基本类型的值等。栈区空间小且分配回收快。

  2. 堆(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 常见误区
    1. 误区:JS 支持引用传递
      真相:JS 传参始终是值传递,只是“值”可能是指向对象的引用。

    2. 误区:对象传参修改属性等价于引用传递
      虽然看起来像引用传递,但形参本质仍是指针的拷贝,若重写指针则不会影响外部。

    3. 误区:函数内部 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 为什么会这样?
    1. 模块只会执行并初始化一次
      当第一次 import 或 require 一个模块时,模块文件里的代码会被执行,导出的对象/变量就生成并存放在模块缓存里。后续所有对这个模块的导入,都是从缓存里拿到同一个“实例”。

    2. 导出的是对同一份对象的引用

      • 对象、数组、函数 等引用类型,导出时并不会把它“复制”一份给每个导入者,而是把同一个对象的引用挂到每个导入模块里。

      • 即便 ES 模块对 变量 本身采用“活绑定”(live binding),也不会把对象内容克隆一次。

    3. 共享缓存带来“引用”效果

      // 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 永远是值传递,但引用类型的“值”本质是一个“指向对象的地址”。

    所以可以改对象内容,却无法改变原引用的绑定。

    模块导出时共享引用,看起来像引用传递,但那是模块缓存机制的结果。 

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值