Последняя функция из нашей тройки — метод reduce()
(говорят "свертка"), который используется для агрегации данных. Под агрегацией понимается операция, вычисляющая значение, зависящее от всего набора данных. К таким операциям, например, относятся нахождение среднего значения, суммы элементов, большего или меньшего. Этот подход разбирался в курсе по массивам.
reduce()
устроен немного сложнее, чем map()
и filter()
, но, в целом, сохраняет общий подход с передачей функции. Реализуем код, находящий общее количество денег у группы людей. Здесь сразу прослеживается агрегация, нам нужно свести количество денег всех пользователей к одному значению:
const users = [
{ name: 'Igor', amount: 19 },
{ name: 'Danil', amount: 1 },
{ name: 'Ivan', amount: 4 },
{ name: 'Matvey', amount: 16 },
]
let sum = 0
for (const user of users) {
sum += user.amount
}
console.log(sum) // => 40
Основное отличие агрегации от отображения и фильтрации в том, что результатом агрегации может быть любой тип данных — как примитивный, так и составной, например, массив. Кроме того, агрегация нередко подразумевает инициализацию начальным значением, которое принято называть аккумулятором. В примере выше она выполняется на строчке let sum = 0
. Здесь переменная sum
"аккумулирует" результат внутри себя.
Посмотрим еще один пример агрегации — группировку имён пользователей по возрасту:
const users = [
{ name: 'Petr', age: 4 },
{ name: 'Igor', age: 19 },
{ name: 'Ivan', age: 4 },
{ name: 'Matvey', age: 16 },
]
const usersByAge = {}
for (const { age, name } of users) {
// Проверяем добавлено ли уже свойство age в результирующий объект или нет
if (!Object.hasOwn(usersByAge, age)) {
usersByAge[age] = []
}
usersByAge[age].push(name)
}
console.log(usersByAge)
// => { 4: [ 'Petr', 'Ivan' ], 16: [ 'Matvey' ], 19: [ 'Igor' ] }
В этом примере результатом агрегации становится объект, в свойствах которого записаны массивы. Этот результат в самом начале инициируется пустым объектом, а затем постепенно, на каждой итерации, "наполняется" нужными данными. Значение, которое накапливает результат агрегации, принято называть словом "аккумулятор". В примерах выше это sum
и usersByAge
.
Реализуем первый пример, используя reduce()
:
const users = [
{ name: 'Igor', amount: 19 },
{ name: 'Danil', amount: 1 },
{ name: 'Ivan', amount: 4 },
{ name: 'Matvey', amount: 16 },
]
const sum = users.reduce((acc, user) => {
const newAcc = acc + user.amount
return newAcc
}, 0)
// const sum = users.reduce((acc, user) => acc + user.amount, 0);
// Распишем
// user: Igor, acc = 0, return value 0 + 19
// user: Danil, acc = 19, return value 19 + 1
// user: Ivan, acc = 20, return value 20 + 4
// user: Matvey, acc = 24, return value 24 + 16
console.log(sum) // => 40
Метод reduce()
принимает на вход два параметра — функцию-обработчик и начальное значение аккумулятора. Этот же аккумулятор возвращается наружу в качестве результата всей операции.
Функция, передаваемая в reduce()
— самая важная часть и ключ к пониманию работы всего механизма агрегации. Она принимает на вход два значения. Первое — текущее значение аккумулятора, второе — текущий обрабатываемый элемент. Задача функции — вернуть новое значение аккумулятора. reduce()
никак не анализирует содержимое аккумулятора. Всё, что он делает, передаёт его в каждый новый вызов до тех пор, пока не будет обработана вся коллекция, и в конце концов вернёт его наружу. Подчеркну, что возвращать аккумулятор надо всегда, даже если он не изменился.
Второй пример с использованием reduce()
выглядит так:
const users = [
{ name: 'Petr', age: 4 },
{ name: 'Igor', age: 19 },
{ name: 'Ivan', age: 4 },
{ name: 'Matvey', age: 16 },
]
// предварительно подготовим функцию-обработчик
const cb = (acc, user) => {
if (!Object.hasOwn(acc, user.age)) {
acc[user.age] = []
}
acc[user.age].push(user.name)
return acc // обязательно вернуть!
}
// Начальное значение аккумулятора – пустой объект
const usersByAge = users.reduce(cb, {})
Код практически не изменился, за исключением того, что ушёл цикл и появился возврат аккумулятора из анонимной функции.
Разберем пошагово работу функции reduce()
. В функцию передается колбек, который принимает два параметра acc
и user
. Чтобы лучше понять работу, нужно проследить чему равны значения этих параметров на каждой итерации:
- На первой итерации
acc
равен пустому объекту, это начальное значение аккумулятора задается вторым параметром,users.reduce(cb, {})
— здесь вторым параметром передается пустой объект. Параметрuser
равен первому элементу массива, то есть{ name: 'Petr', age: 4 }
. В пустом объекте создается массив под ключомuser.age
и в этот массив добавляется текущее имя пользователя. В итогеacc
становится равен объекту{ 4: ['Petr'] }
. Из функции возвращаетсяacc
— это значение будет аккумулятором на следующей итерации - На второй итерации
acc
равен значению, которое вернулось из предыдущей итерации, это объект{ 4: ['Petr'] }
. Параметрuser
равен второму элементу массива{ name: 'Igor', age: 19 }
. В аккумулятореacc
нет ключа с возрастом текущего пользователя, поэтому добавляется новый ключ и массив. После заполненияacc
равен{ 4: ['Petr'], 19: ['Igor'] }
, этот объект возвращается из функции - На этой итерации
acc
равен объекту, вернувшемуся из прошлой итерации{ 4: ['Petr'], 19: ['Igor'] }
. Параметрuser
равен{ name: 'Ivan', age: 4 }
. Значение свойстваuser.age
равно 4 — этот ключ уже имеется в аккумуляторе, поэтому новый ключ не создается, а текущий пользователь добавляется в существующий массив. В итоге аккумулятор равен объекту{ 4: ['Petr', 'Ivan'], 19: ['Igor'] }
и он возвращается из функции - Последняя итерация. Параметр
acc
равен{ 4: ['Petr', 'Ivan'], 19: ['Igor'] }
, аuser
равен{ name: 'Matvey', age: 16 }
. Ключа16
в аккумуляторе нет, поэтому добавляется новый массив в ключ16
, в этот массив добавляется текущий пользователь. В итогеacc
будет равен{ 4: ['Petr', 'Ivan'], 16: ['Matvey'], 19: ['Igor'] }
, этот объект возвращается и в итоге будет результатом работы всего редьюса, так как это последняя итерация
reduce()
— очень мощный метод. Формально, можно работать, используя только его, так как он может заменить и отображение, и фильтрацию. Но делать так не стоит. Агрегация управляет состоянием (аккумулятором) явно. Такой код всегда сложнее и требует больше действий. Поэтому, если задачу возможно решить отображением или фильтрацией, то так и нужно делать.
Как думать о редьюсе
Распишем алгоритм, который поможет правильно подступаться к задачам, в которых требуется редьюс. Представьте, что перед вами список курсов с уроками внутри них и вам нужно посчитать количество всех уроков. Например, такое может быть нужно для вычисления длительности программы обучения. На Хекслете подобные задачи встречаются регулярно.
// Упрощенная структура, чтобы не перегружать
// В реальности тут была бы куча дополнительных данных о курсе и об уроках
const courses = [
{
name: 'Arrays',
lessons: [{ name: 'One' }, { name: 'Two' }],
},
{
name: 'Objects',
lessons: [{ name: 'Lala' }, { name: 'One' }, { name: 'Two' }],
},
]
Здесь мы видим два курса, в которых суммарно 5 уроков. Попробуем теперь высчитать это число программно. Первый вопрос, на который надо ответить, является ли данная операция агрегацией? Ответ - Да, так как мы сводим исходные данные, к какому-то вычисляемому результату. Дальше смотрим, чем является результат операции. В нашем случае это число, которое вычисляется как сумма уроков в каждом курсе. Значит начальным значением аккумулятора будет 0 (тут можно освежить). Теперь примерный алгоритм:
- Инициализируем накапливаемый результат нулем
- Обходим коллекцию курсов по одному
- Прибавляем к аккумулятору количество уроков в текущем курсе
Этот алгоритм будет идентичным в любом варианте решения, как через цикл, так и через редьюс:
// loop
let result = 0
for (const course of courses) {
result += course.lessons.length
}
console.log(result) // => 5
// reduce
const result = courses.reduce((acc, course) => acc + course.lessons.length, 0)
console.log(result) // => 5
Реализация
Напишем свою собственную функцию myReduce()
, работающую аналогично методу массива reduce()
:
const myReduce = (collection, callback, init) => {
let acc = init // инициализация аккумулятора
for (const item of collection) {
acc = callback(acc, item) // Заменяем старый аккумулятор новым
}
return acc
}
const users = [
{ name: 'Petr', age: 4 },
{ name: 'Igor', age: 19 },
{ name: 'Ivan', age: 4 },
{ name: 'Matvey', age: 16 },
]
const oldest = myReduce(
users,
(acc, user) => (user.age > acc.age ? user : acc),
users[0],
)
console.log(oldest) // => { name: 'Igor', age: 19 }
Дополнительные материалы
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.