Как использовать Map, Filter и Reduce в JavaScript

Функциональное программирование в наши дни произвело настоящий фурор в мире разработки. И на то есть веская причина: функциональные методы могут помочь вам написать более декларативный код, который легче понять с первого взгляда, провести рефакторинг и тестирование.

Одним из краеугольных камней функционального программирования является его особое использование списков и операций со списками. Эти вещи - именно то, на что они похожи: множество вещей и то, что вы с ними делаете. Но функциональное мышление относится к ним немного иначе, чем вы могли бы ожидать.

Как использовать Map, Filter и Reduce в JavaScript? В этой статье мы подробно рассмотрим то, что мне нравится называть "большой тройкой" операций со списком: сопоставление, фильтрация и повторное использование. Знакомство с этими тремя функциями является важным шагом на пути к умению писать чистый, функциональный код, и это открывает двери для чрезвычайно мощных методов функционального и реактивного программирования. Любопытно? Давайте погрузимся в это дело.

Map

Отфильтруйте шум Метод уменьшения Сведение всего воедино: сопоставление, фильтрация, сокращение и возможность создания цепочки Заключение и следующие шаги Карта из списка в список Часто мы сталкиваемся с необходимостью взять массив и изменить каждый элемент в нем точно таким же образом. Типичными примерами этого являются возведение в квадрат каждого элемента в массиве чисел, извлечение имени из списка пользователей или выполнение регулярного выражения для массива строк.

Map - это метод, созданный именно для этого. Он определен в Array. prototype, поэтому вы можете вызвать его для любого массива, и он принимает обратный вызов в качестве своего первого аргумента. 

Синтаксис для map показан ниже.

let newArray = arr.map(callback(currentValue[, index[, array]]) {
  // return element for newArray, after executing something
}[, thisArg]);

Когда вы вызываете map для массива, он выполняет этот обратный вызов для каждого элемента внутри него, возвращая новый массив со всеми значениями, возвращенными обратным вызовом.

Под капотом map передает вашему обратному вызову три аргумента:

  • текущий элемент в массиве
  • индекс массива текущего элемента
  • весь массив, на который вы вызвали map

Давайте посмотрим на какой-нибудь код.

Map на практике

Предположим, у нас есть приложение, которое поддерживает множество ваших задач на день. Каждая задача - это объект, у каждого из которых есть свойство name и duration:

// Durations are in minutes 
const tasks = [
     {
       'name'     : 'Write for Envato Tuts+',
       'duration' : 120
     },
     {
       'name'     : 'Work out',
       'duration' : 60
     },
     {
       'name'     : 'Procrastinate on Duolingo',
       'duration' : 240
  }
];

Допустим, мы хотим создать новый массив только с именем каждой задачи, поэтому мы можете взглянуть на все, что мы сделали сегодня. Используя цикл for, мы бы написали что-то вроде этого:

 const task_names = [];
   for (let i = 0, max = tasks.length; i < max; i += 1) {
       task_names.push(tasks[i].name);
   }
console.log(task_names) // [ 'Write for Envato Tuts+', 'Work out', 'Procrastinate on Duolingo' ]

JavaScript также предлагает цикл forEach. Он функционирует как цикл for, но справляется со всей неразберихой, связанной с проверкой индекса нашего цикла на соответствие длине массива для нас:

const task_names = []; 
   tasks.forEach(function (task) {
       task_names.push(task.name);    
   });
console.log(task_names) // [ 'Write for Envato Tuts+', 'Work out', 'Procrastinate on Duolingo'

Используя карту, мы можем просто написать:

const task_names = tasks.map(function (task, index, array) {   
 return task.name; 
   });
console.log(task_names) // [ 'Write for Envato Tuts+', 'Work out', 'Procrastinate on Duolingo' ]

Здесь я включил параметры индекса и массива, чтобы напомнить вам, что они есть, если они вам понадобятся. Однако, поскольку я не использовал их здесь, вы могли бы опустить их, и код работал бы просто отлично.

