通过从头构建来理解数组 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