如何手写数组的 map、filter 和 reduce?
简化版
map 返回等长新数组,filter 返回满足条件的新数组,reduce 把数组折叠成一个累计值。手写时要注意跳过稀疏数组空洞、回调参数顺序、thisArg、初始值缺省以及空数组调用 reduce 的错误边界。
详细版
基础实现要遵循回调签名:callback(value, index, array)。map/filter 可从左到右遍历并判断 i in arr;reduce 若没有传初始值,需要找第一个存在的元素作为初始累计值。
Array.prototype.myMap = function (callback, thisArg) {
const arr = Object(this)
const len = arr.length >>> 0
const result = new Array(len)
for (let i = 0; i < len; i += 1) {
if (i in arr) {
result[i] = callback.call(thisArg, arr[i], i, arr)
}
}
return result
}
面试官常追问稀疏数组,比如 [1, , 3].map(...) 中间空洞不会调用回调。教学版可以覆盖核心语义,不必完整实现 ArraySpeciesCreate 等规范细节。
完整版教学
一、三者的语义差异
| 方法 | 输入输出关系 | 是否改变原数组 | 典型用途 |
|---|---|---|---|
map | n 个元素映射成 n 个位置 | 不改变 | 数据转换 |
filter | n 个元素筛出 0 到 n 个元素 | 不改变 | 条件筛选 |
reduce | n 个元素折叠成 1 个累计值 | 不改变 | 汇总、分组、链式计算 |
map 关注“一一对应”,filter 关注“是否保留”,reduce 关注“累计状态如何变化”。三者都不是简单 for 循环替代品,而是把不同的数据处理意图固化成 API。
记忆钩子:
map看返回值,filter看真假,reduce看累计器。
二、手写 map
Array.prototype.myMap = function (callback, thisArg) {
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function')
}
const arr = Object(this)
const len = arr.length >>> 0
const result = new Array(len)
for (let i = 0; i < len; i += 1) {
if (i in arr) {
result[i] = callback.call(thisArg, arr[i], i, arr)
}
}
return result
}
Object(this) 让类数组对象也能借用方法。length >>> 0 把 length 转成非负 32 位整数,是很多数组 polyfill 的常见写法。
三、手写 filter
Array.prototype.myFilter = function (callback, thisArg) {
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function')
}
const arr = Object(this)
const len = arr.length >>> 0
const result = []
for (let i = 0; i < len; i += 1) {
if (i in arr) {
const value = arr[i]
if (callback.call(thisArg, value, i, arr)) result.push(value)
}
}
return result
}
filter 返回的是原元素本身,不是回调返回值。回调只负责给出是否保留的判断,任何 truthy 值都表示保留。
四、手写 reduce
Array.prototype.myReduce = function (callback, initialValue) {
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function')
}
const arr = Object(this)
const len = arr.length >>> 0
let index = 0
let accumulator
if (arguments.length >= 2) {
accumulator = initialValue
} else {
while (index < len && !(index in arr)) index += 1
if (index >= len) throw new TypeError('Reduce of empty array with no initial value')
accumulator = arr[index]
index += 1
}
for (; index < len; index += 1) {
if (index in arr) {
accumulator = callback(accumulator, arr[index], index, arr)
}
}
return accumulator
}
reduce 最容易错在初始值。[].reduce(fn) 应该抛错,[].reduce(fn, 0) 应该返回 0;[,,3].reduce(fn) 会把 3 当作初始累计值,回调一次都不执行。
五、稀疏数组为什么要用 i in arr
const arr = [1, , 3]
let count = 0
arr.map(() => count += 1)
console.log(count) // 2
空洞不是值为 undefined 的元素,而是这个下标不存在。i in arr 能区分空洞和显式的 undefined,这也是手写数组方法时最常见的细节题。
[1, , undefined]
index 0: 存在,值 1
index 1: 不存在,空洞
index 2: 存在,值 undefined
六、复杂度与副作用边界
三者时间复杂度通常都是 O(n)。map 会分配等长结果数组,空间复杂度 O(n);filter 最多也可能保留全部元素;reduce 的额外空间取决于累计值。
回调可以修改原数组,这会带来难读的边界。简化版教学通常固定初始长度,再按下标访问,避免新增元素无限参与遍历;生产环境更应该避免在这些回调里修改正在遍历的数组。
七、常见误区与追问
- 误区:
map可以用来过滤元素。map返回等长结构,过滤应使用filter。 - 误区:
filter返回回调的返回值数组。 它返回原数组中通过测试的元素。 - 误区:
reduce没有初始值也总能运行。 空数组或全空洞数组会抛错。 - 追问:为什么要跳过空洞? 原生数组迭代方法不会对不存在的下标调用回调。
- 追问:
thisArg有什么用? 它指定非箭头回调执行时的this。 - 追问:类数组能借用吗? 可以,通过
Object(this)和 length 读取支持主要场景。
八、加强记忆
数组三件套可以按“变、筛、归”记:map 负责把每个位置变成新值,filter 负责筛出留下的元素,reduce 负责把一串元素归并成一个累计结果。手写时抓住回调参数、返回结构、稀疏数组和初始值边界,就能从普通 for 循环写到接近原生语义。