Еще более лаконичный способ написания карты в современном JavaScript - это использование функций со стрелками.

console.log(task_names) // ['Write for Envato Tuts+', 'Work out', 'Procrastinate on DuoLingo']
const task_names = tasks.map(task => task.name)

Функции со стрелками - это сокращенная форма для однострочных функций, которые содержат только оператор return. Это не становится намного более читабельным, чем это.

  • Между различными подходами есть несколько важных различий:
  • Используя map, вам не нужно самостоятельно управлять состоянием цикла for.
  • С помощью map вы можете работать с элементом напрямую, вместо того чтобы индексировать его в массиве.
  • Вам не нужно создавать новый массив и вставлять в него данные.
  • map возвращает готовый продукт за один раз, поэтому мы можем просто присвоить возвращаемое значение новому варианту способный.

Вы должны не забыть включить оператор return в свой обратный вызов.

Если вы этого не сделаете, вы получите новый массив, заполненный неопределенным значением. Оказывается, все функции, которые мы рассмотрим сегодня, обладают этими характеристиками.

Тот факт, что нам не нужно вручную управлять состоянием цикла, делает наш код более простым и ремонтопригодным. Тот факт, что мы можем работать непосредственно с элементом вместо того, чтобы индексировать его в массиве, делает вещи более удобочитаемыми. 

Использование цикла forEach решает обе эти проблемы для нас. Но у map все еще есть по крайней мере два неоспоримых преимущества:

  • forEach возвращает значение undefined, поэтому оно не связывается с другими методами массива. Map возвращает массив, так что вы можете связать его с другими методами массива.
  • Map возвращает массив с готовым продуктом, вместо того чтобы требовать от нас изменения массива внутри цикла. 

Сведение количества мест, где вы изменяете состояние, к абсолютному минимуму является важным принципом функционального программирования. Это делает процесс более безопасным и понятным код.

Подводные камни

Обратный вызов, который вы передаете map, должен содержать явный оператор return, иначе map выдаст массив, полный неопределенных значений. Нетрудно запомнить, что нужно указать возвращаемое значение, но это нетрудно и забыть. 

Если вы все-таки забудете, map не будет жаловаться. Вместо этого он спокойно вернет массив, полный пустоты. Подобные молчаливые ошибки может быть на удивление трудно отладить. 

К счастью, это единственная проблема с картой. Но это достаточно распространенная ошибка, которую я обязан подчеркнуть: Всегда убедитесь, что ваш обратный вызов содержит инструкцию return!

Реализация

Чтение реализаций - важная часть понимания. Итак, давайте напишем нашу собственную облегченную карту, чтобы лучше понять, что происходит под капотом. Если вы хотите увидеть реализацию производственного качества, ознакомьтесь с polyfill от Mozilla на MDN.

let map = function (array, callback) {  
  const new_array = [];
      
       array.forEach(function (element, index, array) {
         new_array.push(callback(element));
       });
      
       return new_array;
  };

Этот код принимает массив и функцию обратного вызова в качестве аргументов. Затем он создает новый массив, выполняет обратный вызов для каждого элемента массива, который мы передали, помещает результаты в новый массив и возвращает новый массив.

Если вы запустите это в своей консоли, вы получите тот же результат, что и раньше. В то время как мы используем цикл for под капотом, преобразование его в функцию скрывает детали и позволяет нам вместо этого работать с абстракцией. 

Это делает наш код более декларативным — в нем говорится, что делать, а не как это сделать. Вы по достоинству оцените, насколько более читабельным, поддерживаемым и, э-э, отлаживаемым это может сделать ваш код.

Filter

Следующая из наших операций с массивом - это filter. Он делает именно то, на что это похоже: он берет массив и отфильтровывает нежелательные элементы. Синтаксис для filter таков:

let newArray = arr.filter(callback(currentValue[, index[, array]]) {
  // return element for newArray, if true
}[, thisArg]);

