Статьи

Как использовать карту, фильтр и уменьшить в JavaScript

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

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

В этой статье мы подробно рассмотрим то, что мне нравится называть «большой тройкой» операций со списками: map , filter и reduce Обдумывание этих трех функций — важный шаг на пути к написанию чистого функционального кода и открывает двери для чрезвычайно мощных методов функционального и реактивного программирования.

Это также означает, что вам больше никогда не придется писать цикл for .

Любопытно? Давайте погрузимся в.

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

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

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

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

  1. Текущий элемент в массиве
  2. Индекс массива текущего элемента
  3. Весь массив, который вы назвали map

Давайте посмотрим на некоторый код.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Durations are in minutes
 
var tasks = [
 
  {
 
    ‘name’ : ‘Write for Envato Tuts+’,
 
    ‘duration’ : 120
 
  },
 
  {
 
    ‘name’ : ‘Work out’,
 
    ‘duration’ : 60
 
  },
 
  {
 
    ‘name’ : ‘Procrastinate on Duolingo’,
 
    ‘duration’ : 240
 
  }
 
];

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

1
2
3
4
5
6
7
var task_names = [];
 
for (var i = 0, max = tasks.length; i < max; i += 1) {
 
    task_names.push(tasks[i].name);
 
}

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

1
2
3
4
5
6
7
var task_names = [];
 
tasks.forEach(function (task) {
 
    task_names.push(task.name);
     
});

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

1
2
3
4
5
var task_names = tasks.map(function (task, index, array) {
 
    return task.name;
 
});

Я включил параметры index и array чтобы напомнить, что они есть, если они вам нужны. Так как я не использовал их здесь, вы могли бы их пропустить, и код работал бы просто отлично.

Есть несколько важных различий между двумя подходами:

  1. Используя map , вы не должны сами управлять состоянием цикла for .
  2. Вы можете работать с элементом напрямую, вместо того, чтобы индексировать его в массив.
  3. Вам не нужно создавать новый массив и push его. map возвращает готовый продукт сразу, поэтому мы можем просто присвоить возвращаемое значение новой переменной.
  4. Вы должны помнить, чтобы включить оператор return в ваш обратный вызов. Если вы этого не сделаете, вы получите новый массив, заполненный undefined .

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

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

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

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

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

Сейчас также самое время указать, что если вы находитесь в Node, тестируете эти примеры в консоли браузера Firefox или используете Babel или Traceur , вы можете написать это более кратко с помощью функций стрелок ES6:

1
var task_names = tasks.map((task) => task.name );

Функции стрелок позволяют нам исключить ключевое слово return в однострочниках.

Это не становится намного более читабельным, чем это.

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

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

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
var map = function (array, callback) {
 
    var new_array = [];
 
    array.forEach(function (element, index, array) {
       new_array.push(callback(element));
    });
 
    return new_array;
 
};
 
var task_names = map(tasks, function (task) {
 
    return task.name;
 
});

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

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

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

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

Как и map , filter определен для Array.prototype . Он доступен для любого массива, и вы передаете ему обратный вызов в качестве первого аргумента. filter выполняет этот обратный вызов для каждого элемента массива и выплевывает новый массив, содержащий только те элементы, для которых обратный вызов вернул true .

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

  1. Текущий пункт
  2. Текущий индекс
  3. Массив, который вы назвали filter

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

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

1
2
3
4
5
6
7
var difficult_tasks = [];
 
tasks.forEach(function (task) {
    if (task.duration >= 120) {
        difficult_tasks.push(task);
    }
});

С filter :

1
2
3
4
5
6
var difficult_tasks = tasks.filter(function (task) {
    return task.duration >= 120;
});
 
// Using ES6
var difficult_tasks = tasks.filter((task) => task.duration >= 120 );

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

Как и map , filter позволяет нам:

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

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

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

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

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
var filter = function (array, callback) {
 
    var filtered_array = [];
 
    array.forEach(function (element, index, array) {
        if (callback(element, index, array)) {
            filtered_array.push(element);
        }
    });
 
    return filtered_array;
 
};

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

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

reduce передает вашему обратному вызову четыре аргумента:

  1. Текущее значение
  2. Предыдущее значение  
  3. Текущий индекс
  4. Массив, который вы назвали

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

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

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

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

1
2
3
4
5
6
var numbers = [1, 2, 3, 4, 5],
    total = 0;
     
numbers.forEach(function (number) {
    total += number;
});

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

1
2
3
var total = [1, 2, 3, 4, 5].reduce(function (previous, current) {
    return previous + current;
}, 0);

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

Если мы сделаем это шаг за шагом, это будет выглядеть так:

итерация предыдущий Текущий Общее количество
1 0 1 1
2 1 2 3
3 3 3 6
4 6 4 10
5 10 5 15

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

