Trzy metody iteracyjne JavaScript – map(), filter() i reduce() – to fundament nowoczesnego przetwarzania danych w tablicach w stylu funkcyjnym, bez użycia imperatywnych pętli.
Dzięki nim kod staje się bardziej deklaratywny, czytelny i łatwiejszy do testowania – funkcje traktujemy jak wartości pierwszej klasy i komponujemy je w przejrzyste potoki.
W tym opracowaniu znajdziesz zasady działania, wzorce użycia, najlepsze praktyki oraz techniki optymalizacji z naciskiem na czystość funkcji i niezmienność danych.
Podstawowe koncepcje i programowanie funkcyjne w JavaScript
Programowanie funkcyjne postrzega obliczenia jako ewaluację funkcji matematycznych, unikając mutowania stanu i danych zmiennych.
W tym kontekście metody map(), filter() i reduce() są funkcjami wyższego rzędu – przyjmują inne funkcje jako argumenty.
Niezmienność danych (immutability) oznacza, że po utworzeniu dane nie są modyfikowane w miejscu; zamiast tego tworzymy nowe struktury na bazie istniejących. Metody map() i filter() naturalnie wspierają tę zasadę, zwracając nowe tablice.
Równie ważna jest przejrzystość referencyjna (referential transparency): wynik funkcji zależy wyłącznie od przekazanych argumentów, bez efektów ubocznych. Takie czyste funkcje są łatwiejsze do rozumienia, testowania i komponowania.
Dla szybkiego porównania kluczowych różnic między metodami zapoznaj się z poniższą tabelą:
| Metoda | Główne zadanie | Zwracany typ | Czy mutuje oryginał | Co zwraca callback |
|---|---|---|---|---|
| map() | transformacja elementów | nowa tablica | nie | nową wartość elementu |
| filter() | selekcja po warunku | nowa tablica | nie | wartość logiczną (true/false) |
| reduce() | agregacja do jednej wartości | dany typ (np. liczba, obiekt) | nie | nową wartość akumulatora |
Metoda map() – transformacja elementów tablicy
Metoda map() przekształca każdy element tablicy za pomocą przekazanej funkcji callback i zwraca nową tablicę z wynikami.
map() nie modyfikuje oryginalnej tablicy – pozostaje ona niezmieniona, co sprzyja przewidywalności kodu.
Składnia i parametry map()
Pełna składnia metody map() wygląda następująco:
const newArray = array.map(function callback(element, index, array) {
// Zwróć przekształcony element
}, thisArg)
Wyjaśnienie parametrów callback i argumentu thisArg:
- element – aktualnie przetwarzany element tablicy;
- index – indeks bieżącego elementu (opcjonalnie);
- array – referencja do oryginalnej tablicy (opcjonalnie);
- thisArg – kontekst this dla callbacku, rzadko używany przy funkcjach strzałkowych.
Praktyczne przykłady działania map()
Prosta transformacja wartości liczbowych:
const numbers = [1, 2, 3, 4];
const doubled = numbers.map(item => item * 2);
console.log(doubled); // [2, 4, 6, 8]
Transformacja tablicy obiektów z zachowaniem niezmienności dzięki operatorowi spread:
const products = [
{ name: 'Laptop', price: 1000 },
{ name: 'Phone', price: 500 },
{ name: 'Headphones', price: 150 },
];
const applyDiscount = product => ({
...product,
price: product.price * 0.9,
});
const discountedProducts = products.map(applyDiscount);
Operator rozprzestrzeniający (…) kopiuje właściwości, a my nadpisujemy tylko te, które chcemy zmienić – oryginał pozostaje nietknięty.
Zastosowania map() i najlepsze praktyki
map() świetnie nadaje się do zmiany formatu danych do widoku, konwersji typów i stosowania tej samej transformacji na wszystkich elementach. W React często służy do renderowania list komponentów.
Utrzymuj czystość funkcji callback – unikaj efektów ubocznych, jak mutacja stanu zewnętrznego czy wywołania I/O.
Przy bardzo dużych tablicach rozważ łączenie operacji, aby unikać tworzenia wielu pośrednich tablic w łańcuchach map() i filter().
Metoda filter() – selekcja elementów na podstawie warunku
Metoda filter() tworzy nową tablicę zawierającą elementy spełniające warunek testujący w callbacku.
Callback w filter() musi zwrócić wartość logiczną – true zachowuje element, false go odrzuca.
Mechanika działania filter()
Składnia filter() jest zbliżona do map():
const newArray = array.filter(function callback(element, index, array) {
return true; // zachowaj element; false: odrzuć
}, thisArg)
Praktyczne przykłady działania filter()
Selekcja liczb parzystych:
const numbers = [1, 2, 3, 4, 5, 6];
const evens = numbers.filter(num => num % 2 === 0);
console.log(evens); // [2, 4, 6]
Filtrowanie tablicy obiektów na podstawie warunku:
const students = [
{ name: 'Quincy', grade: 96 },
{ name: 'Jason', grade: 84 },
{ name: 'Alexis', grade: 100 },
{ name: 'Sam', grade: 65 },
{ name: 'Katie', grade: 90 }
];
const topStudents = students.filter(student => student.grade >= 90);
// Wynik:
// [
// { name: 'Quincy', grade: 96 },
// { name: 'Alexis', grade: 100 },
// { name: 'Katie', grade: 90 }
// ]
Zaawansowane techniki filtrowania
Usuwanie duplikatów za pomocą filter() z wykorzystaniem indexOf:
const numbers = [1, 2, 2, 3, 4, 4, 5];
const unique = numbers.filter((num, index, arr) => arr.indexOf(num) === index);
console.log(unique); // [1, 2, 3, 4, 5]
Łączenie warunków z operatorami logicznymi:
const products = [
{ name: 'Laptop', price: 1000, inStock: true },
{ name: 'Phone', price: 500, inStock: false },
{ name: 'Tablet', price: 400, inStock: true },
];
const affordable = products.filter(p => p.inStock && p.price < 600);
Metoda reduce() – agregacja danych do pojedynczej wartości
reduce() redukuje tablicę do pojedynczej wartości, iteracyjnie aktualizując akumulator zgodnie z logiką callbacku.
initialValue (drugi argument) ustawia wartość początkową akumulatora; bez niego pierwszym akumulatorem staje się pierwszy element tablicy.
Anatomia reduce() i parametry
Składnia reduce() różni się od map() i filter() obecnością akumulatora i initialValue:
const result = array.reduce(
function callback(accumulator, currentValue, index, array) {
// Zwróć nową wartość akumulatora
},
initialValue
);
Podstawowe przykłady reduce()
Sumowanie elementów:
const numbers = [1, 2, 3, 4];
const sum = numbers.reduce((accumulator, currentValue) => {
return accumulator + currentValue;
}, 0);
console.log(sum); // 10
Sklejenie liter w słowo:
const letters = ['J', 'U', 'S', 'T'];
const word = letters.reduce((acc, letter) => acc + letter, '');
console.log(word); // 'JUST'
Zaawansowane zastosowania reduce()
Agregacja do obiektu zliczającego wartości po kluczu:
const products = [
{ company: 'Kingston', quantity: 3 },
{ company: 'Adata', quantity: 1 },
{ company: 'Kingston', quantity: 2 },
];
const companyCount = products.reduce((acc, product) => ({
...acc,
[product.company]: (acc[product.company] || 0) + product.quantity
}), {});
// Wynik: { Kingston: 5, Adata: 1 }
Spłaszczanie zagnieżdżonych tablic:
const nested = [[1, 2], [3, 4], [5, 6]];
const flat = nested.reduce((acc, arr) => acc.concat(arr), []);
console.log(flat); // [1, 2, 3, 4, 5, 6]
Implementacja map() i filter() za pomocą reduce():
const numbers = [1, 2, 3, 4];
// Implementacja map() przy użyciu reduce()
const doubled = numbers.reduce((acc, num) => {
acc.push(num * 2);
return acc;
}, []);
// Implementacja filter() przy użyciu reduce()
const evens = numbers.reduce((acc, num) => {
if (num % 2 === 0) acc.push(num);
return acc;
}, []);
Niezmienność danych i operator spread
Spread operator (...) pomaga utrzymać niezmienność przez tworzenie nowych struktur na bazie istniejących.
Przykłady dla tablic:
const original = [1, 2, 3];
const copy = [...original];
const merged = [...original, 4, 5];
Przykłady dla obiektów:
const person = { name: 'Alice', age: 25 };
const updated = { ...person, age: 26 };
// Oryginalny obiekt person pozostaje niezmieniony
Przy mapowaniu obiektów spread pozwala kopiować wszystkie pola i modyfikować tylko wybrane, co doskonale współgra z zasadą niezmienności.
Łańcuchowanie metod – zaawansowana kompozycja
map() i filter() zawsze zwracają tablice, dzięki czemu łatwo je łączyć; reduce() zwykle kończy łańcuch, zwracając dowolny typ.
Przykładowy potok przetwarzania:
const products = [
{ name: 'Laptop', price: 1000, active: true },
{ name: 'Phone', price: 500, active: false },
{ name: 'Headphones', price: 150, active: true },
];
const totalPrice = products
.filter(product => product.active)
.map(product => ({ ...product, price: product.price * 0.9 }))
.reduce((total, product) => total + product.price, 0);
console.log(totalPrice); // 1035
Najpierw filtruj, potem mapuj – mniejsza kolekcja oznacza mniej operacji transformacji i lepszą wydajność.
Programowanie funkcyjne – koncepcje i praktyki
Czyste funkcje i ich znaczenie
Czyste funkcje zwracają ten sam wynik dla tych samych argumentów i nie mają efektów ubocznych.
Przykłady funkcji czystych:
const add = (a, b) => a + b;
const square = (x) => x * x;
Przykłady funkcji nieczystych (zależność od zewnętrznego stanu lub jego modyfikacja):
let multiplier = 2;
const multiply = (x) => x * multiplier; // Zależy od zmiennej spoza zakresu
let result = 0;
const accumulate = (x) => {
result += x; // Efekt uboczny - modyfikacja zmiennej spoza zakresu
return result;
};
Czyste funkcje są prostsze w testowaniu, debugowaniu i komponowaniu.
Funkcje wyższego rzędu
Funkcje wyższego rzędu przyjmują lub zwracają funkcje. map(), filter() i reduce() to klasyczne przykłady, które abstrahują wzorzec iteracji.
Tworzenie własnych HOF z parametryzacją zachowania:
// Funkcja wyższego rzędu, która zwraca funkcję
const createMultiplier = (factor) => {
return (value) => value * factor;
};
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
Kompozycja funkcji
Kompozycja łączy proste funkcje w bardziej złożone przepływy. Poniżej compose (od prawej do lewej) i pipe (od lewej do prawej):
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const addTwo = (x) => x + 2;
const double = (x) => x * 2;
const pipeline = pipe(addTwo, double); // najpierw dodaj 2, potem podwój
console.log(pipeline(5)); // (5 + 2) * 2 = 14
Porównanie map(), filter() i reduce() z alternatywami
Różnice między map() a forEach()
map() zwraca nową tablicę, forEach() zwraca undefined. map() służy do transformacji, a forEach() – do efektów ubocznych (np. logowanie, zapis).
Wybór metody warto uprościć następującymi wskazówkami:
- map() – gdy chcesz przekształcić dane i otrzymać nową tablicę;
- forEach() – gdy wykonujesz operację dla każdego elementu bez zbierania wyników;
- reduce() – gdy potrzebujesz jednej wartości wynikowej (np. suma, indeks, obiekt agregujący).
Wydajność i optymalizacja
Klasyczne pętle for bywają szybsze na ogromnych kolekcjach, ale korzyści z czytelności i utrzymania często przeważają.
Łączenie operacji w jednym przejściu ogranicza koszty tworzenia pośrednich tablic:
// Mniej wydajne - tworzy dwie pośrednie tablice
const result = array
.filter(item => item.active)
.map(item => item * 2);
// Bardziej wydajne - jedno przejście
const result = array.reduce((acc, item) => {
if (item.active) acc.push(item * 2);
return acc;
}, []);
Zaawansowane scenariusze i praktyczne zastosowania
Praca z zagnieżdżonymi strukturami danych
Metoda flatMap() (ES2019) łączy mapowanie i płytkie spłaszczanie:
const arr = [1, 2, 3];
const result = arr.flatMap(x => [x, x * 2]);
console.log(result); // [1, 2, 2, 4, 3, 6]
Rekurencyjne reduce dla zagnieżdżonych danych
Rekurencyjne reduce ujednolica przetwarzanie dowolnie zagnieżdżonych struktur:
function deepReduce(collection, fn, memo) {
function iterator(value, path) {
memo = fn(memo, value, path);
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
iterator(value[i], path.concat(i));
}
} else if (typeof value === 'object' && value !== null) {
for (let key in value) {
iterator(value[key], path.concat(key));
}
}
return memo;
}
return iterator(collection, []);
}
Obsługa null i undefined
Przy filtrowaniu wartości falsy stosuj selektywne warunki – v != null usuwa wyłącznie null i undefined, nie odfiltrowując 0 ani pustego stringa.
Przykłady bezpiecznego filtrowania:
// Usuwa zarówno null/undefined, jak i 0
const filtered1 = data.filter(v => !!v);
// Lepsze - usuwa tylko null/undefined
const filtered2 = data.filter(v => v != null);
// Sprawdzenie typu
const filtered3 = data.filter(v => typeof v === 'number');
Najlepsze praktyki i zalecenia
Czytelność kodu
Wyciągaj złożone callbacki do nazwanych funkcji – poprawia to zrozumiałość i testowalność.
Przykład refaktoryzacji:
// Słabo czytelne
const result = data.map(item => {
const processed = item.value * 2 + item.adjustment;
return {
...item,
processed,
status: processed > 100 ? 'high' : processed > 50 ? 'medium' : 'low'
};
});
// Lepiej czytelne
const calculateStatus = (value) => value > 100 ? 'high' : value > 50 ? 'medium' : 'low';
const processItem = (item) => ({
...item,
processed: item.value * 2 + item.adjustment,
status: calculateStatus(item.value * 2 + item.adjustment)
});
const result = data.map(processItem);
Dokumentacja i typowanie
W TypeScript lub JSDoc eksplicitnie dokumentuj typy argumentów i wartości zwracanych.
Przykład adnotacji JSDoc:
/**
* Transformuje produkt poprzez zastosowanie rabatu
* @param {Object} product - Obiekt produktu
* @param {string} product.name - Nazwa produktu
* @param {number} product.price - Cena produktu
* @returns {Object} Produkt z obniżoną ceną
*/
const applyDiscount = (product) => ({ ...product, price: product.price * 0.9 });
Testowanie funkcji callback
Testuj czyste funkcje w izolacji, a nie tylko w kontekście metod iteracyjnych.
Przykład testu jednostkowego:
describe('processItem', () => {
it('powinno zastosować 10% rabatu do ceny produktu', () => {
const product = { name: 'Laptop', price: 1000 };
const result = applyDiscount(product);
expect(result.price).toBe(900);
expect(product.price).toBe(1000); // Oryginał niezmieniony
});
});
Zaawansowane koncepcje – operatory i reaktywne programowanie
W podejściu reaktywnym (np. RxJS) operatory map, filter i reduce działają na strumieniach asynchronicznych:
observable
.pipe(
filter(value => value > 5),
map(value => value * 2),
reduce((acc, value) => acc + value, 0)
)
.subscribe(result => console.log(result));