Так же, как и map, filter передает вашему обратному вызову три аргумента:

  • текущий элемент
  • текущий индекс
  • массив, для которого вы вызвали filter

Рассмотрим следующий пример, который отфильтровывает любую строку длиной менее 8 символов.

const words = ['Python', 'Javascript', 'Go', 'Java', 'PHP', 'Ruby'];
const result = words.filter(word => word.length < 8);
console.log(result);

Ожидаемый результат будет следующим:

[ 'Python', 'Go', 'Java', 'PHP', 'Ruby' ] 

Давайте вернемся к нашему примеру с задачей. Вместо того чтобы выводить названия каждой задачи, допустим, я хочу получить список только тех задач, на выполнение которых у меня ушло два часа или больше.

Используя forEach, мы бы написали:

 const difficult_tasks = [];
 tasks.forEach(function (task) {
     if (task.duration >= 120) {
         difficult_tasks.push(task);
     }
 });
 console.log(difficult_tasks)
 //  [{ name: 'Write for Envato Tuts+', duration: 120 },
 //   { name: 'Procrastinate on Duolingo', duration: 240 }
 //   ]

С помощью фильтра мы можем просто написать:

const difficult_tasks = tasks.filter((task) => task.duration >= 120 );

Так же, как и карта, фильтр позволяет нам: избегайте изменения массива внутри цикла forEach или for присваивайте его результат непосредственно новой переменной, а не вставляйте в массив, который мы определили в другом месте

Подводные камни

Обратный вызов, который вы передаете map, должен включать оператор return, если вы хотите, чтобы он функционировал должным образом.

С помощью filter вы также должны включить оператор return (если только вы не используете функции со стрелками), и вы должны убедиться, что он возвращает логическое значение.

Если вы забудете свой оператор return, ваш обратный вызов вернет undefined, который фильтр бесполезно принудит к false. Вместо того, чтобы выдавать ошибку, он молча вернет пустой массив! 

Если вы пойдете другим путем и вернете что-то, что явно не является true или false, то filter попытается выяснить, что вы имели в виду, применяя правила приведения типов JavaScript. Больше о чаще, чем нет, это ошибка. И точно так же, как если вы забудете свое заявление о возврате, оно будет молчаливым. Всегда следите за тем, чтобы ваши обратные вызовы включали явный оператор return. И всегда следите за тем, чтобы ваши обратные вызовы в фильтре возвращали значение true или false. Ваше здравомыслие будет вам благодарно.

Реализация

Еще раз, лучший способ понять фрагмент кода - это. . . ну, чтобы написать это. Давайте создадим наш собственный облегченный фильтр. У хороших людей из Mozilla есть для вас также полифилл промышленного уровня, который вы можете прочитать.

 const filter = function (array, callback) {
       const filtered_array = [];
    
       array.forEach(function (element, index, array) {
           if (callback(element, index, array)) {
               filtered_array.push(element);    
           }
       });
    
       return filtered_array;
    
};

Reduce

Синтаксис метода reduce array в JavaScript таков:

let newArray = arr.filter(callback(currentValue, accumulatedValue) {  
 // return the accumulated value, given the current and previous  accumulated value
}, initialValue[, thisArg]);

map создает новый массив путем преобразования каждого элемента в массиве по отдельности. фильтр создает новый массив, удаляя элементы, которые ему не принадлежат. reduce, с другой стороны, берет все элементы массива и сводит их к одному значению.

Точно так же, как map и filter, reduce определен в Array. prototype и поэтому доступен в любом массиве, и вы передаете обратный вызов в качестве его первого аргумента. Но для этого также требуется второй аргумент: значение, в которое нужно начать объединять все элементы вашего массива. reduce передает вашему обратному вызову четыре аргумента:

  • текущее значение
  • предыдущее значение
  • текущий индекс
  • массив, который вы назвали reduce on