1
2
3
4
5
6
7
8
9
var total = [1, 2, 3, 4, 5].reduce(function (previous, current, index) {
    var 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 и отфильтрованный список задач, которые заняли много времени с … ну, filter .

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

Используя цикл forEach , вы должны написать:

1
2
3
4
5
6
7
var total_time = 0;
     
tasks.forEach(function (task) {
    // The plus sign just coerces
    // task.duration from a String to a Number
    total_time += (+task.duration);
});

При reduce это становится:

1
2
3
4
5
6
var total_time = tasks.reduce(function (previous, current) {
    return previous + current;
}, 0);
 
// Using arrow functions
var total_time = tasks.reduce((previous, current) previous + current );

Легко.

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

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

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

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

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

Три большие ошибки со reduce :

  1. Забыв return
  2. Забыть начальное значение
  3. Ожидание массива при reduce возвращает одно значение

К счастью, первых двух легко избежать. Решение, каким должно быть ваше первоначальное значение, зависит от того, что вы делаете, но вы быстро освоитесь.

Последнее может показаться немного странным. Если reduce только когда-либо возвращает одно значение, почему вы ожидаете массив?

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

Во-вторых, если бы при reduce возвращался массив с одним значением, он, естественно, будет хорошо работать с map filter и другими функциями для массивов, которые вы, вероятно, будете использовать с ним.

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

1
2
3
4
5
6
7
8
9
var reduce = function (array, callback, initial) {
    var accumulator = initial ||
     
    array.forEach(function (element) {
       accumulator = callback(accumulator, array[i]);
    });
     
    return accumulator;
};

Здесь нужно отметить две вещи:

  1. На этот раз я использовал имя accumulator вместо previous . Это то, что вы обычно видите в дикой природе.
  2. Я назначаю accumulator начальное значение, если пользователь предоставляет его, и значение по умолчанию 0 , если нет. Так ведет себя и реальное reduce .

На данный момент, вы не можете быть впечатлены.

Достаточно справедливо: map , filter и reduce сами по себе не очень интересны.

В конце концов, их истинная сила заключается в их цепочности.

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

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

Сначала давайте определим наши задачи на понедельник и вторник:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var monday = [
        {
            ‘name’ : ‘Write a tutorial’,
            ‘duration’ : 180
        },
        {
            ‘name’ : ‘Some web development’,
            ‘duration’ : 120
        }
    ];
 
var tuesday = [
        {
            ‘name’ : ‘Keep writing that tutorial’,
            ‘duration’ : 240
        },
        {
            ‘name’ : ‘Some more web development’,
            ‘duration’ : 180
        },
        {
            ‘name’ : ‘A whole lot of nothing’,
            ‘duration’ : 240
        }
    ];
     
var tasks = [monday, tuesday];

А теперь наше прекрасное преобразование:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
var result = tasks.reduce(function (accumulator, current) {
                       return accumulator.concat(current);
                   }).map(function (task) {
                       return (task.duration / 60);
                   }).filter(function (duration) {
                       return duration >= 2;
                   }).map(function (duration) {
                       return duration * 25;
                   }).reduce(function (accumulator, current) {
                       return [(+accumulator) + (+current)];
                   }).map(function (dollar_amount) {
                       return ‘$’ + dollar_amount.toFixed(2);
                   }).reduce(function (formatted_dollar_amount) {
                       return formatted_dollar_amount;
                   });

Или, более кратко:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
// Concatenate our 2D array into a single list
var result = tasks.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 я должен написать:

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

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

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

Вторым моментом, который может сделать вас немного неудобным, является последнее reduce , а именно:

1
2
3
4
5
6
// Remainder omitted
map(function (dollar_amount) {
    return ‘$’ + dollar_amount.toFixed(2);
}).reduce(function (formatted_dollar_amount) {
    return formatted_dollar_amount;
});

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

Другой способ сделать это состоит в том, чтобы удалить запрос на уменьшение и индексировать в массив, который выдает map :

01
02
03
04
05
06
07
08
09
10
11
12
13
var result = tasks.reduce(function (accumulator, current) {
                   return accumulator.concat(current);
               }).map(function (task) {
                   return (task.duration / 60);
               }).filter(function (duration) {
                   return duration >= 2;
               }).map(function (duration) {
                   return duration * 25;
               }).reduce(function (accumulator, current) {
                   return [(+accumulator) + (+current)];
               }).map(function (dollar_amount) {
                   return ‘$’ + dollar_amount.toFixed(2);
               })[0];

Это совершенно правильно. Если вам удобнее использовать индекс массива, продолжайте.

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
var concatenated = monday.concat(tuesday),
    fees = [],
    formatted_sum,
    hourly_rate = 25,
    total_fee = 0;
 
concatenated.forEach(function (task) {
    var duration = task.duration / 60;
     
    if (duration >= 2) {
        fees.push(duration * hourly_rate);
    }
});
 
fees.forEach(function (fee) {
    total_fee += fee
});
 
 
var formatted_sum = ‘$’ + total_fee.toFixed(2);

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

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

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

  1. Превосходный набор упражнений Джафара Хусейна по функциональному программированию в JavaScript , дополненный основательным введением в Rx.js
  2. Курс Envato Tuts + Инструктор Джейсона Роудса по функциональному программированию на JavaScript
  3. Наиболее адекватное руководство по функциональному программированию , в котором более подробно рассказывается о том, почему мы избегаем мутаций и функционального мышления в целом.

JavaScript стал одним из де-факто языков работы в сети. Это не без кривых обучения, и есть множество фреймворков и библиотек, которые также могут вас занять. Если вы ищете дополнительные ресурсы для обучения или использования в своей работе, посмотрите, что у нас есть на рынке Envato .

Если вы хотите узнать больше о подобных вещах, время от времени проверяйте мой профиль ; поймай меня в твиттере ( @PelekeS ); или нажмите мой блог на http://peleke.me.

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

Изучите JavaScript: полное руководство

Мы создали полное руководство, которое поможет вам изучить JavaScript , независимо от того, начинаете ли вы как веб-разработчик или хотите изучать более сложные темы.