RxJS: як оператори вищого порядку спрощують код

RxJS

Якщо ви працювали з Angular, то, напевно, зустрічалися з RxJS. Потоки, розлогі конструкції, багато аргументів у методу pipe, а кожен аргумент повертають різні функції з різною кількістю аргументів. Існують інтуїтивно зрозумілі функції типу filter або map. Перший явно фільтрує значення потоці, а другий ці значення змінює. Такі функції називають операторами. І чим глибше ви провалюєтеся в RxJS, тим більше різних операторів ви дізнаєтеся. І з часом дістаєтеся потоків потоків. Тобто замість звичайних значень такий потік емітує інші потоки. Такі потоки називають Higher Order Observables. І для роботи з такими потоками є спеціальні оператори. Можливо, ви чули, що такі оператори називають Higher Order Operators (HOO). Вони можуть вирівнювати потоки або, іншими словами, робити їх звичайним.

У цій статті я покажу, що в HOO немає нічого міфічного, і розповім, у яких випадках вам потрібно використовувати оператори вищого порядку. Зараз ви подумаєте, що це нудний лонгрід, але не поспішайте. Ми розглянемо всього 4 оператори: switchMapexhaustMapconcatMapі mergeMap.

switchMap

switchMapОднозначно найпопулярніший з усіх. Але чому? А з однієї просто причини — цей оператор позбавляє нас від стану гонки, що дуже часто зустрічається.

Давайте розглянемо код:

const searchInput = document.getElementById('search-input');
const search$ = fromEvent(searchInput, 'input').pipe(
  map(event => event.target.value)
);

search$.subscribe((query) => {
  http.get('/search', {params: {query}}).subcribe((result) => {
    console.log(result);
  })
})

У цьому коді ми знаходимо input, з яким взаємодіє користувач і підписуємось на подію input. Тобто потік search$ емітит рядка. Усередині підписки бачимо, що у кожен еміт рядка відправляється запит на сервер і відповідь сервісу виводиться у консоль.

У цьому коді можна побачити щонайменше дві проблеми:

  1. Race state.  Зазвичай при пошуку чогось користувача важливо бачити результат саме останнього запиту. Але код такого виду не дає нам гарантії, що останні дані, виведені в консоль, відповідають останньому рядку, що випускається в потоці search$.
  2. Subscribe in subscribe та жодного unsubscribe. Є дуже хороше правило, за яким можна позбавити себе багатьох проблем, — на кожен subscrib має бути unsubscribe. Це щонайменше знизить ймовірність витоку пам’яті.

Але давайте подумаємо, як має працювати пошук:

  1. У момент еміту рядка перевірити наявність активних запитів
  2. Якщо запитів немає, перейти до пункту 4
  3. Відписатися від попередніх запитів
  4. Передплатити новий запит
  5. Вивести у консоль

У лоб це можна реалізувати так:

const searchInput = document.getElementById('search-input');

const search$ = fromEvent(searchInput, 'input').pipe(
  map(event => event.target.value)
);


let subscription;
search$.subscribe((query) => {
  if (subscription) {
    subscription.unsubscribe();
  }

  subscription = http.get('/search', {params: {query}}).subcribe((result) => {
    console.log(result);
  })
})

Але якщо я вам скажу, що перші 4 пункти вимог робить оператор switchMap? Давайте перепишемо код:

const searchInput = document.getElementById('search-input');
const search$ = fromEvent(searchInput, 'input').pipe(
  map(event => event.target.value)
);

search$.pipe(
  switchMap((query) => http.get('/search', {params: {query}}))
).subscribe((result) => {
  console.log(result);
});

switchMapгарантує нам, що ми завжди будемо отримувати результати останнього потоку та позбавляє нас race state. Ну і приємний бонус полягатиме в тому, що відмовившись від зовнішньої підписки автоматично відбудеться і відписка від внутрішньої. Профіт!

Резюмуємо. switchMapможна використовувати у випадках, коли нам важливим є результат останньої дії, наприклад, при пошуку або реалізації нескінченного скрола. Якщо всі попередні дії можуть не враховуватися, можна сміливо брати switchMap.

exhaustMap

exhaustMapОднозначно найпопулярніший з усіх. Причина мені не до кінця зрозуміла, але за допомогою нього можна реалізувати пошук, але використовуючи інший підхід.

const searchInput = document.getElementById('search-input');
const searchButton = document.getElementById('search-button');

const searchButtonClick$ = fromEvent(searchButton, 'click');

searchButtonClick$.subscribe((result) => {
  const query = searchInput.value;
  http.get('/search', { params: { query } }).subscribe((result) => {
    console.log(result);
  });
});

У цьому коді ініціатор запит – клік по кнопці.

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

  1. У момент натискання на кнопку перевірити наявність активних запитів
  2. Якщо є активний, нічого не робити та чекати наступного кліку
  3. Якщо немає активного, підписатися на виконання запиту
  4. Вивести результат у консоль

Реалізуємо в лоб:

const searchInput = document.getElementById('search-input');
const searchButton = document.getElementById('search-button');

const searchButtonClick$ = fromEvent(searchButton, 'click');

let isRequestPending = false;
searchButtonClick$.subscribe((result) => {
  if (isRequestPending) {
    return;
  }

  isRequestPending = true;
  const query = searchInput.value;
  http.get('/search', { params: { query } }).subscribe((result) => {
    isRequestPending = false;
    console.log(result);
  });
});

Як і в першому випадку нам знадобилася ще одна змінна поза потоком. Це додає коду складності і якщо ми захочемо змінити поведінку потоку, все доведеться переписати. Ну і як ви вже здогадалися на допомогу нам приходить exhaustMap.