Обратите внимание, что обратный вызов получает предыдущее значение на каждой итерации. На первой итерации предыдущее значение отсутствует. Вот почему у вас есть возможность передать reduce начальное значение: оно действует как "предыдущее значение" для первой итерации , когда в противном случае его бы не было.

Наконец, имейте в виду, что reduce возвращает одно значение, а не массив, содержащий один элемент. Это важнее, чем может показаться, и я вернусь к этому в примерах.

Reduce на практике

Допустим, вы хотите найти сумму списка чисел. Используя цикл, это выглядело бы примерно так:

let numbers = [1, 2, 3, 4, 5],    
total = 0;
         
   numbers.forEach(function (number) {
       total += number;
   });
console.log(total); // 15

Хотя это неплохой вариант использования forEach, у reduce все еще есть преимущество, позволяющее нам избежать мутации. С помощью reduce мы бы написали:

const total = [1, 2, 3, 4, 5].reduce(function (previous, current) {   
 return previous + current;
   }, 0);
console.log(total); // 15

Сначала мы вызываем reduce из нашего списка номеров. Мы передаем ему обратный вызов, который принимает предыдущее значение и текущее значение в качестве аргументов и возвращает результат добавления они вместе.

Поскольку мы передали 0 в качестве второго аргумента для reduce, он будет использовать это как значение previous на первой итерации. С помощью функций со стрелками мы бы написали это следующим образом:

const total = [1, 2, 3, 4, 5].reduce((previous, current) => previous+current),0;
console.log(total) // 15

Если мы будем делать это шаг за шагом, то это будет выглядеть примерно так: 1 0 1 1 2 1 2 3 3 3 3 6 4 6 4 10 5 10 5 15

Если вы не поклонник таблиц, запустите этот фрагмент в консоли:

const total = [1, 2, 3, 4, 5].reduce(function (previous, current, index) {
    const val = previous + current;
       console.log("The previous value is " + previous + 
                   "; the current value is " + current +
                   ", and the current iteration is " + (index + 1));
       return val;
   }, 0);
    
console.log("The loop is done, and the final value is " + total + ".");

Напомним: reduce выполняет итерацию по всем элементам массива, комбинируя их так, как вы указываете в своем обратном вызове. На каждой итерации ваш обратный вызов имеет доступ к предыдущему значению, которое является общим на данный момент или накопленным значением; текущему значению; текущему индексу; и всему массиву, если они вам нужны.

Давайте вернемся к примеру с нашими задачами. Мы получили список названий задач из map и отфильтрованный список задач, выполнение которых заняло много времени. . . хорошо, отфильтруйте. 

Что, если бы мы захотели узнать общее количество времени, которое мы потратили на работу сегодня? Используя цикл forEach, вы бы написали:

  let total_time = 0;    
  tasks.forEach(function (task) {
       // The plus sign just coerces 

       // task.duration from a String to a Number

       total_time += (+task.duration);
   });
    
console.log(total_time)

//expected result is 420

Это почти все, что от этого требуется.

total_time = tasks.reduce((previous, current) => previous + current.duration, 0);
console.log(total_time); //420

Почти, потому что JavaScript предоставляет нам еще один малоизвестный метод, называемый reduceRight. В приведенных выше примерах сокращение начинается с первого элемента в массиве, повторяясь слева направо:

let array_of_arrays = [[1, 2], [3, 4], [5, 6]];
const concatenated = array_of_arrays.reduce( function (previous, current) {
           return previous.concat(current);
   });
     
console.log(concatenated); // [1, 2, 3, 4, 5, 6];

reduceRight делает то же самое, но в противоположном направлении:

let array_of_arrays = [[1, 2], [3, 4], [5, 6]];
const concatenated = array_of_arrays.reduceRight( function (previous, current) {
           return previous.concat(current);
   });

console.log(concatenated); // [5, 6, 3, 4, 1, 2];

Я использую reduce каждый день, но мне никогда не был нужен reduceRight. Я думаю, ты, скорее всего, тоже этого не сделаешь. Но если ты когда-нибудь это сделаешь, теперь ты знаешь это здесь.

