通过从头构建来理解数组 reduce

2025-06-08

通过从头构建来理解数组 reduce

最近,我在 Twitter 上参与了一个讨论,提到我曾经也觉得 Array Reduce 很难理解。乍一看,它完全没有意义。它的名称和签名,与 、 和 等方法相比,都显得map有些filter陌生forEach

但是如果我告诉您上述每种方法实际上都只是的专门化呢reduce

为什么叫“reduce”?我其实不太清楚。但我记得它的作用是,通常情况下,你把一个数组“缩减”成其他东西。这仍然有点用词不当,因为你可以用 reduce 来创建一个新的、更大的数组。但我说的“reduce”,更像是烹饪中的“reduction”。你取出一个数组,然后通过一个过程把它变成其他东西。


从基本循环开始

当然,我们已经有办法做到这一点,而无需任何方法。考虑以下几点:

const numbers = [1, 2, 3];
const plus1 = [];

for (let i = 0; i < numbers.length; i++) {
  const item = numbers[i];
  plus1.push(item + 1);
}

console.log(plus1); // [2, 3, 4]

这里,我们有一个源数组 ,并对其进行循环,然后通过将源数组中元素的值推送到numbers数组 中来更新一些现有状态。总的来说,这种方法高效且简单。plus1

现在假设我们想将这个过程重构成几个部分,以便测试或以其他方式重用它。我们可以在循环内部的函数中完成这项工作:

function add1AndAppend(arr, item) {
  arr.push(item + 1);
}

const numbers = [1, 2, 3];
const plus1 = [];

for (let i = 0; i < numbers.length; i++) {
  add1AndAppend(plus1, numbers[i]);
}

console.log(plus1); // [2, 3, 4]

现在我们add1AndAppend每次循环都会调用这个函数。这还不错,但效果并不好。首先,它不是一个“纯”函数,它实际上会改变我们传递给它的数组。这意味着它可能会以不良的方式运行,或者以后处理起来很麻烦,因为需要考虑更多因素。(关于共享可变状态的危险性,已经有很多文章进行了探讨。)

因此我们可以重构它,每次都返回一个新数组,使其成为“纯”数组,事实上,我甚至会将其重命名为add1AndConcat

function add1AndConcat(arr, item) {
  return [...arr, item + 1];
}

const numbers = [1, 2, 3];
let plus1 = [];

for (let i = 0; i < numbers.length; i++) {
  plus1 = add1AndConcat(plus1, numbers[i]);
}

console.log(plus1); // [2, 3, 4]

现在我们有了这个可以轻松测试的方法,它将接受一个数组和一个项,并将 1 添加到该项,然后创建一个新数组,其中包含旧数组中的项和新项加 1。我们可以重用它,我们可以测试它:

expect(add1AndConcat([1, 2], 4)).toEqual([1, 2, 5]);

创建原始的 reduce 方法

如果我们有一种方法可以为我们完成这些事情,那不是很好吗(是的,有map,但这不是我们在这里学习的内容)。

function add1AndConcat(arr, item) {
  return [...arr, item + 1];
}

// This isn't the "real reduce" yet.
// Also, don't augment types like this in JavaScript. It's bad.
Array.prototype.reduce = function (callback) {
  let result = [];

  for (let i = 0; i < this.length; i++) {
    result = callback(result, this[i]);
  }

  return result;
};

const numbers = [1, 2, 3];

const plus1 = numbers.reduce(add1AndConcat);

console.log(plus1); // [2, 3, 4]

现在,如果我们能用这个方法做更多的事情,岂不是更好?比如,如果我们不总是希望结果是一个数组怎么办?如果我们想要一个对象?或者一个数字怎么办?我们需要能够将result初始化的内容更改为:

Array.prototype.reduce = function (callback, initialState) {
  let result = initialState;

  for (let i = 0; i < this.length; i++) {
    // We can pass the index to the callback too, because why not?
    result = callback(result, this[i], i);
  }

  return result;
}

// and we'd call it like so:
const plus1 = numbers.reduce(add1AndConcat, []);

这真的很有用!现在我们可以用它来做各种各样的事情了。比如,我们可以把一个数组转换成一个对象:

const keysAndValues = ['x', 20, 'y', 30, 'z': 3, 'name', 'Emma' ];

function toAnObject(obj, item, i) {
  if (i % 2 === 0) {
    // keys
    obj[item] = undefined;
  } else {
    // values
    obj[keysAndValues[i - 1]] = item;
  }

  return obj;
}

const obj = keysAndValues.reduce(toAnObject, {});
console.log(obj); // { x: 20, y: 30, z: 3, name: "Emma" }

