我带前端新人时,有个问题几乎每次都要问:把数组传进函数,在里面 push 一个元素,外面的数组会不会变?能秒答的人不多,能解释清楚的人更少。
不是大家理解能力差,而是这个问题背后藏着一连串概念:数组到底是什么类型、函数参数到底怎么传、变量里存的到底是值还是地址、对象在内存里是怎么被复制的。于是“JS数组+函数+存储形式+传递方式”这几个关键词经常被放在一起搜,因为它们本身就纠缠在一起。
这篇文章我就顺着这条线,把“存储形式决定传递方式”这件事讲透。过程中会用到一些能直接跑的代码片段,也会把我自己踩过、带人时见过的典型坑摆出来,争取让你看完之后,再遇到“变量为什么变了、为什么没变”这一类问题,能有一套自己的判断逻辑。
1. 从三个“看起来不该变却变了”的JS片段说起
先看第一段代码。
javascript复制const arr = [1, 2, 3];
function addItem(list) {
list.push(4);
}
addItem(arr);
console.log(arr); // [1, 2, 3, 4]?
很多刚接触函数的人会想:我不是把 arr 传进函数了吗?函数里操作的应该是它自己的 list,为什么外面的 arr 也变了?
再看第二段。
javascript复制const user = { name: "小明" };
function renameUser(u) {
u = { name: "小红" };
}
renameUser(user);
console.log(user.name); // 小明,不是小红
这段更反直觉:函数里明明给 u 赋值成了小红,为什么外面的 user 纹丝不动?
再看第三段,函数本身也可以被塞进数组、塞进变量、作为参数传来传去:
javascript复制const sayHello = function () {
console.log("hello");
};
const actionList = [sayHello];
actionList[0](); // hello
function callTwice(fn) {
fn();
fn();
}
callTwice(sayHello); // hello hello
为什么函数能赋值给变量?函数在传递的时候,到底是把整个函数体复制了一份,还是只传了一个“指针”?如果是指针,那数组传参为什么看上去是“传引用”,而第二段代码里的对象重新赋值却又“传值”?
这三个问题如果放在一起想,很多人就乱了。当年我自己学到这里,脑子里也是浆糊状态,后来把“存储形式”和“传递方式”拆开看才逐渐清晰。你只要记住一个核心结论:JS 变量里到底存的是数据本身,还是通往内存的地址,决定了一切后续行为。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 存储形式:变量里装的是账单,还是储物柜钥匙
2.1 原始值类型:钱包里直接放钞票
JS 的原始类型一共有七种:string、number、boolean、null、undefined、symbol、bigint。这里有一个容易忽略的点:原始类型的值在内存里是“直接存在变量绑定的那个内存格子里的”。
写 let a = 38,变量 a 的口袋里装的就是一个 38。不是 38 的地址,不是一个包装对象,就是数值本身。当你把它赋值给另一个变量时,JS 会新开一个内存格子,把 38 复制一份放进去:
javascript复制let a = 38;
let b = a;
b = 40;
console.log(a); // 38,a 完全不受影响
这说明原始类型的赋值是“值拷贝”。生活里找类比的话,就是钱包里直接放钞票:你复制一张 50 元钞票给朋友,朋友自己花掉了,和你钱包里的原钞没有任何关系。
这个直觉必须在脑子里立住。因为后面讲引用类型时,最常见的错误就是用“钱包里放钞票”的思路去理解对象和数组,那就会处处撞墙。
2.2 引用类型:手里拿的是储物柜钥匙
JS 里的 Object、Array、Function 都属于引用类型。引用类型和原始类型在存储方式上有本质差别:变量口袋里装的不是完整数据,而是一个地址。真实数据被存放在堆内存里,变量里只保存这个对象在堆里的位置。
打个比方:对象就像放进储物柜里的物品,变量手里拿的不是物品,而是储物柜的钥匙卡。你赋值一个对象给新变量,不是把物品复制了一份,而是多配了一把钥匙卡,两人拿的钥匙能打开同一个柜门。
javascript复制let obj1 = { count: 1 };
let obj2 = obj1; // 复制钥匙卡
obj2.count = 2;
console.log(obj1.count); // 2,因为 obj1 和 obj2 指向同一个对象
数组也一样:
javascript复制let listA = [1, 2];
let listB = listA;
listB.push(3);
console.log(listA); // [1, 2, 3]
明白了这个模型,能解释很多现象:为什么 const arr = [] 之后还能往里面 push?因为 const 限制的是“变量名不能再指向别的地址”,并没有限制“这个地址指向的对象内容不能被修改”。
那新对象呢?arr = [] 这种重新赋值的操作才会触发 const 报错,因为你试图换掉摸个钥匙卡上的房间号。
2.3 函数也是对象,只是它的钥匙比较特殊
接着往前推:函数为什么能赋值给变量、塞进数组、塞进对象属性?因为函数在 JS 里同样是一种对象,而且是“可调用对象”,也就是 callable object。
函数体本身存储在堆内存里,变量里保存的是函数对象的引用。所以:
javascript复制function sayHi() {
console.log("hi");
}
const anotherSayHi = sayHi;
anotherSayHi(); // hi
这里没有任何“复制函数代码”的动作,anotherSayHi 和 sayHi 指向同一个函数对象。因此函数才能作为参数被到处传递,也能作为返回值从函数里被抛出来。这就是“函数是一等公民”的意思。
不过函数有一个特殊待遇:typeof function 会返回 "function",而不是 "object"。这其实是一个历史兼容设计,就是想让开发者好区分。而数组就没这个待遇了,typeof [] 返回的是 "object"。所以判断数组要用 Array.isArray(),判断函数直接用 typeof fn === "function"。
很多人会把 typeof 当成万能工具,实际在判断引用类型时,typeof 并不能告诉你它是数组、日期还是普通对象。普通对象的判断通常用 instanceof 或 Object.prototype.toString。前面这些基础是后面理解传参的垫脚石。
3. 函数传参的真实规则:只有一种“按值传地址”
3.1 参数传递的本质就是一次赋值
函数在被调用时发生了什么?可以近似理解为在函数入口处悄悄执行了一行“形参 = 实参”。
javascript复制function addItem(list) {
// 进入这个函数时,本质发生了一件事:list = arr
list.push(4);
}
因为这个“赋值”把 arr 的引用拷给了形参 list,所以函数内部 list 和外部 arr 持有了同一把储物柜钥匙。list.push(4) 是在“打开储物柜,往里面放东西”,arr 再打开门一看,自然能看见新放进去的 4。
所以不是“数组按地址传”有什么特殊机制,而是所有引用类型都这样。对象可以,函数也可以:
javascript复制function call(fn) {
fn(); // fn 拿到 sayHello 的引用
}
就相当于在 call 函数内部执行了 let fn = sayHello,仅此而已。
3.2 修改内容 vs 改变指向:判断“会不会影响外部”的唯一标准
既然参数传递是赋值,那问题就集中在:函数内部到底是“通过钥匙卡开门改东西”,还是“直接把钥匙卡换掉”。
看这段代码:
javascript复制function replaceList(list) {
list = [100]; // 把形参 list 换成一把新钥匙
}
const original = [1, 2];
replaceList(original);
console.log(original); // [1, 2]
我来拆一下执行过程:
- 调用 replaceList(original),函数内部标记 list = original。此时 list 拿的是 original 那把钥匙。
- 执行 list = [100],JS 在堆里新建了一个数组,然后让 list 指向这个新数组。注意,这个动作只是把 list 手里的钥匙换成了新房间的钥匙。
- original 变量从头到尾没被碰过,它手里依然是原来那间房的钥匙。
所以 original 依然是 [1, 2]。
这就引出了一条特别实用的判断规则:
- 在函数里写
形参.属性 = xxx,或者调用形参.push()、形参.sort()这样能改动对象内部状态的方法,会改变外部对象。 - 在函数里写
形参 = xxx,无论 xxx 是新对象还是新数组,都只是在改变形参这个局部变量的指向,外部变量不会跟着变。
用一句话记忆:你是在改房间里的东西,还是在换手里那把钥匙?
3.3 网上说的“JS按引用传递”,到底错没错
每次聊到这里,都会有人拿出“JS 到底是按值传递还是按引用传递”这个老问题。我的态度是:语言定义要说清楚,但别被绕进去。
严格定义下,JS 所有参数都是按值传递:
- 实参是原始类型时,复制的是数据值本身。
- 实参是引用类型时,复制的是对象的引用(地址)副本。
表面上看,函数内部可以修改外部对象的属性,很像“按引用传递”。但如果真是引用传递,那重新给形参赋值也会同步改变外部变量——JS 做不到这一点。真正的引用传递,比如 C++ 的引用参数,形参和实参是同一个东西,改变形参就会改变实参。
所以学术上更准确的说法叫“按共享传递”(call by sharing):函数内外共享同一个对象,但变量绑定是各自独立的。这个叫法精确,但日常沟通时大家都说“对象是引用传递”,只要内心清楚“传递的是引用副本”,交流不会出大问题。
3.4 箭头函数和回调函数为什么天天跟数组一起出现
回调函数的本质,就是把一个函数引用作为参数传进另一个函数。数组的 map、filter、forEach 这些高阶方法,接收的正是函数引用:
javascript复制const numbers = [1, 2, 3, 4];
const even = numbers.filter(function (num) {
return num % 2 === 0;
});
用箭头函数后更精简:
javascript复制const even = numbers.filter((num) => num % 2 === 0);
这里 filter 内部会遍历数组,拿到每个元素,然后调用你传入的函数引用,根据函数返回的布尔值决定保留还是丢弃。整个过程不复制函数体,而是调用同一个函数对象。
箭头函数和普通函数还有一个区别是:它不是构造函数,不能用 new 调用,也没有自己的 arguments。但作为回调传进数组方法时,它外层的 this 绑定往往更符合直觉,所以现代代码里写过滤逻辑时箭头函数几乎成了默认选择。
4. 数组方法:先分清“原地修改”和“返回新数组”
4.1 原地修改派:push、pop、splice、sort、reverse
既然数组是引用类型,数组方法按“对原数组的影响”分成两派就特别需要重视。很多bug来自“我以为它返回了新数组,结果原数组被改了”。
原地修改派主要有:push、pop、shift、unshift、splice、sort、reverse。它们会直接改变调用它们的数组对象,返回的内容则五花八门:push 返回新长度,pop 返回被删的元素,splice 返回被删元素组成的数组。
最坑的是 sort:
javascript复制const arr = [3, 1, 2];
const sorted = arr.sort();
console.log(arr); // [1, 2, 3]
console.log(sorted); // [1, 2, 3]
console.log(arr === sorted); // true,sort 返回的是原数组
很多人以为 const sorted = arr.sort() 得到了一份新数组,然后放心地继续操作 sorted。实际上 arr 和 sorted 指向同一个数组空间。想保留原顺序,至少要先做一层复制:
javascript复制const sorted = [...arr].sort((a, b) => a - b);
这样 arr 才会保持不变。如果数组里有嵌套对象,那外层数组复制了,但里面的对象仍然共享引用。想彻底隔离又是另一回事。
4.2 返回新数组派:map、filter、slice、concat
另一派方法如 map、filter、slice、concat 不会改变原数组,而是创建一个新数组并返回。它们之间往往还可以链式调用:
javascript复制const list = [1, 2, 3, 4, 5];
const result = list
.filter((x) => x % 2 === 1)
.map((x) => x * 10);
console.log(list); // [1, 2, 3, 4, 5] 不变
console.log(result); // [10, 30, 50]
使用这些方法时,原始数组不会受到污染,因为每次都会在堆里开一间新房,然后把按条件处理后的元素放进去。
这个“改原数组”和“返回新数组”的分野,恰恰也是很多状态管理框架喜欢用不可变数据的原因。如果你在一个函数里无脑调用 sort,调用方持有的数组就可能被偷偷修改,排查起来非常痛苦。
我自己习惯定一条规矩:写函数时如果接收的是数组或对象,默认不去修改入参。需要改变时,就在函数内部先复制再加工,最后返回新值。这样函数对外部的影响是可预测的,调试成本低很多。
4.3 深拷贝与浅拷贝:复制一层和复制到底的区别
前面说的 [...arr]、arr.slice()、Object.assign 都属于浅拷贝。浅拷贝只复制最外层数组或对象,数组里的元素仍然和原始数组共享引用:
javascript复制const original = [{ price: 1 }, { price: 2 }];
const copy = [...original];
copy[0].price = 99;
console.log(original[0].price); // 99,两个数组共享同一个对象
这里 copy 和 original 是两个不同数组,但下标 0 处的对象是同一个。所以修改 copy[0].price,原数组也会变。想让内部对象完全独立,只能上深拷贝。
几种方案按场景选:
- 纯 JSON 安全的场景:
JSON.parse(JSON.stringify(data))。缺点很多,函数、undefined、symbol 会被丢弃,Date 变成字符串,循环引用直接抛错。 - 现代浏览器和 Node 17+:
structuredClone(data),能处理循环引用,最省心。 - 需要兼容旧环境:手写递归深拷贝。
我常用的深拷贝版本会加一个 WeakMap 缓存,用来处理循环引用:
javascript复制function deepClone(value, cache = new WeakMap()) {
if (typeof value !== "object" || value === null) {
return value;
}
if (cache.has(value)) {
return cache.get(value);
}
const clone = Array.isArray(value) ? [] : {};
cache.set(value, clone);
for (const key of Object.keys(value)) {
clone[key] = deepClone(value[key], cache);
}
return clone;
}
这段代码没有覆盖 Date、RegExp 这些特殊对象,但处理日常对象和数组已经够用。想在生产环境直接抄作业,优先用 structuredClone。
5. 把存储与传递套进高频场景:转字符串、判断包含、去重、脚本加载
5.1 判断包含时的“严格相等”陷阱
JS 里判断字符串是否包含子串,最常见的是 str.includes("xx")。数组也有 includes 方法,判断的是数组里是否存在某个元素,内部用严格相等比较。
严格相等对原始值没问题:1 === 1 为 true,"abc" === "abc"
