8. concatMap и exhaustMap
⏳ concatMap и exhaustMap — контроль очереди
Заголовок раздела «⏳ concatMap и exhaustMap — контроль очереди»Яша, мы уже знаем mergeMap (параллельно) и switchMap (отменяй прошлое). Теперь — два оставшихся оператора сплющивания. Они про контроль и порядок! 🎛️
🎯 Четыре стратегии сплющивания
Заголовок раздела «🎯 Четыре стратегии сплющивания»Напомним всю картину целиком:
Новый запрос пришёл, пока старый ещё выполняется. Что делать?
mergeMap → Запускай параллельно! (всё сразу)switchMap → Отмени старый, запусти новый! (только последний)concatMap → Подожди, поставь в очередь! (по порядку)exhaustMap → Проигнорируй новый, старый ещё занят! (один за раз)📋 concatMap — очередь строго по порядку
Заголовок раздела «📋 concatMap — очередь строго по порядку»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 — занят, приходи позже
Заголовок раздела «🚫 exhaustMap — занят, приходи позже»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-кнопки |
Практика
Заголовок раздела «Практика»Попробуйте примеры в интерактивном редакторе: