Асинхронні цикли: чому вони не спрацьовують

8 хв. читання

Функції вищого порядку — як от forEach(), reduce(), map() і filter() — досить поширені для програмування JavaScript. Та якщо їх змішати з асинхронними викликами й промісами, результати можуть бути неочікуваними. І проблеми виникають лише з цими функціями, на основний код це не впливає.

Які можуть виникати складнощі

Щоб побачити, в чому проблема, почнемо з фальшивого асинхронного виклику, що за короткий час (timeToWait) поверне задане значення (dataToReturn). Для наочного тестування інколи треба буде завершити виклик невдачею, тому ми додамо третій параметр (fail), який автоматично буде false.

Для більшості прикладів ми будемо використовувати ось цей код :

export const getAsyncData = (dataToReturn, timeToWait, fail = false) =>
  new Promise((resolve, reject) =>
    setTimeout(
      () => (fail ? reject("FAILED") : resolve(dataToReturn)),
      timeToWait
    )
  );

Також знадобиться логування у реальному часі, тож ми можемо зробити так:

export const logWithTime = (val) =>
  console.log(new Date().toJSON().substr(11, 12), val);

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

import { logWithTime, getAsyncData } from "./functions.mjs";

logWithTime("START #1 -- sequential calls");
logWithTime(await getAsyncData("data #1", 1000));
logWithTime(await getAsyncData("data #2", 2000));
logWithTime(await getAsyncData("data #3", 3000));
logWithTime(await getAsyncData("data #5", 5000));
logWithTime(await getAsyncData("data #8", 8000));
logWithTime("END #1");

Ми запускаємо код і отримуємо результати, які нас задовольняють. Асинхронні виклики не виконуються паралельно. Для першого потрібна одна секунда, для другого — дві секунди після першого, і так далі. Загалом весь експеримент займає близько дев'ятнадцяти секунд.

13:05:28.264 START #1 -- sequential calls
13:05:29.269 data #1
13:05:31.270 data #2
13:05:34.274 data #3
13:05:39.274 data #5
13:05:47.283 data #8
13:05:47.283 END #1

Ми також можемо запустити код за допомогою звичайного циклу for(), це теж спрацює, оскільки функції вищого порядку не задіяні.

const data = [1, 2, 3, 5, 8];

logWithTime("START #2 -- using a common for(...)");
for (let i = 0; i < data.length; i++) {
  const val = data[i];
  const result = await getAsyncData(`data #${val}`, 1000 * val);
  logWithTime(result);
}
logWithTime("END #2");

Результати подібні. Виклики йдуть так само, як і раніше.

13:05:47.284 START #2 -- using a common for(...)
13:05:48.285 data #1
13:05:50.286 data #2
13:05:53.290 data #3
13:05:58.292 data #5
13:06:06.296 data #8
13:06:06.297 END #2

А зараз спробуємо forEach()!

logWithTime("START #3 -- using forEach(...)");
data.forEach(async (val) => {
  const result = await getAsyncData(`data #${val}`, 1000 * val);
  logWithTime(result);
});
logWithTime("END #3");

Халепа! Цикл закінчується до того моменту, як виконається будь-який асинхронний виклик.

13:06:06.297 START #3 -- using forEach(...)
13:06:06.298 END #3
13:06:07.299 data #1
13:06:08.298 data #2
13:06:09.298 data #3
13:06:11.298 data #5
13:06:14.298 data #8

Це відома проблема. Наприклад, у MDN ми читаємо: forEach очікує синхронну функцію, forEach не чекає на проміси. Будь ласка, переконайтеся, що ви знаєте про наслідки, коли використовуєте проміси (або асинхронні функції) як колбек forEach.

Ця проблема стосується й map(), reduce() тощо. Отже, розглянемо, як це можна обійти!

Зациклення

Як усунути проблему forEach()? Оскільки ми будемо працювати з промісами, то результат цього методу теж буде проміс. Ми хочемо послідовно пройти масив, щоразу викликаючи колбек, але тільки після того, як закінчиться попередній.

Простий спосіб впоратися з цим — пов'язати новий виклик із попереднім. Оскільки ми можемо використовувати finally(), то й зможемо впоратись із помилками (наприклад, будемо ігнорувати їх).

Array.prototype.forEachAsync = function (fn) {
  return this.reduce(
    (prom, val, idx, arr) => prom.finally(() => fn(val, idx, arr)),
    Promise.resolve()
  );
};

export const forEachAsync = (arr, fn) =>
  arr.reduce(
    (prom, val, idx, arr) => prom.finally(() => fn(val, idx, arr)),
    Promise.resolve()
  );

Ми робимо це за допомогою .reduce() та починаємо з виконаного проміса. Для кожного елемента у масиві ми викликаємо асинхронну функцію у виклику .finally() для попереднього проміса. (Ми також могли би працювати із .then() та .catch(), та нам доведеться дублювати код.) Після вдалого проміса наступний виклик функції пройде через весь масив та завершиться.

Щоразу ми надамо дві реалізації для кожної функції: одну додамо до Array.prototype (хоча модифікувати прототип зазвичай не рекомендується), а другу як самостійну функцію. Можете вибрати будь-яку.

