Перейти к содержимому

5. map, filter, reduce

Привет, Яша! 🎉 Сегодня мы разберём самые важные операторы трансформации данных в RxJS. Это твой швейцарский нож — используешь их каждый день!


Представь, что Observable — это конвейерная лента на заводе. Сырые детали едут по ленте, и на каждой станции что-то происходит: одни детали отбраковываются, другие обрабатываются, третьи складываются в стопки. Операторы — это и есть те самые станции! 🏭

Сырые данные → [map] → [filter] → [reduce] → Готовый результат

map работает точно так же, как Array.prototype.map, только для потоков. Берёт каждое значение и возвращает новое.

Мраморная диаграмма:

Источник: --1----2----3----4-->
map(x=>x*2):
Результат: --2----4----6----8-->
import { of, from } from 'rxjs';
import { map } from 'rxjs/operators';
// Простейший пример
of(1, 2, 3, 4, 5).pipe(
map(x => x * 2)
).subscribe(console.log);
// 2, 4, 6, 8, 10
// Реальный пример: трансформация ответа API
interface ApiUser {
first_name: string;
last_name: string;
age: number;
}
interface DisplayUser {
fullName: string;
isAdult: boolean;
}
from(fetch('/api/users').then(r => r.json())).pipe(
map((users: ApiUser[]) =>
users.map(u => ({
fullName: `${u.first_name} ${u.last_name}`,
isAdult: u.age >= 18,
}))
)
).subscribe(displayUsers => {
console.log(displayUsers);
});

💡 Запомни: map — чистая функция. Входные данные не меняются, создаётся новое значение.


filter работает как охранник на входе: пропускает только тех, кто соответствует условию.

Мраморная диаграмма:

Источник: --1----2----3----4----5-->
filter(x => x > 2):
Результат: ---------------3----4----5-->
import { from } from 'rxjs';
import { filter, map } from 'rxjs/operators';
// Фильтрация событий пользователя
interface UserEvent {
type: 'click' | 'hover' | 'keypress';
target: string;
value?: string;
}
const events$ = from<UserEvent[]>([
{ type: 'hover', target: 'button' },
{ type: 'click', target: 'button' },
{ type: 'keypress', target: 'input', value: 'H' },
{ type: 'click', target: 'link' },
]);
// Оставляем только клики
events$.pipe(
filter(event => event.type === 'click')
).subscribe(event => {
console.log('Клик по:', event.target);
});
// "Клик по: button"
// "Клик по: link"
// Цепочка filter + map
events$.pipe(
filter(event => event.type === 'keypress'),
filter(event => !!event.value),
map(event => event.value!.toUpperCase())
).subscribe(char => {
console.log('Нажата клавиша:', char);
});
// "Нажата клавиша: H"

reduce ждёт завершения потока и накапливает результат — как копилка, которую вскрываешь только в конце. 🐷

Мраморная диаграмма:

Источник: --1----2----3--|
reduce((a,b)=>a+b, 0):
Результат: ---------------6|

⚠️ Важно: reduce эмитит значение ТОЛЬКО когда поток завершился (complete). Для бесконечных потоков — не подходит!

import { from } from 'rxjs';
import { reduce } from 'rxjs/operators';
// Подсчёт суммы заказа
interface OrderItem {
name: string;
price: number;
quantity: number;
}
const items: OrderItem[] = [
{ name: 'Кофе', price: 150, quantity: 2 },
{ name: 'Круассан', price: 80, quantity: 3 },
{ name: 'Сок', price: 120, quantity: 1 },
];
from(items).pipe(
reduce((total, item) => total + item.price * item.quantity, 0)
).subscribe(total => {
console.log(`Итого: ${total} руб.`);
// "Итого: 660 руб."
});
// Собираем данные в объект
from(['Alice', 'Bob', 'Alice', 'Charlie', 'Bob', 'Bob']).pipe(
reduce((acc, name) => {
acc[name] = (acc[name] || 0) + 1;
return acc;
}, {} as Record<string, number>)
).subscribe(counts => {
console.log(counts);
// { Alice: 2, Bob: 3, Charlie: 1 }
});

scan — это reduce, но который эмитит значение на КАЖДОМ шаге. Как бегущий итог в кассовом аппарате! 🧾

Мраморная диаграмма:

Источник: --1----2----3----4-->
scan((a,b)=>a+b, 0):
Результат: --1----3----6----10-->
import { Subject } from 'rxjs';
import { scan, map } from 'rxjs/operators';
// Корзина покупок в реальном времени
interface CartAction {
type: 'add' | 'remove';
item: string;
price: number;
}
const cartActions$ = new Subject<CartAction>();
// Состояние корзины обновляется при каждом действии
cartActions$.pipe(
scan((cart, action) => {
if (action.type === 'add') {
return {
items: [...cart.items, action.item],
total: cart.total + action.price,
};
} else {
return {
items: cart.items.filter(i => i !== action.item),
total: cart.total - action.price,
};
}
}, { items: [] as string[], total: 0 })
).subscribe(cart => {
console.log('Корзина:', cart.items, 'Сумма:', cart.total);
});
cartActions$.next({ type: 'add', item: 'Книга', price: 500 });
// Корзина: ['Книга'] Сумма: 500
cartActions$.next({ type: 'add', item: 'Ручка', price: 50 });
// Корзина: ['Книга', 'Ручка'] Сумма: 550
cartActions$.next({ type: 'remove', item: 'Книга', price: 500 });
// Корзина: ['Ручка'] Сумма: 50

💡 scan идеально подходит для управления состоянием в стиле Redux!


pluck устарел в RxJS 8, но его легко заменить через map. Он извлекает вложенное свойство объекта.

import { from } from 'rxjs';
import { map } from 'rxjs/operators';
interface Response {
data: {
user: {
name: string;
email: string;
};
};
}
// Вместо pluck используй map:
from<Response[]>([
{ data: { user: { name: 'Alice', email: '[email protected]' } } },
{ data: { user: { name: 'Bob', email: '[email protected]' } } },
]).pipe(
map(response => response.data.user.name) // достаём только имя
).subscribe(name => console.log(name));
// "Alice"
// "Bob"

Вот как операторы работают вместе в реальном сценарии:

import { fromEvent } from 'rxjs';
import { map, filter, debounceTime, distinctUntilChanged } from 'rxjs/operators';
const searchInput = document.getElementById('search') as HTMLInputElement;
fromEvent(searchInput, 'input').pipe(
map(event => (event.target as HTMLInputElement).value),
filter(value => value.length > 2), // минимум 3 символа
debounceTime(300), // ждём паузы
distinctUntilChanged(), // не повторяем одинаковые
map(value => value.trim().toLowerCase()), // нормализуем
).subscribe(query => {
console.log('Ищем:', query);
// отправляем запрос на сервер
});

Попробуйте примеры в интерактивном редакторе: