文章

手写数组方法深度解析:为什么 [1,2,3].map(parseInt) 输出 NaN?

手写 map/filter/reduce/flat 重点在稀疏数组的 i in arr 检查、reduce 无初始值取首元素、flat 用栈替递归防爆栈。 面试考细节而非背代码。

手写数组方法深度解析:为什么 [1,2,3].map(parseInt) 输出 NaN?

一句话概括

手写 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 的初始化逻辑,这些细节才是区分「背过了」和「真懂了」的分水岭。

本文由作者按照 CC BY 4.0 进行授权