Тепер розглянемо альтернативу! У нас буде виклик getForEachData(), що отримає значення з нашого фіктивного виклику API. Лише для різноманітності, якщо ми передамо 2 як аргумент, виклик не вдасться. Повний код наведено нижче.

import { logWithTime, getAsyncData } from "./functions.mjs";
import { forEachAsync } from "./forEachAsync.mjs";

const getForEachData = async (v, i, a) => {
  logWithTime(`Calling - v=${v} i=${i} a=[${a}]`);
  try {
    const result = await getAsyncData(`data #${v}`, 1000 * v, v === 2);
    logWithTime(`Success - ${result}`);
    return result;
  } catch (e) {
    logWithTime(`Failure - error`);
    return undefined;
  }
};

logWithTime("START -- using .forEachAsync(...) method");
await [1, 2, 3, 5, 8].forEachAsync(getForEachData);
logWithTime("END");

logWithTime("START -- using forEachAsync(...) function");
await forEachAsync([1, 2, 3, 5, 8], getForEachData);
logWithTime("END");

Обидві реалізації дають однаковий результат, тому розглянемо лише один запуск.

17:26:16.476 START -- using .forEachAsync(...) method
17:26:16.480 Calling - v=1 i=0 a=[1,2,3,5,8]
17:26:17.482 Success - data #1
17:26:17.482 Calling - v=2 i=1 a=[1,2,3,5,8]
17:26:19.484 Failure - error
17:26:19.484 Calling - v=3 i=2 a=[1,2,3,5,8]
17:26:22.488 Success - data #3
17:26:22.488 Calling - v=5 i=3 a=[1,2,3,5,8]
17:26:27.494 Success - data #5
17:26:27.494 Calling - v=8 i=4 a=[1,2,3,5,8]
17:26:35.503 Success - data #8
17:26:35.503 END

Нарешті! Послідовність лог-файлів саме така, як ми очікували: початковий START, далі 5 викликів та фінальний END. До того ж подібний алгоритм буде працювати як альтернатива для .reduce() — подивимося, як це відбуватиметься.

Зменшення

Щоб зменшити масив до єдиного значення за допомогою .reduce(), потрібно послідовно переглянути всі його значення. Визнаю, що звернення до віддаленого ендпоінту з метою зменшення — це малоймовірна ситуація. Втім, спробуємо уявити.

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

Array.prototype.reduceAsync = function (fn, init) {
  return this.reduce(
    (prom, val, idx, arr) =>
      prom.then((acc) => fn(acc, val, idx, arr)).catch((acc) => acc),
    Promise.resolve(init)
  );
};

export const reduceAsync = (arr, fn, init) =>
  arr.reduce(
    (prom, val, idx, arr) =>
      prom.then((acc) => fn(acc, val, idx, arr)).catch(() => acc),
    Promise.resolve(init)
  );

Якщо ви порівняєте код reduceAsync() з попереднім кодом forEachAsync(), то побачите, що:

  • Ми надаємо виконаний проміс з початковим значенням для зменшення, для reduce().
  • Ми не використовуємо .finally тому, що хочемо передати значення у наступний проміс. Якщо попередній виклик був вдалим, ми передаємо оновлений акумулятор, а якщо виклик не вдався, ми ігноруємо його та передаємо (незмінний) акумулятор.

У цьому коді можемо побачити, як ми це зробили:

import { logWithTime, getReducedData } from "./functions.mjs";
import { reduceAsync } from "./reduceAsync.mjs";

const reduceData = async (acc, v, i, a) => {
  logWithTime(`Calling - v=${v} i=${i} a=[${a}]`);
  try {
    const result = await getReducedData(acc, v, 1000 * v, v === 2);
    logWithTime(`Success - ${result}`);
    return result;
  } catch (e) {
    logWithTime(`Failure - error`);
    return acc;
  }
};

logWithTime("START -- using .reduceAsync(...) method");
const result1 = await [1, 2, 3, 5, 8].reduceAsync(reduceData, 0);
logWithTime(`END -- ${result1}`);

console.log();

logWithTime("START -- using reduceAsync(...) function");
const result2 = await reduceAsync([1, 2, 3, 5, 8], reduceData, 0);
logWithTime(`END -- ${result2}`);

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

17:37:35.646 START -- using .reduceAsync(...) method
17:37:35.650 Calling - v=1 i=0 a=[1,2,3,5,8]
17:37:36.652 Success - 1
17:37:36.653 Calling - v=2 i=1 a=[1,2,3,5,8]
17:37:38.655 Failure - error
17:37:38.655 Calling - v=3 i=2 a=[1,2,3,5,8]
17:37:41.658 Success - 4
17:37:41.658 Calling - v=5 i=3 a=[1,2,3,5,8]
17:37:46.663 Success - 9
17:37:46.663 Calling - v=8 i=4 a=[1,2,3,5,8]
17:37:54.671 Success - 17
17:37:54.671 END -- 17

Тут були додані всі значення, крім 2, воно проігнороване через підроблену помилку. Наш кінцевий результат — 17, все вдалося!

А що стосовно .reduceRight() у поєднанні з асинхронними викликами? У reduceAsync() просто змініть .reduce() на .reduceRight() та отримаєте reduceRightAsync()!

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 6.2K
Приєднався: 7 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація