深入理解 JavaScript 函数参数传递:从值传递到纯函数实践
一、问题引入:一段代码引发的思考
先看以下代码示例,你能准确预测两次 console.log
输出结果吗?
javascript
ini
// 案例1:修改对象参数的属性
const a = { b: 1 };
const fn = (x) => { x.b = 2; };
console.log('修改前 a :>> ', a); // 输出:{ b: 1 }
fn(a);
console.log('修改后 a :>> ', a); // 输出:{ b: 2 }
// 案例2:深拷贝后修改参数
const c1 = { c1: 'c1' };
const c = { a: 1, c: c1 };
const fn2 = (x) => {
// 深拷贝:切断与原对象的引用关联
x = JSON.parse(JSON.stringify(x));
x.a = 2;
x.c.c1 = 'c1-1';
return x;
};
console.log('修改前 c :>> ', c); // 输出:{ a: 1, c: { c1: 'c1' } }
fn2(c);
console.log('修改后 c :>> ', c); // 输出:{ a: 1, c: { c1: 'c1' } }
若对结果存疑或想探究背后原理,本文将从内存存储机制切入,彻底解析 JS 函数参数传递规则,并演示如何通过纯函数避免副作用。
二、核心原理:JS 函数只有 "值传递"
许多开发者易混淆 "值传递" 与 "引用传递",但JavaScript 函数参数传递规则仅有 "值传递" ------ 调用函数时,会将实参的值复制一份赋值给形参。
形参和实参本质是独立变量,初始值相同。而 "修改形参是否影响实参" 的关键,在于不同数据类型的 "值" 在内存中的存储方式差异。
2.1 基本数据类型:直接存储 "具体值"
基本数据类型(number
、string
、boolean
、undefined
、null
、symbol
、bigint
)的 "值" 直接存储于栈内存。
传递基本数据类型时,函数复制 "具体值" 给形参,形参修改仅影响自身,不波及实参:
javascript
ini
const fn = (num) => {
num = 10; // 仅修改形参
console.log('形参 num :>> ', num); // 输出:10
};
let a = 5;
fn(a);
console.log('实参 a :>> ', a); // 输出:5(未被修改)
2.2 引用数据类型:存储 "内存地址"
引用数据类型(object
、array
、function
等)的 "值" 分两部分存储:
-
栈内存:存储指向堆内存的 "引用地址"(类似文件快捷方式);
-
堆内存:存储对象具体数据(类似文件实际内容)。
传递引用数据类型时,函数复制 "栈内存中的引用地址",形参和实参指向同一块堆内存数据:
-
若通过形参修改堆内存数据(如修改对象属性),实参会同步变化;
-
若直接修改形参的引用地址(如
x = { new: 'obj' }
),则不影响实参。
示例代码:
javascript
ini
const fn = (obj) => {
// 情况1:修改堆内存数据(影响实参)
obj.b = 2;
// 情况2:修改形参引用地址(不影响实参)
obj = { b: 3 };
console.log('形参 obj :>> ', obj); // 输出:{ b: 3 }
};
const a = { b: 1 };
fn(a);
console.log('实参 a :>> ', a); // 输出:{ b: 2 }(仅堆数据被修改)
三、实践方案:如何避免修改参数影响外部?
开发中常需在函数内处理数据但不影响外部变量(避免 "副作用"),纯函数是最佳解决方案。
3.1 什么是纯函数?
纯函数需满足两个核心条件:
-
无副作用:不修改函数外部数据(全局变量、外部对象属性),也不修改引用类型形参指向的堆内存数据;
-
输入决定输出:相同输入必定返回相同输出,不依赖函数外部状态。
纯函数优势:逻辑可预测、易于测试、无意外副作用。
3.2 实现纯函数:切断引用关联
实现纯函数的关键是切断形参与实参的引用关联------ 对引用类型参数进行 "拷贝",在新对象上修改数据。根据需求,拷贝分为 "浅拷贝" 和 "深拷贝"。
3.2.1 浅拷贝:适用于单层对象
若对象属性均为基本数据类型,浅拷贝可切断引用。常用浅拷贝方式:
-
扩展运算符(
...
); -
Object.assign()
; -
数组
slice()
、concat()
方法(针对数组)。
示例代码:
javascript
ini
const fn = (obj) => {
// 浅拷贝:生成新对象,与原对象无引用关联
const newObj = { ...obj };
newObj.b = 2; // 修改新对象,不影响原对象
return newObj;
};
const a = { b: 1 };
const newA = fn(a);
console.log('原对象 a :>> ', a); // 输出:{ b: 1 }(未修改)
console.log('新对象 newA :>> ', newA); // 输出:{ b: 2 }
⚠️ 注意:浅拷贝仅拷贝对象 "第一层属性"。若对象包含嵌套引用类型(如 { a: 1, b: { c: 2 } }
),嵌套对象仍共享引用,修改嵌套属性会影响原对象。
3.2.2 深拷贝:适用于嵌套对象
若对象存在多层嵌套(如案例 2 中的 c
对象),需用深拷贝 ------ 完全复制所有层级数据,生成独立新对象。
常用深拷贝方式:
-
JSON.parse(JSON.stringify(x))
:简单高效,适用于多数场景,但不支持bigint
、symbol
、函数、循环引用; -
递归实现深拷贝:自定义递归逻辑,处理所有数据类型(复杂场景推荐);
-
第三方库 :如 Lodash 的
_.cloneDeep()
(成熟稳定,适合生产环境)。
示例代码(案例 2 解析):
javascript
ini
const c1 = { c1: 'c1' };
const c = { a: 1, c: c1 }; // 嵌套对象
const fn2 = (x) => {
// 深拷贝:完全切断与原对象的所有引用
const newX = JSON.parse(JSON.stringify(x));
newX.a = 2; // 修改新对象属性
newX.c.c1 = 'c1-1'; // 修改新对象嵌套属性
return newX;
};
const newC = fn2(c);
console.log('原对象 c :>> ', c); // 输出:{ a: 1, c: { c1: 'c1' } }(未修改)
console.log('新对象 newC :>> ', newC); // 输出:{ a: 2, c: { c1: 'c1-1' } }
四、总结与避坑指南
4.1 核心结论
-
JS 函数参数传递只有 "值传递" :形参是实参的 "值拷贝",二者独立;
-
引用类型的 "值" 是内存地址:修改形参的堆数据会影响实参,修改形参引用地址则不会;
-
纯函数是避免副作用的关键:通过拷贝(浅拷贝 / 深拷贝)切断引用关联,确保逻辑可预测。
4.2 避坑指南
- 不直接修改函数的引用类型参数(如
function (obj) { obj.key = value }
),避免产生副作用; - 按需选择浅拷贝 / 深拷贝:根据对象嵌套层级选择,避免过度拷贝影响性能;
- 慎用
JSON.parse(JSON.stringify())
:注意其不支持的数据类型,复杂场景推荐_.cloneDeep()
或自定义递归拷贝。