const searchInput = document.getElementById('search-input');
const searchButton = document.getElementById('search-button');

const searchInput$ = fromEvent(searchButton, 'click');

searchInput$.pipe(
  exhaustMap(() => {
    const query = searchInput.value;
    return http.get('/search', { params: { query } });
  })
).subscribe((result) => {
  console.log(result);
});

Резюмуємо. exhaustMapпотрібно використовувати у випадках, коли при активній підписці на потік інші можна ігнорувати, як у разі пошуку натискання кнопки або, наприклад, при пропуску подій початку анімації при її відтворенні.

mergeMap

mergeMap– Оператор, який об’єднує всі внутрішні потоки в один вихідний потік. Це означає, що внутрішні потоки можуть завершуватись у будь-якому порядку, і їх результати будуть об’єднані разом. І це найпростіше пояснення, яке я зміг із себе видавити.

Давайте перейдемо до прикладів:

entityId$.subscribe((id) => {
  entityService.get(id).subscribe(() => {
    entityStore.upsertItem(entity);
  });
});

У цьому коді ми бачимо entityId$ – це потік рядків з id певної сутності. Тут ми повинні на кожен id запросити дані щодо сутності з сервера і додати або оновити цю сутність в стор. Власне, саме це наш код і робить і вирішувати тут нема чого. Але проблеми є і в цьому випадку вони абсолютно ідентичні попереднім. Давайте спробуємо ускладнити завдання і ввести обмеження в 3 запити одночасно.

Вирішуємо:

const CONCURRENT_REQUESTS = 3;
let activeRequests = 0;
const queue = [];

function processNext() {
  if (queue.length === 0 || activeRequests >= CONCURRENT_REQUESTS) {
    return;
  }

  const id = queue.shift();
  activeRequests++;

  entityService.get(id).subscribe(entity => {
    entityStore.upsertItem(entity);
    activeRequests--;

    processNext();
  });
}

entityId$.subscribe(id => {
  queue.push(id);
  processNext();
});

Я навіть не намагався перевіряти код на працездатність, бо написав його у редакторі тексту. Код став складно-читаним. Функція processNextмає кілька побічних ефектів усередині. А ще є додаткові змінні за межами потоку та функції. Скласти все це воєдино досить складно.

Саме такі завдання вирішує mergeMap. Давайте перепишемо перший приклад із використанням цього оператора:

entityId$.pipe(
  mergeMap((id) => entityService.get(id))
).subscribe((entity) => {
  entityStore.upsertItem(entity);
});

У цьому коді mergeMapпідписується на кожен потік, повернутий entityService.get(id)і їх значення видає в одному єдиному потоці.

Добре, а як бути з обмеженням 3 запити в один момент часу? Виявляється, що mergeMapвже й так усе вміє. Другий аргумент у mergeMapприймає число, яке якраз налаштовує конкурентність.

entityId$.pipe(
  mergeMap((id) => entityService.get(id), 3)
).subscribe((entity) => {
  entityStore.upsertItem(entity);
});

От і все!

Резюмуємо. mergeMapвідмінно підходить, коли ви хочете виконувати паралельні дії та поєднувати їх результати. Однак, слід бути обережним, оскільки може виникнути багато активних запитів, якщо вихідний потік випромінює значення занадто швидко.

concatMap

concatMap– Останній оператор вищого порядку. Ключова відмінність полягає в тому, що concatMap підтримує порядок виконання. Він дочекається завершення одного внутрішнього потоку, перш ніж перейде до наступного.

Щоб практично подивитися на його використання, ми можемо взяти попередній приклад і змінити до нього вимоги. Так вийшло, що нас перестало влаштовувати невпорядкованість запитів, і ми хочемо виконувати їх не паралельно, а по черзі. Тобто конкурентність має стати рівною одиниці.

entityId$.pipe(
  mergeMap((id) => entityService.get(id), 1)
).subscribe((entity) => {
  entityStore.upsertItem(entity);
});

Але mergeMap з конкурентністю 1 робить те ж саме, що і concatMap! Буквально. Це чудово видно у вихідному коді оператора.

concatMap

Тобто використання mergeMap із конкурентністю 1 на стільки частий кейс, що його винесли в окремий оператор.

Резюмуємо. concatMapчудово підходить для ситуацій, коли порядок виконання важливий. Якщо ви хочете обробити послідовність дій без паралельної обробки, це ваш вибір.

Висновок

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

  • Оператор switchMap чудово підходить, коли нам важливий лише останній результат випромінювання, наприклад, у разі пошуку у реальному часі.
  • exhaustMap ідеальний для випадків, коли нам потрібно ігнорувати нові об’єкти, що спостерігаються, до завершення поточного.
  • mergeMap дозволяє обробляти кілька вхідних об’єктів, що спостерігаються паралельно, але може призвести до перевантаження, якщо не контролювати кількість одночасних потоків.
  • concatMap гарантує порядок обробки, виконуючи кожен внутрішній об’єкт, що спостерігається послідовно.

При правильному використанні ці оператори можуть справлятися з безліччю реактивних завдань, будь то події з інтерфейсу користувача, HTTP-запити або навіть складні анімаційні послідовності.

Однак ключове слово тут – правильне використання . Завжди аналізуйте вимоги вашої програми та ретельно вибирайте відповідний оператор. Це допоможе уникнути небажаних побічних ефектів та створити реактивні рішення, які можуть масштабуватися та легко підтримуватися.

Реактивне програмування пропонує безліч інструментів, серед них високорівневі оператори займають особливе місце. Провівши час на вивчення та розуміння їх особливостей, ви значно можете покращити якість та ефективність вашого коду.

Джерело