但是等等!糟了!我们没法测试这个函数,因为它不是“纯函数”,而是keysAndValues以共享状态关闭的。那么,如果我们在回调函数中再加一个参数,也就是源数组,会怎么样呢?

Array.prototype.reduce = function (callback, initialState) {
  let result = initialState;

  for (let i = 0; i < this.length; i++) {
    result = callback(result, this[i], i, this);
  }

  return result;
}

function toAnObject(obj, item, i, source) {
  if (i % 2 === 0) {
    // keys
    obj[item] = undefined;
  } else {
    // values
    obj[source[i - 1]] = item;
  }

  return obj;
}

const obj = keysAndValues.reduce(toAnObject, {});
console.log(obj); // { x: 20, y: 30, z: 3, name: "Emma" }

现在我们可以测试一下:

const source = ['a', 1, 'b', 2];
expect(toAnObject({}, 'a', 0, source)).toEqual({ a: undefined });
expect(toAnObject({ a: undefined }, 1, 1, source)).toEqual({ a: 1 });
expect(toAnObject({ a: 1 }, 'b', 2, source)).toEqual({ a: 1, b: undefined, });
expect(toAnObject({ a: 1, b: undefined }, 2, 2, source)).toEqual({ a: 1, b: 2 });


没有第二个论点

可能是 reduce 最令人困惑的行为

有一个大家通常不太理解的怪癖:如果你不传递初始状态会发生什么reduce?第二个参数实际上是可选的。

如果未提供初始状态,则 Reducer 函数(回调)将“跳过”数组中的第一个值并将其用作初始状态。以下两点是等效的:

[a, b, c].reduce(fn, INIT);

// is the same as

[INIT, a, b, c].reduce(fn);

这使得我们上面的伪 reduce 方法变得更加复杂:

Array.prototype.reduce = function (callback, initialState) {
  const hasInitialState = arguments.length > 1;

  let result = initialState;

  for (let i = 0; i < this.length; i++) {
    if (i === 0 && !hasInitialState) {
      result = this[i];
    } else {
      result = callback(result, this[i], i, this);
    }
  }

  return result;
}


通过 reduce 进行 DIY 地图和过滤器:

好吧,我们已经用上面制作了一个“地图” add1AndConcat,但是让我们在这里制作一张假地图:

地图

Array.prototype.map = function (callback) {
  return this.reduce(
    (result, item, i, source) =>
      [...result, callback(item, i, source)],
    []
  );
}

过滤器与此大同小异,但这次我们在决定附加到结果之前对谓词进行断言:

筛选

Array.prototype.filter = function (callback) {
  return this.reduce(
    (result, item, i, source) =>
      callback(item, i, source) ? [...result, item] : result,
    []
  );
}

世界上的 Reduce 和 Reducer 函数

数组 reduce 的回调函数被称为“reducer”,近年来,它的形式因 Redux、NgRx 和 RxJS 等库而广受欢迎。它是一个函数签名,用于创建一个纯函数,该纯函数能够处理传递一些预先存在的状态以及一些值(例如一个 action 或其他数组项),然后返回一个新的状态。在 TypeScript 中,可以这样声明(非常宽泛,如下所示):

type ReducerFunction<T, S> = (currentState: S, item: T, index: number) => S; // returns new state

虽然 Redux、RxJS 和 NgRx 都以“异步”的方式处理状态,这与我们在 Array Reduce 中看到的同步行为不同,但它们的原理完全相同。底层状态会被初始化和维护,并在每次调用时传递给回调函数。在 RxJS、Redux 和 NgRx 中,最终状态需要订阅才能观察。

在 RxJS 中可以这样表达scan

import { of } from 'rxjs';
import { scan } from 'rxjs/operators';

function toSquares(result, number) {
  return [...result, number * number];
}

of(1, 2, 3).pipe(
  scan(toSquares, []);
).subscribe(x => console.log(x));

/**
 * [1]
 * [1, 4]
 * [1, 4, 9]
 */

但请注意,我们可以使用 Array Reduce 重复使用相同的 Reducer:

[1, 2, 3].reduce(toSquares, []); // [1, 4, 9]

特别感谢@EmmaBostian启发我写下这篇文章。这些知识我早已掌握,并且习以为常。希望这篇文章对大家有所帮助。

鏂囩珷鏉ユ簮锛�https://dev.to/benlesh/understanding-array-reduce-by-building-it-from-scratch-1fk7
PREV
一起构建 Web 组件!第二部分:Gluonjs 的 Polyfill
NEXT
🤓🤓 顶级 VS Code 扩展和设置,助您成为更高效的开发人员 2 设置扩展