Кто есть кто: обратные вызовы, промисы и асинхронные функции

Когда-то мне было трудно разобраться в таких понятиях, как обратные вызовы, промисы и асинхронные функции. Что это вообще такое? В каких случаях используются? Как работают? Каков их внутренний механизм? В данной статье мы раз и навсегда проясним суть этих процессов. 

Обратные вызовы 

Со 100% уверенностью можно сказать, что обратный вызов  —  это функция, которая передается в другую функцию в качестве аргумента. В дальнейшем она может быть вызвана в любое время той функцией, в которую мы ее передали. Функция, принимающая другую функцию в качестве аргумента, называется функцией высшего порядка. 

Пример обратного вызова:

function someFunctionAcceptingCallback(number, callback){
return callback(number + 10)
}
function divide(answer) {
return answer / 2
}
someFunctionAcceptingCallback(5, divide) // 7.5 при условии console.log

addEventListener в Javascript  —  еще один отличный пример: 

document.getElementById('addUser').addEventListener('click', function() {// Выполняем какое-либо действие})

Рассмотрим, что здесь происходит. addEventListener позволяет отложить действие до определенного момента. В данном примере выполнение функции callback происходит только после нажатия кнопки addUser. Это асинхронное событие.

Разберем принцип действия. Поскольку JavaScript является однопоточным языком, то он не может одновременно выполнять несколько программ. У JavaScript есть стек вызовов, который за раз справляется с 1 задачей сверху вниз. 

При добавлении addEventListener мы на самом деле вызываем не нативный метод JavaScript, а метод в WEB API. И WEB API можно представить как еще один поток. 

Таким образом, WEB API addEventListener  —  это событие DOM, которое ожидает нажатия на кнопку. Когда это происходит, данный метод принимает функцию callback, переданную в качестве аргумента, и отправляет ее в очередь задач/обратных вызовов (task queue/callback queue). Далее так называемый цикл событий (event loop) берет функцию callback и направляет ее в стек вызовов (call stack), где она выполняется при условии, что стоит первой в очереди задач. Рассмотрим описанный принцип действия: 

Какой пример лучше всего отражает эффективность функции обратного вызова? Конечно же, сетевые запросы. 

Допустим, нужно сделать сетевой запрос, получить ответ от сервера и вызывать функцию callback

Однако, будучи однопоточным, JavaScript не располагал нативным способом решить эту задачу до тех пор, пока в ES6 не появились промисы. 

Если бы мы вызвали сетевой запрос без WEB API, код был бы заблокирован, и стек вызовов ничего не смог бы сделать до завершения запроса. Это называется блокировкой кода. Она происходит при наличии синхронного времязатратного кода. В этом случае веб-страница виснет, и код JavaScript стопорится. Объясняется это тем, что мы вызываем сетевой запрос непосредственно в стеке вызовов, тем самым блокируя работу остального кода. 

Решением проблемы стало применение еще одного WEB API под названием XMLHttpRequest.

Мы передаем XMLHttpRequest в функцию callback. По мере своего выполнения он направляет ее в очередь задач, после чего цикл событий подхватывает функцию и отправляет в стек вызовов. 

Пример:

function reqListener () {
alert(this.responseText)
}

var oReq = new XMLHttpRequest();
oReq.addEventListener("load", reqListener);
oReq.open("GET", "https://jsonplaceholder.typicode.com/todos/1");
oReq.send();

Все отлично. С помощью WEB API мы делаем сетевые запросы асинхронными и передаем функции callback, которые должны выполняться после получении ответа от сетевого запроса.

Ад обратных вызовов 

Если же нам потребуются сетевые запросы по порядку, сначала первый, потом второй и т.д., то это обернется усложнением кода. Рассмотрим пример: 

const user = new XMLHttpRequest();
user.open("GET", "/user");
user.send();
user.onload = () => {

const getPosts = new XMLHttpRequest();
getPosts.open("GET", `/posts${user.response.id}`);
getPosts.send();
getPosts.onload = () => {

const getMessages = new XMLHttpRequest();
getMessages.open("GET", `/messages${user.response.id}`);
getMessages.send();
getMessages.onload = () => {
// Останавливаем загрузку и выводим данные пользователя на экран
}
}

Довольно сложно прочитать данный код из-за привычки последовательно мыслить. Он чреват ошибками и может стать еще более запутанным. Мы должны проверять каждый запрос на наличие ошибок. В результате окончательный код будет намного длиннее, менее читаемым, а следовательно, более сложным в обслуживании и подверженным ошибкам. 

В чем же состоит решение проблемы? Есть несколько отличных способов и множество библиотек, позволяющих избежать ада обратных вызовов и тем самым улучшить код 

Переходим к рассмотрению промисов: что они делают и как работают. 

Промисы

Что такое промис? 

До появления промисов в нативном JavaScript для асинхронного программирования применялись другие методы и библиотеки, поскольку не было никакой возможности использовать для этой цели нативный JavaScript. 

Промисы стали большим событием для разработчиков JavaScript, поскольку они облегчали написание асинхронных сетевых запросов и позволяли избежать ада обратных вызовов. 

Ранее упоминалось, что XMLHttpRequest является WEB API. Учитывая однопоточный характер JavaScript, во избежание блокировки кода мы должны передать асинхронные функции во что-то иное, чем стек вызовов. 

Однако промисы не используют очередь обратных вызовов. Тогда что же они задействуют для асинхронного кода? 

Очередь микрозадач 

Данная функциональность была представлена в ES6. Она аналогична очереди обратных вызовов, но применительно к промисам и с некоторыми отличиями. Очередь микрозадач micro queue имеет приоритет перед callback queue. Приведем пример кода: 

setTimeout(() => {
console.log("Using callback queue")

}, 0)

new Promise(resolve => resolve(console.log("Using micro queue")))

Как видно, setTimeout запускает обратный вызов сразу через 0 секунд, и промис также настроен сделать это немедленно. Но несмотря на это, промис сначала запустит свой обратный вызов. Дело в том, что он задействует очередь микрозадач, имеющую приоритет над очередью обратных вызовов, которую использует метод WEB API setTimout

Углубляясь в тему промисов, отметим, что они имеют 3 состояния: pending (ожидает выполнения), fulfilled (выполнено), rejected (отклонено). 

pending указывает на то, что выполнение или отклонение промиса еще не произошло. В контексте запроса API это означает следующее: мы делаем запрос, сервер не присылает никакого ответа, вследствие чего промис пребывает в состоянии ожидания. 

fulfilled  —  показатель того, что получен успешный ответ на запрос и промис выполнен. 

rejected означает, что промис отклонен. Это происходит, когда мы получаем ответ 404 и перехватываем ошибку.  

Например, мы можем создать промис с 3-х секундным ожиданием и по истечении этого времени успешно его выполнить. Это означает возможность осуществления одних задач после завершения других в нативном JavaScript без библиотек для целей асинхронного кода. Это касается всех случаев, в которых нам неизвестно время получения ответа. 

После промиса мы можем задействовать .then(). Отметим, что все действия, прописанные в данном методе, осуществляются только после результативного завершения промиса. Для примера создадим промис, который выполняется через 3 секунды и передает строку в resolve:

const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("I'm resolved")
}, 3000)
})
myPromise.then(res => console.log(res)) // I'm resolved

