手写数组方法深度解析:为什么 [1,2,3].map(parseInt) 输出 NaN?
手写 map/filter/reduce/flat 重点在稀疏数组的 i in arr 检查、reduce 无初始值取首元素、flat 用栈替递归防爆栈。 面试考细节而非背代码。
一句话概括
手写 map/filter/reduce/flat 的核心不在于背代码,而在于理解三个面试必问细节:稀疏数组的 i in arr 检查、reduce 无初始值时的取首元素逻辑、以及 flat(depth=Infinity) 如何用栈替代递归避免爆栈。
核心知识点
1. map:跳过空洞 + 保留空洞
原生 map 遇到稀疏数组的空洞不调用回调,但保留空洞位置:
1
2
3
4
5
6
7
8
9
10
11
12
13
Array.prototype.myMap = function (fn, thisArg) {
const arr = Object(this), len = arr.length >>> 0
const res = new Array(len)
for (let i = 0; i < len; i++) {
if (i in arr) res[i] = fn.call(thisArg, arr[i], i, arr)
// ^^^^^^^^^^^^^ 不赋值 → 保留空洞(empty slot)
}
return res
}
const sparse = [1, , 3]
sparse.map(x => x * 2) // [2, empty, 6] ← 不是 [2, undefined, 6]
sparse.myMap(x => x * 2) // [2, empty, 6] ✅
经典陷阱:[1,2,3].map(parseInt) → [1, NaN, NaN]。因为 parseInt 的第二个参数是 radix,map 把 (item, index, array) 全传进去了:parseInt(1,0)=1, parseInt(2,1)=NaN, parseInt(3,2)=NaN。
2. filter:只保留真值
1
2
3
4
5
6
7
Array.prototype.myFilter = function (fn, thisArg) {
const arr = Object(this), len = arr.length >>> 0, res = []
for (let i = 0; i < len; i++) {
if (i in arr && fn.call(thisArg, arr[i], i, arr)) res.push(arr[i])
}
return res
}
filter 自动跳过空洞,[, null, 0, undefined, ''].filter(x => x) → [],因为所有 falsy 值都被过滤。
3. reduce:最复杂的初始化逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Array.prototype.myReduce = function (fn, init) {
const arr = Object(this), len = arr.length >>> 0
let acc = init, i = 0
if (arguments.length < 2) {
// 无初始值:取第一个非空洞元素
while (i < len && !(i in arr)) i++
if (i >= len) throw new TypeError('Reduce of empty array with no initial value')
acc = arr[i++]
}
for (; i < len; i++) {
if (i in arr) acc = fn(acc, arr[i], i, arr)
}
return acc
}
// 三连陷阱:
[].reduce((a,b) => a+b) // TypeError!
[].reduce((a,b) => a+b, 0) // 0
[42].reduce((a,b) => a+b) // 42(不执行回调)
reduce 的万能性:reduce 可以模拟 map、filter、flat、some、every:
1
2
// reduce 实现 filter
const filter = (arr, fn) => arr.reduce((a, v, i) => (fn(v,i) && a.push(v), a), [])
4. flat:栈替代递归,安全支持 Infinity
递归版 flat 遇到深嵌套容易栈溢出,用显式栈可解决:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Array.prototype.myFlat = function (depth = 1) {
const result = [], stack = []
// 逆序入栈保持原始顺序
for (let i = this.length - 1; i >= 0; i--) {
if (i in this) stack.push({ val: this[i], d: depth })
}
while (stack.length) {
const { val, d } = stack.pop()
if (Array.isArray(val) && d > 0) {
for (let i = val.length - 1; i >= 0; i--) {
if (i in val) stack.push({ val: val[i], d: d - 1 })
}
} else {
result.push(val)
}
}
return result
}
// 深度嵌套不爆栈
const arr = [[[[[[[[[[[1]]]]]]]]]]]
arr.myFlat(Infinity) // [1] ✅
5. 所有方法的通用模板
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Array.prototype.myXxx = function (callback, thisArg) {
// 1. null/undefined 检查
if (this == null) throw new TypeError('this is null or undefined')
// 2. 回调类型检查
if (typeof callback !== 'function') throw new TypeError('callback is not a function')
// 3. 获取数组 + 长度快照
const arr = Object(this)
const len = arr.length >>> 0 // 无符号右移,保证是 32 位正整数
// 4. 遍历(跳过空洞)
for (let i = 0; i < len; i++) {
if (i in arr) { /* 处理 arr[i] */ }
}
// 5. 返回结果
}
其实你每天都在用
- 后端数据清洗 —
users.map(u => ({ name: u.name, age: u.age }))提取字段;orders.filter(o => o.status === 'paid')筛选已支付订单;cart.reduce((sum, i) => sum + i.price, 0)算总价 - React 列表渲染 —
items.map(item => <Card key={item.id} {...item} />)就是 map - JSON 数据展平 — 后端返回的嵌套评论树
comments.flat(Infinity)全部展平 - 处理 API 响应 —
responses.filter(r => r.ok).map(r => r.json())先过滤失败再提取 body - sort/groupBy —
arr.sort((a,b) => a-b)+filter()+map()的组合拳,几乎每个业务需求都离不开
常见误解(FAQ)
❌ 误区:「flat() 不传参数默认展开一层」
flat() 默认 depth = 1。要全部展开用 flat(Infinity)。另外 arr.flat(0) 返回浅拷贝(等价 arr.slice())。
❌ 误区:「reduce 的初始值是可选的所以传 0 或 undefined 效果一样」
完全不一样。[1].reduce(fn) 不执行 fn 直接返回 1;[1].reduce(fn, 0) 执行 fn(0, 1)。更关键的是 ['a','b'].reduce((a,b) => a+b, '') 可以正确处理字符串拼接,而无初始值时 a 是 'a'(字符串),b 是 'b'。
❌ 误区:「手动实现数组方法时用 this.length 足够安全」
应该用 this.length >>> 0。它能把非数字转为 0、负数转为大整数(补码),确保 length 是 32 位非负整数。这是 ECMA 规范的要求。
❌ 误区:「map 的回调不执行是因为元素是 undefined」
不执行是因为索引不存在(空洞),而不是值是 undefined。[undefined].map(x => x * 2) 会执行回调,返回 [NaN];但 [,].map(x => x * 2) 不执行回调,返回 [empty]。
一句话总结
数组方法的手写题,表面上考你会不会写迭代,实际上考你对 ECMA 规范的理解深度——i in arr 检查、length >>> 0 转换、arguments.length < 2 的初始化逻辑,这些细节才是区分「背过了」和「真懂了」的分水岭。