🚀 JavaScript 神器 reduce()
:从入门到精通,彻底掌握数组的"降维打击"
嘿,各位前端同学!在我们的日常开发中,和数组打交道就像呼吸一样自然。我们用 forEach
遍历,用 map
转换,用 filter
筛选。但当需求变得复杂,比如"计算购物车商品总价"、"统计投票结果"或者"将二维数组展平"时,你是否曾用一连串的 for
循环和临时变量把自己绕晕?
别担心,今天我们要聊的,就是专门解决这类问题的"终极武器"------Array.prototype.reduce()
。
很多人对 reduce
的第一印象是:强大,但有点绕。它就像一把瑞士军刀,功能繁多,初见时不知从何下手。但一旦你理解了它的核心思想,你就会爱上这种将复杂问题优雅地"浓缩"成一行代码的快感。
这篇文章的目标,就是带你彻底撕掉 reduce
"难懂"的标签。我们将不只停留在"会用"的层面,而是通过从零开始手写一个 myReduce
,让你真正洞悉其内部的每一个细节,成为真正的 reduce
大师!
💡 什么是 reduce
?
在我们动手造轮子之前,先快速回顾一下 reduce
的"官方定义"。
根据 MDN 文档,reduce()
方法对数组中的每个元素按序执行一个你提供的 "reducer" 回调函数 ,每一次运行 reducer 会将先前元素的计算结果作为参数传入,最后将其结果汇总为单个返回值。
听起来有点抽象?没关系,看下它的语法结构:
javascript
array.reduce(callbackFn, initialValue);
这里的关键是两个参数:
-
callbackFn
(回调函数) :这就是reduce
的核心引擎,它会在数组的每个元素上运行一次。这个函数本身接收四个参数:accumulator
(累加器):灵魂角色!它是上一次回调函数执行后的返回值。它的初始值由initialValue
决定。currentValue
(当前值):数组中正在被处理的那个元素。currentIndex
(当前索引):当前元素的索引。array
(源数组):调用reduce()
的那个数组本身。
-
initialValue
(初始值) :可选参数,但极其重要!它决定了accumulator
的"第一滴血"来自哪里。- 如果提供了
initialValue
:accumulator
的初始值就是它,reduce
会从数组的第一个元素(索引 0)开始执行。 - 如果未提供
initialValue
:accumulator
会被初始化为数组的第一个元素 ,reduce
则从数组的第二个元素(索引 1)开始执行。
- 如果提供了
是不是感觉清晰了一点?别急,当我们亲手实现它时,你会豁然开朗。
⚙️ 动手造轮子:从零实现一个 myReduce
理论讲千遍,不如代码敲一遍。现在,让我们卷起袖子,根据官方规范,一步步实现我们自己的 myReduce
。
javascript
// filepath: c:\test\02-reduce.js
Array.prototype.myReduce = function (callbackFn, initialValue) {
// 'this' 指向调用 myReduce 方法的数组实例。
// 保存 this 是一个好习惯,能确保上下文正确,提高代码可读性。
const arr = this;
// 1. 边界检查:防御性编程是专业素养
if (arr == null) {
throw new TypeError("Array.prototype.myReduce called on null or undefined");
}
if (typeof callbackFn !== "function") {
throw new TypeError(callbackFn + " is not a function");
}
let accumulator;
let k = 0; // k 是用来遍历数组的索引
// 2. 核心逻辑:确定 accumulator 的初始状态
// arguments.length >= 2 是一个巧妙的方式,判断调用时是否传入了 initialValue
if (arguments.length >= 2) {
accumulator = initialValue;
} else {
// 当没有 initialValue 时,事情变得有趣了!
// 我们必须找到数组中的第一个"真实"元素作为初始值。
// 这段循环是为了跳过稀疏数组中的"空槽" (empty slots)。
while (k < arr.length && !(k in arr)) {
k++;
}
// 如果数组为空,且没有初始值,这是不允许的!
if (k >= arr.length) {
throw new TypeError("Reduce of empty array with no initial value");
}
// 将找到的第一个有效元素设为 accumulator,并从下一个元素开始遍历
accumulator = arr[k];
k++;
}
// 3. 主循环:遍历数组,执行回调
while (k < arr.length) {
// 再次使用 `k in arr` 确保我们只处理存在的元素,跳过空槽。
if (k in arr) {
// 这就是魔法发生的地方!
// callbackFn 的返回值,会成为下一次循环的 accumulator。
accumulator = callbackFn(accumulator, arr[k], k, arr);
}
k++;
}
// 4. 返回最终结果
return accumulator;
};
代码剖析:
- 稀疏数组与
in
操作符 :你可能会好奇!(k in arr)
是什么操作。这是处理像[1, , 3]
这种稀疏数组的关键。in
操作符可以准确地检测一个索引是否存在于数组中,而不是仅仅判断它是否为undefined
。这是许多原生数组方法(包括reduce
)在内部遵循的规范。
- 密集数组(dense array),如 [10, 20, 30],可以看作是这样一个对象:{ "0": 10, "1": 20, "2": 30, "length": 3 }。
- 稀疏数组(sparse array),如 [10, , 30],则可以看作是:{ "0": 10, "2": 30, "length": 3 }。
- 在遍历多个元素的方法中,这些方法在访问索引之前执行 in 检查,并且不将空槽与 undefined 合并:concat()、copyWithin()、every()、filter()、flat()、flatMap()、forEach()、indexOf()、lastIndexOf()、map()、reduce()、reduceRight()、reverse()、slice()、some()、sort()、splice()。
- 这些旧方法将空槽视为 undefined:entries()、fill()、find()、findIndex()、findLast()、findLastIndex()、includes()、join()、keys()、toLocaleString()、values()。
accumulator
的更新 :accumulator = callbackFn(...)
这一行是整个reduce
的精髓。它形成了一个数据流:上一次计算的结果,无缝地流入下一次计算,像滚雪球一样,直到遍历结束。
✨ reduce
的魔力:实用场景大揭秘
现在我们已经理解了 reduce
的内在机制,让我们用我们亲手打造的 myReduce
来见证它的强大威力吧!
场景一:数组求和(基础中的基础)
javascript
const numbers = [1, 2, 3, 4, 5];
// ❌ 无 initialValue
const sumWithoutInitial = numbers.myReduce((accumulator, currentValue) => {
return accumulator + currentValue;
});
console.log("总和 (无 initialValue):", sumWithoutInitial); // 输出: 15
// 过程: acc从1开始,依次加上2, 3, 4, 5
// ✅ 有 initialValue
const sumWithInitial = numbers.myReduce((accumulator, currentValue) => {
return accumulator + currentValue;
}, 10);
console.log("总和 (有 initialValue):", sumWithInitial); // 输出: 25
// 过程: acc从10开始,依次加上1, 2, 3, 4, 5
场景二:二维数组展平("降维打击")
javascript
const nestedArray = [[0, 1], [2, 3], [4, 5]];
const flattenedArray = nestedArray.myReduce(
(accumulator, currentValue) => accumulator.concat(currentValue),
[] // 初始值是一个空数组
);
console.log("展平后的数组:", flattenedArray); // 输出: [0, 1, 2, 3, 4, 5]
在这里,accumulator
从一个空数组开始,每次都把当前子数组合并进去,优雅地完成了展平操作。
场景三:统计数组元素出现次数
javascript
const names = ["Alice", "Bob", "Tiff", "Bruce", "Alice"];
const countedNames = names.myReduce((allNames, name) => {
const currCount = allNames[name] ?? 0; // 使用空值合并运算符简化代码
return { ...allNames, [name]: currCount + 1 };
}, {}); // 初始值是一个空对象
console.log("名字出现次数统计:", countedNames);
// 输出: { Alice: 2, Bob: 1, Tiff: 1, Bruce: 1 }
这才是 reduce
真正施展魔法的地方!accumulator
不再是简单的数字或数组,而是一个对象,完美地实现了数据聚合。
场景四:按对象属性分组
javascript
const people = [
{ name: "Alice", age: 21 },
{ name: "Max", age: 20 },
{ name: "Jane", age: 20 },
];
const groupedPeople = people.myReduce((acc, person) => {
const key = person.age;
const currentGroup = acc[key] ?? [];
return { ...acc, [key]: [...currentGroup, person] };
}, {});
console.log("按年龄分组的人员:", groupedPeople);
// 输出:
// {
// 20: [ { name: 'Max', age: 20 }, { name: 'Jane', age: 20 } ],
// 21: [ { name: 'Alice', age: 21 } ]
// }
看到了吗?通过 reduce
,我们可以轻松地将一个扁平的数组结构转换成一个复杂的、按需分组的对象结构。这比用 for
循环和一堆 if-else
判断要优雅得多!
🎯 总结:你现在是 reduce
大师了!
恭喜你!读到这里,你不仅学会了 reduce
的各种花式用法,更重要的是,你亲手构建了它的内核,理解了它每一行代码背后的逻辑。
现在,让我们回顾一下关键点:
reduce
是一个累加器:它的本质是将一个数组"浓缩"成一个单一的值(这个值可以是任何类型:数字、字符串、数组、对象)。initialValue
至关重要 :它决定了reduce
的起始状态,并能避免空数组报错,请大胆使用它!accumulator
的流动是核心 :理解回调函数的返回值如何成为下一次调用的accumulator
,你就掌握了reduce
的精髓。- 超越求和 :不要把
reduce
局限在数学计算上,它在数据塑形、转换和聚合方面的能力超乎你的想象。