先记住这个答案
arguments 是一个类数组对象:有数字下标、有 length,但它的原型是 Object.prototype,所以没有 map、filter、reduce。常见的转换写法有三种。Array.prototype.slice.call(arguments) 是 ES5 时代的老写法,借用数组的 slice 方法。Array.from(arguments) 和 [...arguments] 是 ES6 之后的写法,更直观。如果函数是自己新写的,直接声明剩余参数 function f(...args),args 本身就是数组,不需要转换。
arguments是类数组,没有map、filterArray.from同时支持类数组和可迭代对象- 展开运算符只认迭代器,普通类数组会报错
- 新代码直接用剩余参数
...args
三种写法都能用在 arguments 上
先确认 arguments 确实不是数组,再看三种写法的结果。
function collect() {
console.log(Array.isArray(arguments), typeof arguments.map) // 不是数组,没有 map
const byFrom = Array.from(arguments)
const bySpread = [...arguments]
const bySlice = Array.prototype.slice.call(arguments)
console.log([byFrom, bySpread, bySlice].every(Array.isArray)) // 三个都是真数组
console.log(byFrom.map((n) => n * 2).join(','))
}
collect(1, 2, 3)查看输出与解释
false undefined
true
2,4,6slice.call 能成功,是因为数组的很多方法是「通用」的:它们内部只读 this.length 和 this[0]、this[1] 这些下标,不要求 this 真的是数组。用 call 把 this 换成 arguments,slice() 不传参数就复制出一个完整的新数组。同样的道理,也可以不转换,直接借用:Array.prototype.map.call(arguments, fn)。
[...arguments] 能成功,是因为 ES6 给 arguments 对象加上了 Symbol.iterator,它是可迭代的。这一点很关键,下一节会看到,普通的类数组对象就没有这个待遇。
类数组和可迭代对象不是一回事
这是三种写法真正的区别。类数组指有 length 和数字下标的对象;可迭代对象指实现了 Symbol.iterator 方法的对象。arguments、NodeList 两者都是,但你自己写的 { 0: 'a', length: 1 } 只是类数组。
const likeArray = { 0: 'a', 1: 'b', length: 2 } // 有下标和 length,没有迭代器
console.log(Array.from(likeArray).join(',')) // 按 length 和下标读取
console.log(Array.prototype.slice.call(likeArray).join(',')) // 同样按下标读取
try {
console.log([...likeArray])
} catch (e) {
console.log(e.name) // 展开只认迭代器
}
console.log(Array.from({ length: 3 }, (_, i) => i * i).join(',')) // 第二个参数做映射查看输出与解释
a,b
a,b
TypeError
0,1,4Array.from 的逻辑是先看对象有没有 Symbol.iterator,有就用迭代器,没有就退回到按 length 和下标读取。所以它两种对象都能处理。展开运算符只走迭代器,遇到没有迭代器的普通对象,直接抛 TypeError,提示对象不可迭代。
Array.from 的第二个参数是映射函数,相当于转换完再 map 一次,但少建一个中间数组。Array.from({ length: n }, fn) 也常被用来生成指定长度的数组。
- 空位的处理也不一样
Array.from会把缺失的下标读成undefined,得到一个没有空位的数组。slice.call则会保留空位:Array.prototype.slice.call({ length: 2 })得到的数组里是两个空位,对它判断1 in arr会得到false,用forEach遍历时空位会被跳过。转换arguments遇不到这种情况,但转换自定义的类数组时要知道。
新代码直接用剩余参数
arguments 在非严格模式下还有一个特别的行为:它和形参是联动的。改 arguments[0],第一个形参也跟着变。严格模式下这种联动取消了,转出来的数组更是和形参完全无关。
arguments 联动、转换后的数组和剩余参数JavaScriptfunction sloppy(a) {
arguments[0] = 99 // 非严格模式:会同时改掉 a
return a
}
function copied(a) {
const args = Array.from(arguments)
args[0] = 99 // 改的是新数组,和 a 无关
return a
}
function withRest(first, ...rest) {
return [arguments.length, rest.length, Array.isArray(rest)].join(' ')
}
console.log(sloppy(1), copied(1))
console.log(withRest(1, 2, 3)) // rest 只收剩下的实参查看输出与解释
99 1
3 2 true剩余参数有三个好处。第一,它本身就是真数组,不用再转换。第二,它只收集没有对应形参的那部分实参,上面的 rest 只有 2 和 3,而 arguments 永远包含全部实参。第三,箭头函数没有自己的 arguments,但可以用剩余参数。所以现在写新函数,基本不需要再碰 arguments。
容易答错的地方
- 直接调用
arguments.map arguments虽然长得像数组,也能用for循环和for...of遍历,但原型上没有数组方法,arguments.map(...)会抛TypeError,提示map不是函数。- 以为展开运算符能处理所有类数组
- 展开能用在
arguments、NodeList、Set、字符串上,是因为它们都可迭代,不是因为它们是类数组。接口返回的{ 0: ..., length: n }这种对象,只能用Array.from或slice.call。 - 以为转换后是深拷贝
- 三种写法都只复制了一层。实参如果是对象,新数组里存的还是同一个对象的引用,改对象的属性,原来持有它的地方都能看到。
面试官还会怎么问?
为什么很多教程说不要把 arguments 传给别的函数?
早期的 V8 在函数把 arguments 泄漏出去时,比如 return arguments 或者直接传给其他函数,会放弃对这个函数的优化,所以当时的性能建议是在函数内部用 for 循环手动复制。新版引擎已经大幅改善了这个问题,但剩余参数依然是最干净的写法。
arguments.length 和函数的 length 有什么区别?
arguments.length 是这次调用实际传入的实参个数。函数的 length 是声明的形参个数,而且不算剩余参数,也不算有默认值的参数及其后面的参数。比如 function f(a, b = 1, ...c) {} 的 f.length 是 1。
NodeList 和 HTMLCollection 转数组也一样吗?
思路一样,但要注意差别。document.querySelectorAll 返回的 NodeList 可迭代,本身还自带 forEach;getElementsByTagName 返回的 HTMLCollection 是实时更新的集合,现代浏览器里它也可迭代。遍历过程中如果会增删节点,最好先用 Array.from 转成静态数组再操作。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。