Сведение всего воедино: Map, filter, reduce и возможность создания цепочки

На данный момент это может вас не сильно впечатлить. Справедливо: отображение, фильтрация и уменьшение сами по себе не очень интересны. В конце концов, их истинная сила заключается в их цепочечности.

Допустим, я хочу сделать следующее:

  • Соберите задания на два дня.
  • Преобразуйте продолжительность задачи в часы, а не в минуты.
  • Отфильтруйте все, что заняло два часа или больше.
  • Подведите итог всему этому.
  • Умножьте результат на почасовую ставку для выставления счета.
  • Выведите отформатированную сумму в долларах.

Во-первых, давайте определим наши задачи на понедельник и вторник:

const monday = [
        {
               'name'     : 'Write a tutorial',
               'duration' : 180
           },
           {
               'name'     : 'Some web development',
               'duration' : 120
           }
       ];
    
   const tuesday = [
           {
               'name'     : 'Keep writing that tutorial',
               'duration' : 240
           },
           {
               'name'     : 'Some more web development',
               'duration' : 180
           },
           {
               'name'     : 'A whole lot of nothing',
               'duration'  : 240
           }
       ];
       
const tasks = [monday, tuesday];

А теперь наше очаровательное преображение:

const result = tasks    
// Concatenate our 2D array into a single list
 .reduce((acc, current) => acc.concat(current))
 // Extract the task duration, and convert minutes to hours
 .map((task) => task.duration / 60)
 // Filter out any task that took less than two hours
 .filter((duration) => duration >= 2)
 // Multiply each tasks' duration by our hourly rate
 .map((duration) => duration * 25)
 // Combine the sums into a single dollar amount
 .reduce((acc, current) => [(+acc) + (+current)])
 // Convert to a "pretty-printed" dollar amount
 .map((amount) => '$' + amount.toFixed(2))
 // Pull out the only element of the array we got from map
.reduce((formatted_amount) =>formatted_amount); 

Если вы зашли так далеко, то это должно быть довольно просто. Однако есть две странности, которые нужно объяснить.

Сначала, в строке 10, я должен написать:

// Remainder omitted
reduce(function (accumulator, current) {
       return [(+accumulator) + (+current_];
})

Здесь следует объяснить две вещи:

  1. Знаки "плюс" перед значениями "аккумулятор" и "ток" преобразуют их значения в цифры. Если вы этого не сделаете, возвращаемым значением будет довольно бесполезная строка "12510075100".
  2. Если вы не заключите эту сумму в квадратные скобки, reduce выдаст одно значение, а не массив. Это привело бы к выдаче TypeError, потому что вы можете использовать map только для массива!

Второй момент, который может вызвать у вас некоторый дискомфорт, - это последнее сокращение, а именно:

// Remainder omitted
map(function (dollar_amount) {
       return '$' + dollar_amount.toFixed(2);
   }).reduce(function (formatted_dollar_amount) {
       return formatted_dollar_amount;
});

Этот вызов map возвращает массив, содержащий одно значение. Здесь мы вызываем reduce, чтобы извлечь это значение.

Наконец, давайте посмотрим, как наш друг цикл forEach справился бы с этим:

let concatenated = monday.concat(tuesday),    
fees = [],
hourly_rate = 25,
total_fee = 0;
 
 concatenated.forEach(function (task) {
 let duration = task.duration / 60;
   
 if (duration >= 2) {
     fees.push(duration * hourly_rate);
 }
 });
 
 fees.forEach(function (fee) {
 total_fee += fee;
 });

console.log(total_fee); //400

Терпимо, но шумно.

Заключение и следующие шаги

В этом руководстве вы узнали, как отображать, фильтровать и сокращать работу; как их использовать; и примерно как они реализованы. Вы видели, что все они позволяют вам избежать изменения состояния, чего требует использование циклов for и forEach, и теперь у вас должно быть хорошее представление о том, как связать их все вместе.