Якщо ви тільки починаєте програмувати, швидше за все, ви думаєте про програми як про набір послідовних блоків логіки, де кожен блок виконує певну дію та передає свій результат, щоб наступний блок міг виконуватися тощо, і для здебільшого ви маєте рацію, більшість програм виконуються послідовно, ця модель дозволяє нам створювати програми, які легко писати та підтримувати. Однак існують конкретні випадки використання, коли ця послідовна модель не працюватиме або не буде оптимальною. Як приклад розглянемо додаток для читання книг. Ця програма має кілька розширених функцій, таких як пошук усіх входжень слова, навігація між закладками тощо. А тепер уявіть, що користувач зараз читає довгу книгу та вирішує знайти всі випадки загального слова, наприклад «The». Зазвичай програмі знадобиться кілька секунд, щоб знайти та проіндексувати всі випадки цього слова. У послідовній програмі користувач не може взаємодіяти з додатком (змінювати сторінку або виділяти текст), доки не буде виконана операція пошуку. Сподіваюся, ви бачите, що це не оптимальний досвід користувача!
Діаграма ілюструє типовий потік виконання програми для читання книг. Якщо користувач ініціює тривалу операцію (у цьому випадку пошук усіх входжень «the» у великій книзі), програма «зависає» на весь час цієї операції. У цьому випадку користувач продовжуватиме натискати кнопку наступної закладки без результату, доки операція пошуку не буде завершена, і всі операції почнуть діяти одразу, створюючи відчуття, що кінцевий користувач відстає у програмі.
Ви могли помітити, що цей приклад насправді не відповідає послідовній моделі, яку ми представили раніше. Це тому, що операції тут незалежні одна від одної. Користувачеві не потрібно знати про кількість входжень «the», щоб перейти до наступної закладки, тому порядок виконання операцій не дуже важливий. Нам не потрібно чекати закінчення операції пошуку, перш ніж ми зможемо перейти до наступної закладки. Можливе вдосконалення попереднього потоку виконання базується на цій логіці: ми можемо запустити довгу операцію пошуку у фоновому режимі, продовжити будь-які вхідні операції, а коли довгу операцію буде виконано, ми можемо просто повідомити користувача. Потік виконання виглядає наступним чином:
За допомогою цього потоку виконання користувацький досвід значно покращується. Тепер користувач може ініціювати тривалу операцію, продовжувати використовувати програму в звичайному режимі та отримувати сповіщення, коли операцію буде виконано. Це основа асинхронного програмування.
Javascript, серед інших мов, підтримує цей стиль асинхронного програмування, надаючи розширені API для досягнення будь-якої асинхронної поведінки, яку тільки можете придумати. Зрештою, Javascript за своєю суттю має бути асинхронною мовою. Якщо ми звернемося до попереднього прикладу, то асинхронна логіка лежить в основі всіх програм для взаємодії з користувачем, а Javascript в основному створено для використання в браузері, де більшість програм реагують на дії користувача.
Нижче наведено короткий посібник з асинхронного Javascript:
Зворотні виклики
У типовій програмі ви зазвичай знайдете кілька функцій. Щоб використовувати функцію, ми викликаємо її з набором параметрів. Код функції виконається та поверне результат, нічого незвичайного. Асинхронне програмування трохи змінює цю логіку. Повертаючись до прикладу програми для читання книг, ми не можемо використовувати звичайну функцію для реалізації логіки операції пошуку, оскільки операція займає невідому кількість часу. Звичайна функція в основному повертається до того, як операція буде виконана, і це не та поведінка, яку ми очікуємо. Рішення полягає в тому, щоб вказати іншу функцію, яка буде виконана після завершення операції пошуку. Це моделює наш варіант використання, оскільки наша програма може продовжувати свій потік у звичайному режимі, і після завершення операції пошуку зазначена функція буде виконана, щоб повідомити користувача про результати пошуку. Цю функцію ми називаємо функцією зворотного виклику:
// Search occurrences function
function searchOccurrences(word, callback) {
try {
// search operation logic, result is in result variable
//....
callback(null, word, result);
} catch (err) {
callback(err);
}
}
// Search occurrences callback function
function handleSearchOccurrencesResult(err, word, result) {
if (err) {
console.log(`Search operation for ${word} ended with an error`);
} else console.log(`Search results for ${word}: ${result}`);
return;
}
searchOccurrences("the", handleSearchOccurrencesResult);
Спочатку ми визначаємо функцію операції пошуку, searchOccurrences. Він приймає слово для пошуку та другий параметр «зворотний виклик», який буде функцією для виконання після завершення операції пошуку. Функція пошукової операції навмисно залишалася абстрактною, нам потрібно лише зосередитися на двох її можливих результатах: у першому випадку все пройшло успішно, і ми маємо результат пошуку в змінній результату. У цьому випадку нам просто потрібно викликати функцію зворотнього виклику з такими параметрами: перший параметр має значення null, що означає, що помилки не сталося, другий параметр — це слово, яке шукали, а третій і, мабуть, найважливіший параметр із трьох, є результатом пошукової операції.
У другому випадку виникає помилка, це також випадок, коли виконується пошукова операція, і ми повинні викликати функцію зворотного виклику. Ми використовуємо блок try and catch, щоб перехопити будь-яку помилку, і ми просто викликаємо функцію зворотного виклику з об’єктом помилки з блоку catch.
Потім ми визначили функцію зворотного виклику, handleSearchOccurrences, ми зберегли її логіку досить простою. Це лише питання друку повідомлення на консоль. Спочатку ми перевіряємо параметр «err», щоб побачити, чи сталася якась помилка в основній функції. У цьому випадку ми просто повідомляємо користувачеві, що пошукова операція завершилася з помилкою. Якщо помилок не було, друкуємо повідомлення з результатом пошукової операції.
Нарешті, ми викликаємо функцію searchOccurrences словом «the». Функція тепер працюватиме нормально, не блокуючи основну програму, і після завершення пошуку буде виконано зворотний виклик, і ми отримаємо повідомлення результату або з результатом пошуку, або з повідомленням про помилку.
Тут важливо зазначити, що ми маємо доступ лише до змінної результату всередині функції main і функції зворотного виклику. Якщо ми спробуємо щось подібне:
let result;
function searchOccurrences(word, callback) {
try {
// search operation logic, result is in searchResult variable
//....
result = searchResult;
callback(null, word, result);
} catch (err) {
callback(err);
}
}
searchOccurrences("the", handleSearchOccurrencesResult);
console.log(result);
результат друку буде невизначеним, оскільки програми не чекають на виконання функції searchOccurrences. Він переходить до наступної інструкції, яка є оператором друку, перш ніж змінна результату буде призначена всередині головної функції. У результаті ми надрукуємо непризначену змінну результату.
Отже, виходячи з цієї логіки, ми повинні зберегти весь код, який використовує змінну результату, у функції зворотного виклику. Зараз це може не здаватись проблемою, але вона може швидко перерости у справжню проблему. Уявіть собі випадок, коли ми маємо ланцюжок асинхронних функцій, які повинні виконуватися послідовно. У типовій логіці зворотного виклику ви реалізуєте щось на зразок цього:
functionA(function (err, resA) {
///......
functionB(resA, function (err, resB) {
///......
functionC(resB, function (err, resC) {
///......
functionD(resC, function (err, resD) {
///......
});
});
});
});
Майте на увазі, що кожен зворотній виклик має параметр помилки, і кожну помилку потрібно обробляти окремо. Це робить і без того складний код, наведений вище, ще складнішим і складнішим у підтримці. Сподіваюся, Promises тут, щоб вирішити проблему пекла зворотного виклику, ми розглянемо це далі.
Обіцянки
Обіцянки будуються на основі зворотних викликів і працюють подібним чином. Вони були представлені як частина функцій ES6, щоб вирішити кілька явних проблем із зворотними викликами, наприклад, пекло зворотного виклику. Обіцянки надають власні функції, які запускаються після успішного завершення (вирішення) і коли виникають помилки (відхилення). Далі демонструється приклад searchOccurrences, реалізований за допомогою обіцянок:
// Search occurrences function
function searchOccurrences(word) {
return new Promise((resolve, reject) => {
try {
// search operation logic, result is in result variable
//....
resolve(word, result);
} catch (err) {
reject(err);
}
});
}
searchOccurrences("the")
.then((word, result) => {
console.log(`Search results for ${word}: ${result}`);
})
.catch((err) => {
console.log(`Search operation ended with an error`);
});
Розглянемо зміни, які ми застосували:
Функція searchOccurrences повертає обіцянку. Всередині обіцянки ми дотримуємося тієї самої логіки: у нас є дві функції resolve та reject, які представляють наші зворотні виклики, а не одна функція зворотного виклику, яка обробляє як успішне виконання, так і виконання з помилками. Обіцянки розділяють два результати та забезпечують чистий синтаксис під час виклику функції main. Функція resolve «підключена» до основної функції за допомогою ключового слова «then». Тут ми просто вказуємо два параметри функції resolve та друкуємо результат пошуку. Подібне стосується функції відхилення, її можна підключити за допомогою ключового слова catch. Сподіваємось, ви зможете оцінити переваги обіцянок щодо читабельності та чистоти коду. Якщо ви все ще обговорюєте це, подивіться, як ми можемо вирішити проблему пекла зворотного виклику, об’єднавши разом асинхронні функції для виконання одна за одною:
searchOccurrences("the")
.then(searchOccurrences("asynchronous"))
.then(searchOccurrences("javascript"))
.then(searchOccurrences("guide"))
.catch((err) => {
console.log(`Search operation ended with an error`);
});
Async/Await
Async/Await є останнім доповненням до нашої асинхронної панелі інструментів у JavaScript. Представлені в ES8, вони забезпечують новий рівень абстракції поверх асинхронних функцій, просто «очікуючи» на виконання асинхронної операції. Потік програми блокується за цією інструкцією, доки не буде повернено результат асинхронної операції, а потім програма продовжить виконання наступної інструкції. Якщо ви думаєте про синхронний потік виконання, ви маєте рацію. Ми пройшли повне коло! Async/await намагається привнести простоту синхронного програмування в асинхронний світ. Будь ласка, майте на увазі, що це сприймається лише у виконанні та коді програми. Під капотом все залишається без змін, Async/await все ще використовує проміси, а зворотні виклики є їх будівельними блоками.
Давайте розглянемо наш приклад і реалізуємо його за допомогою Async/await:
async function searchOccurrences(word) {
try {
// search operation logic, result is in result variable
//....
return result;
} catch (err) {
console.log(`Search operation for ${word} ended with an error`);
}
}
const word = "the";
const result = await searchOccurrences(word, handleSearchOccurrencesResult);
console.log(`Search results for ${word}: ${result}`);
Наш код не сильно змінився, важливо звернути увагу на ключове слово “async” перед оголошенням функції searchOccurrences. Це означає, що функція асинхронна. Також зверніть увагу на ключове слово “await” під час виклику функції searchOccurrences. Це вказує програмі чекати виконання функції, поки не буде повернено результат, перш ніж програма зможе перейти до наступної інструкції, іншими словами, змінна результату завжди буде містити значення, що повертається функцією searchOccurrences, а не обіцянку у цьому сенсі функція Async/Await не має стану очікування, як Promises. Після завершення виконання ми переходимо до оператора друку, і цього разу результат фактично містить результат операції пошуку. Як і очікувалося, новий код має таку ж поведінку, ніби він був синхронним.
Ще одна незначна річ, про яку слід пам’ятати, полягає в тому, що оскільки у нас більше немає функцій зворотного виклику, нам потрібно обробляти помилку searchOccurrences у тій самій функції, оскільки ми не можемо просто поширити помилку на функцію зворотного виклику та обробити її там. Тут ми просто друкуємо повідомлення про помилку у випадку помилки для прикладу.
Підведення підсумків
У цій статті ми розглянули різні підходи, які використовуються для реалізації асинхронної логіки в Javascript. Ми почали з вивчення конкретного прикладу того, чому нам потрібно буде перейти від звичайного синхронного стилю програмування до асинхронної моделі. Потім ми перейшли до зворотних викликів, які є основними будівельними блоками асинхронного Javascript. Обмеження зворотних викликів привели нас до різних альтернатив, які були додані протягом багатьох років, щоб подолати ці обмеження, головним чином обіцянки та Async/await. Асинхронну логіку можна знайти будь-де в Інтернеті, незалежно від того, чи ви викликаєте зовнішній API, ініціюєте запит до бази даних, записуєте в локальну файлову систему або навіть очікуєте на введення користувача у формі входу. Сподіваємось, тепер ви відчуваєте себе більш впевнено, щоб вирішувати ці проблеми, написавши чистий і підтримуваний асинхронний Javascript!
Якщо вам сподобалася ця стаття, перегляньте блог CLA, де ми обговорюємо різні теми про те, як потрапити в технологію. Також відвідайте наш youtube-канал для наших попередніх безкоштовних семінарів і слідкуйте за нами в соцмережах -labs-academy/), щоб ви не пропустили наступні!