Функції вищого порядку — як от 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()
!
Ще немає коментарів