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

8. concatMap и exhaustMap

Яша, мы уже знаем mergeMap (параллельно) и switchMap (отменяй прошлое). Теперь — два оставшихся оператора сплющивания. Они про контроль и порядок! 🎛️


Напомним всю картину целиком:

Новый запрос пришёл, пока старый ещё выполняется. Что делать?
mergeMap → Запускай параллельно! (всё сразу)
switchMap → Отмени старый, запусти новый! (только последний)
concatMap → Подожди, поставь в очередь! (по порядку)
exhaustMap → Проигнорируй новый, старый ещё занят! (один за раз)

concatMap не отменяет ничего и не запускает параллельно. Он терпеливо ждёт завершения текущего Observable, и только потом берётся за следующий. Как в очереди к врачу — один вошёл, другие ждут. 🏥

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

Клики: --C1--C2--C3-->
C1 запрос (занимает 3 тика): [---R1-->]
C2 ждёт: [---R2-->]
C3 ждёт ещё: [---R3-->]
concatMap:
Результат: --------R1--------R2--------R3-->
import { Subject, from } from 'rxjs';
import { concatMap, map, delay } from 'rxjs/operators';
import { of } from 'rxjs';
// Симуляция API-запроса
const saveData = (data: string) =>
of(`Сохранено: ${data}`).pipe(delay(1000));
const saveQueue$ = new Subject<string>();
// concatMap гарантирует: следующее сохранение начнётся только после завершения предыдущего
saveQueue$.pipe(
concatMap(data => saveData(data))
).subscribe(result => {
console.log(result);
});
// Добавляем в очередь
saveQueue$.next('Запись 1');
saveQueue$.next('Запись 2');
saveQueue$.next('Запись 3');
// Вывод (строго по порядку, через каждую секунду):
// (1с) "Сохранено: Запись 1"
// (2с) "Сохранено: Запись 2"
// (3с) "Сохранено: Запись 3"

Реальный пример: последовательные анимации

import { from, of } from 'rxjs';
import { concatMap, delay, tap } from 'rxjs/operators';
interface AnimationStep {
element: string;
animation: string;
duration: number;
}
const steps: AnimationStep[] = [
{ element: '.header', animation: 'fadeIn', duration: 500 },
{ element: '.content', animation: 'slideIn', duration: 800 },
{ element: '.footer', animation: 'fadeIn', duration: 400 },
];
// Анимации идут строго одна за другой
from(steps).pipe(
concatMap(step =>
of(step).pipe(
tap(s => {
const el = document.querySelector(s.element);
el?.classList.add(s.animation);
console.log(`▶️ Запустили анимацию: ${s.element}`);
}),
delay(step.duration),
tap(s => console.log(`✅ Завершено: ${s.element}`))
)
)
).subscribe();

Сравнение с mergeMap:

import { Subject, of, timer } from 'rxjs';
import { mergeMap, concatMap, map } from 'rxjs/operators';
const requests$ = new Subject<string>();
// mergeMap: все три запроса летят одновременно, завершаются в произвольном порядке
requests$.pipe(
mergeMap(id => of(`Результат ${id}`).pipe(
// разные задержки
map(v => v)
))
).subscribe(console.log);
// concatMap: строгая очерёдность, даже если запрос быстрее
requests$.pipe(
concatMap(id => of(`Результат ${id}`).pipe(
map(v => v)
))
).subscribe(console.log);

💡 Когда использовать concatMap:

  • Последовательные шаги onboarding
  • Анимации, зависящие от порядка
  • Запись данных, где важна очерёдность
  • Синхронизация офлайн-данных

exhaustMap — это “кабинет директора с секретарём”: если директор занят — новых посетителей не принимает. Текущая задача завершится — только тогда примет следующую. ВСЕ запросы, пришедшие пока занят, игнорируются. 🙅

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

Клики: --C1--C2--C3-------C4-->
C1 запрос (занимает 3 тика): [---R1-->]
C2 пришёл пока C1 занят: ⛔ ПРОИГНОРИРОВАН
C3 пришёл пока C1 занят: ⛔ ПРОИГНОРИРОВАН
C4 пришёл после завершения: [---R4-->]
exhaustMap:
Результат: --------R1---------R4-->
import { fromEvent, from, of } from 'rxjs';
import { exhaustMap, tap, delay, map } from 'rxjs/operators';
// 🎯 Классический пример: кнопка отправки формы
const submitButton = document.getElementById('submit') as HTMLButtonElement;
fromEvent(submitButton, 'click').pipe(
exhaustMap(() => {
// Пока запрос выполняется — дополнительные клики ИГНОРИРУЮТСЯ
return from(
fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ data: 'form data' }),
}).then(r => r.json())
).pipe(
tap(() => console.log('✅ Форма отправлена'))
);
})
).subscribe(response => {
console.log('Ответ сервера:', response);
});
// Нажали 5 раз подряд — отправится ТОЛЬКО ПЕРВЫЙ запрос
// Остальные 4 клика будут проигнорированы!

Реальный пример: обновление токена

import { Subject, from, of } from 'rxjs';
import { exhaustMap, catchError, tap } from 'rxjs/operators';
// Несколько компонентов могут запросить обновление токена одновременно
const refreshToken$ = new Subject<void>();
refreshToken$.pipe(
exhaustMap(() => {
console.log('🔄 Обновляем токен...');
return from(
fetch('/api/auth/refresh', { method: 'POST' }).then(r => r.json())
).pipe(
tap(token => {
localStorage.setItem('token', token.access_token);
console.log('✅ Токен обновлён');
}),
catchError(err => {
console.error('❌ Ошибка обновления токена');
return of(null);
})
);
})
).subscribe();
// Три компонента одновременно просят обновить токен
refreshToken$.next(); // ✅ Запрос отправлен
refreshToken$.next(); // ⛔ Проигнорирован (предыдущий ещё не завершён)
refreshToken$.next(); // ⛔ Проигнорирован
// Токен обновится ровно ОДИН раз!

🎛️ Полное сравнение всех четырёх операторов

Заголовок раздела «🎛️ Полное сравнение всех четырёх операторов»
import { Subject, of } from 'rxjs';
import { mergeMap, switchMap, concatMap, exhaustMap, delay, map } from 'rxjs/operators';
// Тест: 3 быстрых клика, каждый запрос занимает 1 секунду
const clicks$ = new Subject<number>();
const request = (n: number) => of(`Ответ ${n}`).pipe(delay(1000));
// mergeMap: все 3 запроса параллельно → 3 ответа через 1 секунду
clicks$.pipe(mergeMap(n => request(n))).subscribe(v => console.log('merge:', v));
// switchMap: только последний клик имеет значение → 1 ответ через 1 секунду
clicks$.pipe(switchMap(n => request(n))).subscribe(v => console.log('switch:', v));
// concatMap: ответы строго по порядку → через 1с, 2с, 3с
clicks$.pipe(concatMap(n => request(n))).subscribe(v => console.log('concat:', v));
// exhaustMap: только первый клик → 1 ответ через 1 секунду
clicks$.pipe(exhaustMap(n => request(n))).subscribe(v => console.log('exhaust:', v));
clicks$.next(1);
clicks$.next(2);
clicks$.next(3);
ОператорПараллельно?Отменяет?Игнорирует?Use case
mergeMap✅ Да❌ Нет❌ НетЗагрузка файлов
switchMap❌ Нет✅ Да❌ НетПоиск, навигация
concatMap❌ Нет❌ Нет❌ НетАнимации, очереди
exhaustMap❌ Нет❌ Нет✅ ДаSubmit-кнопки

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