前端知库

怎样手写数组的 map、filter 和 reduce?

三个方法都读取 length,按下标遍历,用 in 判断跳过稀疏数组的空位。map 和 filter 支持 thisArg 参数;reduce 没有 thisArg,不传初始值时用第一个存在的元素当起点,数组里一个元素都没有时抛 TypeError。

参考答案

简答

面试官让你在不用原生方法的前提下写一个 myMap,最简单的写法是 for 循环加 push,但这样会漏掉几个细节:稀疏数组的空位、thisArg 参数,还有 reduce 不传初始值时的行为。

三个方法的骨架一样:先检查回调是不是函数,再读取 length,从 0 到 length - 1 逐个下标处理。MDN 写明回调只对“有值的下标”调用,空位会被跳过,所以每个下标要先用 i in obj 判断一下。

不同的地方在于结果怎么收集。map 返回一个同样长度的新数组,空位在结果里仍然是空位;filter 只把回调返回真值的元素放进新数组;reduce 把上一次的返回值传给下一次,最后返回一个值。

要注意 reduce 的两个边界。它不接受 thisArg;不传初始值时,累加器从第一个存在的元素开始,回调从下一个元素开始调用,如果数组里一个元素都没有,就抛出 TypeError。

先写 map 和 filter

下面的实现重点看两处:用 i in obj 跳过空位,用 callback.call(thisArg, ...) 把第二个参数绑成回调里的 this。

JavaScript
function myMap(callback, thisArg) {
  if (typeof callback !== 'function') {
    throw new TypeError(callback + ' is not a function');
  }
  const obj = Object(this);
  const len = obj.length >>> 0;
  const result = new Array(len);
  for (let i = 0; i < len; i++) {
    if (i in obj) {
      result[i] = callback.call(thisArg, obj[i], i, obj);
    }
  }
  return result;
}

function myFilter(callback, thisArg) {
  if (typeof callback !== 'function') {
    throw new TypeError(callback + ' is not a function');
  }
  const obj = Object(this);
  const len = obj.length >>> 0;
  const result = [];
  for (let i = 0; i < len; i++) {
    if (i in obj && callback.call(thisArg, obj[i], i, obj)) {
      result.push(obj[i]);
    }
  }
  return result;
}

Array.prototype.myMap = myMap;
Array.prototype.myFilter = myFilter;

const sparse = [1, , 3];
const doubled = sparse.myMap((x) => x * 2);
console.log(doubled.length); // 3
console.log(1 in doubled); // false
console.log(sparse.myFilter((x) => x > 0)); // [ 1, 3 ]

const limit = { max: 2 };
console.log([1, 2, 3].myFilter(function (x) { return x <= this.max; }, limit)); // [ 1, 2 ]

为什么 doubled 的长度是 3,下标 1 却不存在?因为 [1, , 3] 的第二个位置是空位,不是 undefined。MDN 说 map 的回调只对有值的下标调用,稀疏数组经过 map 以后仍然是稀疏的。实现里用 new Array(len) 先建一个同样长度的空数组,只给存在的下标赋值,空位就自然保留下来了。filter 没有这个问题,它跳过空位,只收集通过的元素,所以结果是 [1, 3]。

i in obj 和 obj[i] !== undefined 不是一回事。[1, undefined, 3] 的第二个位置有值,值恰好是 undefined,原生方法会对它调用回调;只有真正的空位才跳过。

Object(this) 和 length >>> 0 是为了贴近原生方法的“通用”特性。MDN 说这几个方法只要求 this 有 length 属性和整数下标,所以 Array.prototype.map.call(类数组对象, fn) 也能用。>>> 0 把 length 转成非负整数,面试里不写这一步一般也能接受。

thisArg 只对普通函数有用。箭头函数没有自己的 this,传了 thisArg 也不起作用,所以例子里用的是 function。

再写 reduce

reduce 难在没有初始值的情况。下面看累加器从哪里开始,以及什么时候抛错。

JavaScript
function myReduce(callback, ...rest) {
  if (typeof callback !== 'function') {
    throw new TypeError(callback + ' is not a function');
  }
  const obj = Object(this);
  const len = obj.length >>> 0;
  let i = 0;
  let acc;
  if (rest.length > 0) {
    acc = rest[0];
  } else {
    // 没有初始值时,用第一个存在的元素当起点
    while (i < len && !(i in obj)) i++;
    if (i >= len) {
      throw new TypeError('Reduce of empty array with no initial value');
    }
    acc = obj[i++];
  }
  for (; i < len; i++) {
    if (i in obj) {
      acc = callback(acc, obj[i], i, obj);
    }
  }
  return acc;
}

Array.prototype.myReduce = myReduce;

console.log([1, 2, 3].myReduce((a, b) => a + b)); // 6
console.log([].myReduce((a, b) => a + b, 0)); // 0
console.log([, , 5].myReduce((a, b) => a + b)); // 5
console.log([undefined].myReduce((a, b) => a + b, 1)); // NaN
try {
  [].myReduce((a, b) => a + b);
} catch (e) {
  console.log(e instanceof TypeError); // true
}
try {
  [].reduce((a, b) => a + b);
} catch (e) {
  console.log(e.constructor.name); // TypeError
}

为什么用 ...rest 判断有没有初始值,而不是判断 initialValue === undefined?因为调用者可能就是想用 undefined 当初始值,[1].reduce(fn, undefined) 和 [1].reduce(fn) 的行为不同。只有看实际传了几个参数,才能把这两种情况分开。

没有初始值时,MDN 的说法是累加器取数组的第一个值,回调从第二个值开始执行,数组为空就抛 TypeError。再加上“跳过空位”这条规则,[, , 5] 的第一个存在的元素是 5,后面没有别的元素,回调一次也没调用,直接返回 5。[undefined] 加初始值 1 的例子说明空位和 undefined 的区别:undefined 是真实的元素,会参与计算,1 + undefined 得到 NaN。

还要注意 reduce 没有 thisArg 参数。MDN 写明它和其他迭代方法不同,回调总是以 undefined 作为 this 调用,所以实现里直接写 callback(...),不用 call。

常见追问

为什么不直接把方法挂到 Array.prototype 上?

例子里挂上去只是为了演示调用方式。实际项目里修改内置原型会影响所有代码,和以后新增的同名原生方法也可能冲突。写 polyfill 时一般先判断原生方法不存在再补,并且用 Object.defineProperty 设成不可枚举,避免 for...in 遍历时多出这个属性。

能用 reduce 实现 map 和 filter 吗?

能。map 就是 arr.reduce((acc, x, i) => { acc.push(fn(x, i)); return acc; }, []),filter 是满足条件时才 push。不过这样写跳过空位后结果会变成紧凑数组,和原生 map 保留空位的行为不同。

回调里修改原数组会怎样?

原生方法在开始前就读取了 length,遍历过程中新加到末尾的元素不会被访问;被删除的、还没访问到的元素会因为下标不存在而被跳过。上面的实现也是先读 len 再循环,每次用 in 判断,所以行为一致。