Мы также можем обернуть fetch в промис: 

const myPromise = new Promise((resolve, reject) => {
fetch('url').then(res => res.json().then((json) => resolve(json)))
})
myPromise.then(res => console.log(res)) // данные json

Fetch тоже основан на промисах. При вызове .then() для fetch мы просто ждем, пока сервер не выполнит промис/вернет ответ. 

Цепочки промисов 

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

const myPromise = new Promise((resolve, reject) => {
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(res => res.json().then((json) => resolve(json)))
})
myPromise.then((res) => doSomething(res)).then(() => console.log("Okey now i'm done!"))

В этом примере мы получаем todo, преобразуем ответ в JSON и приводим в исполнение. Затем применяем .then() для myPromise, который становится итоговым значением промиса. После этого вызываем функцию doSomething() с данными JSON и выводим сообщение о завершении работы.  

Promise.all

У промисов есть метод Promise.all, который ожидает выполнения заданного числа промисов, после чего запускает блок кода. Приведем пример ситуации, где он может пригодиться. Допустим, необходимо вызвать массив запросов и дождаться их полного завершения. Для этого потребуется несколько строк кода: 

const myPromises = urls.map((url) => fetch(url).then((res) => res.json()));
Promise.all(myPromises).then((data) => {
// В момент завершения всех запросов
});

Async/await

А можно ли написать код, который выглядит последовательным, а на самом деле основан на промисах? В случае с промисами мы видели, что после их завершения необходимо вызывать .then(), что не совсем отвечает представлениям о последовательном процессе.  

А вот конструкция async/await как раз то, что нужно. 

async/await  —  это новый способ создания асинхронного кода, разработанный для упрощенного написания цепочек промисов. 

Несмотря на свой асинхронный характер, данная конструкция не является блокирующей. Она возвращает результативный промис.  

Для создания цепочки промисов сделаем следующее:

fetchData().then((data) => {
fetchUserData(data).then((userData) => {
updateUserState(userData)
})
})

Теперь рассмотрим более оптимальный способ написания асинхронного кода, который выглядит как синхронный: 

const data = fetchData()
const userData = fetchUserData(data)
updateUserState(userData)

Именно эта идея лежит в основе async/await.

И наконец, самый точный вариант написания async/await:

const asyncFunc = async () => {
const data = await fetchData()
const userData = await fetchUserData(data)
updateUserState(userData)
return
}

Вместо fetchData().then(resolvedData)... делаем const data = await fetchData(), и data сохраняет полученные в результате данные. Следующая строка кода не будет выполняться до тех пор, пока fetchData() не завершит свой промис. 

Следует помнить, что по сути вся асинхронная функция и есть промис. И мы делаем то же самое, что и при выполнении промиса для async-функции. Поэтому мы применяем asyncFunction.then() для работы кода, после того как функция завершает свой промис. А это в свою очередь происходит после выполнения всех await и возврата из функции. 

asyncFunc() сначала делает запрос fetchData() и ожидает выполнения промиса сервером. Как только это происходит, функция вызывает запрос fetchUserData() и снова ожидает выполнения промиса. Только после этого она обновляет userState.

Если бы потребовалось вызвать данную функцию и подождать, пока она реализует все эти действия, мы бы выполнили то же самое, что и для промиса, а именно asyncFunc().then(), потому что асинхронная функция и есть промис. Когда мы возвращаем что-либо из функции, мы по сути выполняем промис. 

Заключение

Конечно же, статья не охватывает весь материал по данной теме. Мы не затронули такие темы, как перехват ошибок, оптимизационные методы и т.д. Однако надеюсь, что вы получили общее представление о том, чем отличаются обратные вызовы, промисы и асинхронные функции, как и в каких случаях они работают. 

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Anton Franzen: Callbacks vs. Promises vs. Async Await: A Step by Step Guide

Предыдущая статьяКак импортировать наборы данных Kaggle в Google Colab?
Следующая статьяПодробный обзор JSON, JSON5 и циклических зависимостей