关于 Array#reduce 你需要知道的一切
从我的网站博客交叉发布。
Array#reduce
,或者Array.prototype.reduce
(下文简称为reduce
)是我最喜欢的 JavaScript 标准库函数。虽然可能需要一些时间来适应,但它绝对值得你付出努力。一旦reduce
完全掌握了 的强大功能,它就能让你以声明式、易读的方式重新定义复杂的逻辑。
这篇文章主要分为两部分:1)解释什么reduce
是 Reduce 以及它的工作原理;2)演示一些你可能之前没有考虑过的 Reduce 的有趣应用。如果你是一位经验丰富的 Reduce 爱好者reduce
,那么解释部分将作为复习。你可以直接跳到演示部分。
什么是 reduce?
简而言之,reduce
它是一个函数,用于将数组缩减为单个值。这个值,我们称之为缩减后的值,可以是任何您想要的类型。您经常会发现,根据具体情况,需要将数组缩减为众多 JavaScript 基本类型之一,例如对象、数字、布尔值,甚至是另一个数组(我们稍后会看到一些示例!)。但是,缩减后的值并不局限于基本类型。缩减后的值可以是任何您想要的类型,例如Map
、Set
或项目定义的任何自定义类型。
在 JavaScript 中,reduce
函数是在Array
原型(Array.prototype.reduce
)上定义的,这意味着你可以在任何数组实例上调用它:
const myArray = [1, 2, 3];
const reducedValue = myArray.reduce(/* args */);
如何减少工作?
您调用的数组reduce
描述了您想要减少的内容,而传入的参数reduce
描述了您希望如何从数组中构建减少后的值。的MDN 文档reduce
非常详细地描述了 的输入和输出reduce
。快去看看吧!我会在这里做一个高阶概述。
参数
- 函数
reducer
。不要将其与 Redux 中使用的状态管理 Reducer 函数混淆。虽然概念相似,但它们并不相同。 - 减少循环的初始值。
Reducer 函数
当你调用reduce
数组时,reduce
它会逐个元素地迭代数组,并为每个元素调用一次 Reducer 函数。reduce
调用 Reducer 函数时,它会传入以下四个参数:
- 累加器
- 当前元素
- 当前指数
- 源数组
暂时不用太担心最后两个参数。实际使用中,我很少用到它们。
累加器(有时也称为收集器)是表示对数组中每个元素(直到当前元素,但不包括当前元素)调用 Reducer 函数的结果的值。它实际上是“迄今为止的缩减值”。Reducer 函数的本质如下:
Reducer 函数描述了如何将累加器(迄今为止的减少值)与数组的当前元素结合起来,生成下一次迭代的累加器。
初始值(reduce
的第二个参数)作为reducer函数第一次调用的累加器,reducer函数最后一次调用的返回值是该调用最终返回的最终reducer值reduce
。
案例研究:sum
函数
我们都熟悉这个sum
函数。我们来看一个简单的实现:
function sum(numbers) {
let sumSoFar = 0;
for (const number of numbers) {
sumSoFar += number;
}
return sumSoFar;
}
关于这个函数,可能不太明显的sum
是,它实际上只是 的一个特例reduce
。sumSoFar
变量充当累加器:
function sum(numbers) {
let accumulator = 0;
for (const number of numbers) {
accumulator += number;
}
return accumulator;
}
循环体for
描述了如何将当前元素(number
)与当前累加器组合起来,生成下一个用于下一次迭代的累加器。这听起来应该很熟悉!reduce
这就是 Reducer 函数的工作:
function sum(numbers) {
let accumulator = 0;
for (const number of numbers) {
accumulator = reducer(accumulator, number);
}
return accumulator;
}
function reducer(accumulator, currentElement) {
return accumulator + currentElement;
}
注意,我们通过将计算下一个累加器的逻辑移到 Reducer 函数中,创建了一个抽象层。现在,我们离实际实现已经非常接近了reduce
。让我们通过重命名一些函数,并允许传入 Reducer 函数和初始值来完成它:
function reduce(array, reducer, initialValue) {
let accumulator = initialValue;
for (const currentElement of array) {
accumulator = reducer(accumulator, currentElement);
}
return accumulator;
}
支持 Reducer 函数的最后两个参数(数组索引和数组本身)很简单。为了跟踪当前数组索引,我们可以切换到标准for
循环而不是for...of
:
function reduce(array, reducer, initialValue) {
let accumulator = initialValue;
for (let i = 0; i < array.length; ++i) {
accumulator = reducer(accumulator, array[i], i, array);
}
return accumulator;
}
最后但同样重要的一点是,使用原生 JavaScript函数时reduce
,我们不需要传入数组,因为我们直接调用的reduce
是数组。为了便于说明,代码如下所示,但请记住,我们不会将其运行在生产环境中。通常情况下,没有必要覆盖原生 JavaScript 函数的行为:
Array.prototype.reduce = function(reducer, initialValue) {
let accumulator = initialValue;
for (let i = 0; i < this.length; ++i) {
accumulator = reducer(accumulator, this[i], i, this);
}
return accumulator;
}
请注意,当函数在 上定义时Array.prototype
,我们可以将数组本身引用为this
。
Reduce 有哪些应用?
让我们看一些实际的 reduce 函数示例!
接下来的一些示例展示了在 上定义的函数Array.prototype
。请注意,我并不建议在生产环境中运行此类代码。这些示例旨在演示如何实现一些原生方法Array.prototype
。在实践中,我们始终希望使用现有的原生实现,而不是用我们自己的实现覆盖它们。
函数sum
我们已经看到了如何稍微修改一下简单的 sum 函数来使其成为实际reduce
函数,但让我们重新回顾一下sum
如何使用 reduce 来编写它:
function sum(numbers) {
return numbers.reduce((accumulator, currentElement) => {
return accumulator + currentElement;
}, 0);
}
注意初始值,0
以及 Reducer 函数如何简单地将当前元素添加到累加器以生成下一个累加器。通过利用,reduce
我们解锁了一种极具声明性的方式来编写这个求和循环。
虽然accumulator
和currentElement
在循环上下文中是合理的变量名reduce
,但你会发现在实践中,通常有更适合代码上下文的更好的名称。例如,对于sum
函数来说,名称sumSoFar
和number
传达了更具体的含义,并且可能对其他人(甚至是你自己!)在代码审查期间或将来阅读代码时更有帮助:
function sum(numbers) {
return numbers.reduce((sumSoFar, number) => {
return sumSoFar + number;
}, 0);
}
函数map
这个map
函数极其有用,应该挂在你的工具带上,以便快速轻松地使用。如果没有,可以去MDN 上看看Array.prototype.map
。
map
对数组的每个元素调用一个函数(由您定义的“mapper”函数),并返回一个包含每个函数调用结果的新数组。
以下是map
实际操作的一个例子:
function addOneToEach(numbers) {
return numbers.map((number) => number + 1);
}
addOneToEach([1, 2, 3]) // [2, 3, 4]
大多数人可能还没意识到map
,这其实只是 的一个特例reduce
!与 不同sum
, 我们会将数组缩减为数字,而map
则是将数组缩减为另一个数组。因此,我们传递一个空数组作为初始值。如下所示:
Array.prototype.map = function(mapperFn) {
return this.reduce((accumulator, currentElement) => {
const mappedCurrentElement = mapperFn(currentElement);
return [...accumulator, mappedCurrentElement];
}, []);
}
请注意,reducer 函数需要做的唯一一件事是通过传入的 mapper 函数运行当前元素,然后将其添加到累加器的末尾,累加器被初始化为一个空数组。
map
随着输入数组的大小增长,上述实现将面临严重的性能问题。这是因为 Reducer 函数在每次迭代时都会创建一个新数组,然后将累加器中的元素复制到其中,最后再附加新映射的当前值。如果你进行相关的数学计算,就会发现这种方法的时间复杂度(假设 Mapper 函数的时间复杂度为常数)约为 O(n² )。
这很糟糕,所以我们来修复它!与其在每次迭代时创建一个新数组,不如在整个归约过程中一直使用同一个数组。在每次迭代中,我们可以将映射的当前元素推送到数组中,并在下一次迭代中返回它:
Array.prototype.map = function(mapper) {
return this.reduce((accumulator, currentElement) => {
const mappedCurrentElement = mapper(currentElement);
accumulator.push(mappedCurrentElement);
return accumulator;
}, []);
}
这种方法有两个好处:
- 我们将时间复杂度提高到线性(或 O(n))时间,并且
- 作为初始值传入的数组与最终返回的数组是同一个。
函数filter
这又是一个你需要熟悉的!如果你不熟悉,可以去MDN 上查看一下。
filter
对数组的每个元素调用一个函数(由您定义的“过滤器”函数),并返回一个新数组,该数组包含原始数组中过滤器函数返回真值的所有元素。
以下是“过滤器”实际运行的示例:
function removeUndefined(array) {
return array.filter((x) => x !== undefined);
}
removeUndefined([1, true, undefined, 'hi']); // [1, true, 'hi']
可能不太明显的是,filter
也只是 的一个特例reduce
!它使用 reduce 循环的实现与 非常相似map
。唯一的区别是map
的 reducer 函数无条件地将映射元素附加到累加器,而filter
的 reducer 函数则根据使用该元素调用 filter 函数的结果,有条件地将原始元素附加到累加器。它如下所示:
Array.prototype.filter = function(filterFn) {
return this.reduce((accumulator, currentElement) => {
if (filterFn(currentElement)) {
accumulator.push(currentElement);
}
return accumulator;
}, []);
}
凉爽的!
函数some
不要与sum
我们已经讨论过的函数混淆。该some
函数通常不如 和 知名map
,filter
但它有一些用例,绝对值得在你的工具箱中扮演辅助角色。如果你是 的新手,可以去看看some
。
some
对数组的每个元素调用一个函数(即由你定义的“测试函数”),并返回一个布尔值,指示测试函数是否对数组中任何元素返回了真值。一旦遇到测试函数返回真值的元素,它就会立即终止数组迭代,从而尽可能地“缩短”计算时间。
以下是some
实际操作的一个例子:
function gotMilk(array) {
return array.some((x) => x === 'milk');
}
gotMilk(['juice', 'water']); // false
gotMilk(['juice', 'milk', 'water']); // true
你可能已经猜到这是怎么回事了……没错——some
它实际上只是 的一个特例reduce
。与sum
(我们将其化简为数字)和map
以及filter
(我们将其化简为数组)不同,some
我们会将其化简为布尔值。布尔累加器指示到目前为止数组中的任何值是否已从测试函数返回真值。因此,我们将累加器初始化为false
,一旦它翻转为 ,true
我们就停止对数组的其余部分调用测试函数:
Array.prototype.some = function(testFn) {
return this.reduce((accumulator, currentElement) => {
if (accumulator) { return accumulator; }
return testFn(currentElement);
}, false);
}
reduce
的实现性能some
略低于原生实现。原生实现一旦遇到真值就会停止迭代,而该reduce
实现仅停止调用测试函数,而不会停止迭代。我们可以通过在达到真值时从 Reducer 函数中抛出异常来解决这个问题,然后在外部捕获异常并返回true
。然而,这违背了使用 的初衷reduce
。
some
展示使用的实现的原因reduce
是为了说明函数的想法some
是 函数的一个特例reduce
,尽管some
使用 不能轻易编写的高性能实现reduce
。
还有这些!
与 类似some
,以下Array.prototype
方法都是 的特殊情况reduce
,可以使用简单的 reducer 函数实现:
every
find
findIndex
indexOf
flat
正如我们在 中看到的some
,其中一些函数能够提前终止数组迭代,因此无法使用 高效地实现reduce
。尽管如此,值得注意的是,它们都是在我们希望将数组缩减为单个值的特定情况下实现的。
所以呢?
该reduce
函数代表了一个简单的想法:将数组缩减为单个值。毫不奇怪,它的实现也非常简单。事实上,它非常简单,我们只需对一个简单的sum
函数进行一些微小的修改就可以实现它!
但我们不应reduce
被 的简洁性所迷惑。 的强大和适用性reduce
显而易见,Array
原型中有大量函数(例如map
、filter
和some
)只是 的特例reduce
,可以用简单的 reduce 循环实现。这并不是建议我们应该使用reduce
来代替这些更具体的函数。使用 的特例reduce
(而不是reduce
本身)可以提高代码的可读性!我指出这一点是为了展示 的强大功能reduce
。
力量与美存在于简单之中。它们不需要复杂性。相反,应该尽可能地避免复杂性!这样想:一个简单的问题解决方案将更容易实现。它更不容易意外地写入错误。其他程序员更容易接手并在此基础上进行构建或修改。它也更容易测试。诸如此类!
用伟大的 Edsger W. Dijkstra 的话来说:
简单固然是美德,但要达到这一点需要付出艰辛的努力,也需要接受教育才能真正欣赏它。更糟糕的是:复杂的反而更畅销。
和:
简单是可靠性的先决条件。
简单的解决方案比复杂的解决方案好,这几乎是所有你能想到的方式。难就难在想出简单的解决方案。这项技能需要你用整个职业生涯去培养,而且永远无法完美。
我现在就这些!希望你不仅能从中受到启发,在自己的代码中寻找改进的机会reduce
,还能在条件允许的情况下,寻求更简单的解决方案。长远来看,这一定会有所回报!
编码愉快!
喜欢这篇文章吗?
在 Twitter 上关注我,我会在那里发布关于前端的推文:@thesnups
鏂囩珷鏉yu簮锛�https://dev.to/thesnups/everything-you-need-to-know-about-array-reduce-2cc9