JavaScript. Асинхронный код — callback, promise и async/await
27.05.2022
Теги: Backend • Frontend • JavaScript • Web-разработка • АсинхронныйКод • Теория
По умолчанию код в JavaScript выполняется последовательно — в одном потоке, синхронно. То есть таким образом, когда каждая следующая операция ждёт завершения предыдущей. Но часто встречаются задачи, для выполнения которых требуется значительное время. Если их реализовать с помощью синхронного кода, то это может привести к тому, что страницы будут подвисать — с ними нельзя будет взаимодействовать некоторое время.
Чтобы этого избежать необходимо использовать асинхронный код. Он, в отличие от синхронного, выполняется в фоновом режиме и не блокирует основной поток. То есть код, расположенный после него в основном потоке, выполняется сразу же, не дожидаясь его завершения. Асинхронный код в JavaScript может быть написан разными способами — с помощью callback
(колбэк), promise
(промис) и ключевых слов async/await
.
1. Callback (колбэк)
1.1. Как это работает
Рассмотрим функцию, которая загружает на страницу новый скрипт. Когда в тело документа добавится конструкция <script src="…">
, браузер загрузит скрипт и выполнит его.
function loadScript(src) { let script = document.createElement('script'); script.src = src; document.head.append(script); }
Вот пример использования этой функции:
// загрузит и выполнит скрипт loadScript('/some/path/script.js');
Такие функции называют «асинхронными», потому что действие (загрузка скрипта) будет завершено не сейчас, а потом. Если после вызова loadScript(…)
есть какой-то код, то он не будет ждать, пока скрипт загрузится.
Мы хотели бы использовать новый скрипт, как только он будет загружен. Скажем, он объявляет новую функцию, которую мы хотим выполнить. Но если мы просто вызовем эту функцию после loadScript(…)
, у нас ничего не выйдет.
loadScript('/some/path/script.js'); // содержит function doSomething() {…} doSomething(); // такой функции не существует, script.js еще не выполнился
Действительно, ведь у браузера не было времени загрузить скрипт. Сейчас функция loadScript
никак не позволяет отследить момент загрузки. Скрипт загружается, а потом выполняется. Но нам нужно точно знать, когда это произойдёт, чтобы использовать функции и переменные из этого скрипта.
Давайте передадим функцию callback
вторым аргументом в loadScript
, чтобы вызвать её, когда скрипт загрузится:
function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; script.onload = () => callback(script); document.head.append(script); }
Теперь, если мы хотим вызвать функцию из скрипта, нужно делать это в колбэке:
loadScript('/some/path/script.js', function() { // эта функция вызовется после того, как загрузится скрипт doSomething(); .......... });
Такое написание называют асинхронным программированием с использованием колбэков. В функции, которые выполняют какие-либо асинхронные операции, передаётся аргумент callback
— функция, которая будет вызвана по завершению асинхронного действия.
1.2. Адская пирамида колбэков
Как нам загрузить несколько скриптов один за другим — сначала первый, потом второй, потом третий? Первое, что приходит в голову — вызывать loadScript
ещё и ещё внутри колбэка.
loadScript('/some/path/script1.js', function(script) { console.log(`Скрипт ${script.src} загружен`); loadScript('/some/path/script2.js', function(script) { console.log(`Скрипт ${script.src} загружен`); loadScript('/some/path/script3.js', function(script) { console.log(`Скрипт ${script.src} загружен`); // ...и так далее, пока не загрузим все скрипты }); }) });
Чем больше вложенных вызовов, тем наш код будет иметь всё большую вложенность, которую сложно поддерживать. Иногда это называют «адом колбэков» или «адской пирамидой колбэков». Пирамида вложенных вызовов растёт вправо с каждым асинхронным действием. Такой подход к написанию кода не приветствуется.
1.3. Перехват ошибок
В примерах выше мы не думали об ошибках. А что если загрузить скрипт не удалось? Колбэк должен уметь реагировать на возможные проблемы.
function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; script.onload = () => callback(null, script); script.onerror = () => callback(new Error(`Ошибка загрузки скрипта ${src}`)); document.head.append(script); }
Мы вызываем callback(null, script)
в случае успешной загрузки и callback(error)
, если загрузить скрипт не удалось.
loadScript('/some/path/script.js', function(error, script) { if (error) { // обрабатываем ошибку } else { // скрипт успешно загружен } });
Подход, который мы использовали в loadScript
, называется «колбэк с первым аргументом-ошибкой» (error-first callback). Одна и та же функция callback
используется и для информирования об ошибке, и для передачи результатов.
- Первый аргумент функции
callback
зарезервирован для ошибки. В этом случае вызов выглядит вот так:callback(error)
. - Второй и последующие аргументы — для результатов выполнения. В этом случае вызов будет:
callback(null, result1, result2…)
.
2. Promise (промис)
2.1. Объект промис
Promise (промис) — это специальный объект c набором методов для удобного написания асинхронного кода. Способ использования, в общих чертах, такой:
- Код, которому надо сделать что-то асинхронно, создаёт объект
promise
и возвращает его. - Внешний код, получив
promise
, навешивает на него обработчикиthen
,catch
,finally
. - По завершении процесса асинхронный код переводит
promise
в состояниеfulfilled
(с результатом) илиrejected
(с ошибкой). При этом автоматически вызываются соответствующие обработчики во внешнем коде.
let promise = new Promise(function(resolve, reject) { // функция-исполнитель (executor) });
Функция, переданная в конструкцию new Promise
, называется исполнитель (executor). Когда промис создаётся, функция запускается автоматически. Её аргументы resolve
и reject
— это колбэки, которые предоставляет сам JavaScript. Наш код внутри исполнителя должен вызвать один из этих колбэков:
resolve(value)
— если работа завершилась успешно, с результатомvalue
reject(error)
— если произошла ошибка, гдеerror
— объект ошибки
У объекта promise
, возвращаемого конструктором new Promise
, есть внутренние свойства (у нас нет к ним прямого доступа):
state
— вначалеpending
(ожидание), потомfulfilled
при вызовеresolve
илиrejected
при вызовеreject
.result
— вначалеundefined
, далееvalue
при вызовеresolve(value)
илиerror
при вызовеreject(error)
.
resolve
или reject
. Состояние промиса может быть изменено только один раз — все последующие вызовы resolve
и reject
будут проигнорированы.
Промис — и успешный, и отклонённый — будем называть «завершённым», в отличие от изначального промиса «в ожидании».
2.2. Обработчики
Универсальный метод для навешивания обработчиков:
promise.then( function(result) { /* обработает результат */ }, function(error) { /* обработает ошибку */ } );
Первый аргумент метода then
— функция, которая выполняется, когда промис переходит в состояние fulfilled
(выполнен успешно), и получает на вход результат. Второй аргумент метода then
— функция, которая выполняется, когда промис переходит в состояние rejected
(выполнен с ошибкой), и получает на вход ошибку.
let promise = new Promise(function(resolve, reject) { setTimeout(() => resolve('success'), 1000); }); // resolve запустит первую функцию, переданную в then() promise.then( result => alert(result), // выводит «success» через секунду error => alert(error) // вообще не будет запущена );
let promise = new Promise(function(resolve, reject) { setTimeout(() => reject(new Error('failure')), 1000); }); // reject запустит вторую функцию, переданную в then() promise.then( result => alert(result), // вообще не будет запущена error => alert(error.message) // выводит «failure» через секунду );
Если мы хотели бы только обработать ошибку, то можно использовать null
в качестве первого аргумента then(null, errorHandler)
. Или можно воспользоваться методом catch(errorHandler)
, который сделает то же самое.
let promise = new Promise((resolve, reject) => { setTimeout(() => reject(new Error('failure')), 1000); }); // .catch(f) это то же самое, что promise.then(null, f) promise.catch(error => alert(error.message)); // выводит «failure» спустя секунду
Вызов finally(f)
похож на then(f, f)
, в том смысле, что функция f
выполнится в любом случае, когда промис завершится — успешно или с ошибкой. Этот хорошо подходит для очистки, например остановки индикатора загрузки, его ведь нужно остановить вне зависимости от результата.
let promise = new Promise((resolve, reject) => { /* сделать что-то, что займёт время, и после вызвать resolve/reject */ }); promise // выполнится, когда промис завершится, независимо от того, успешно или нет .finally(() => { /* остановить индикатор загрузки */ }) .then( result => { /* показать результат */ }, error => { /* показать ошибку */ } );
Обработчик, вызываемый из метода finally
, не имеет аргументов. В finally
мы не знаем, как был завершён промис. И это нормально, потому что обычно наша задача — выполнить «общие» завершающие процедуры. Обработчик finally
«пропускает» результат или ошибку дальше, к последующим обработчикам — то есть then
и/или catch
.
Если промис в состоянии ожидания, обработчики в then/catch/finally
будут ждать его завершения. Однако, если промис уже завершён, то обработчики выполнятся сразу.
// при создании промиса он сразу переводится в состояние «завершён успешно» let promise = new Promise(resolve => resolve('success')); promise.then(result => alert(result)); // сразу выводит «success»
2.3. Пример: loadScript
У нас есть функция loadScript
для загрузки скрипта с использованием колбэка:
function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; script.onload = () => callback(null, script); script.onerror = () => callback(new Error(`Ошибка загрузки скрипта ${src}`)); document.head.append(script); }
Новой версии функции loadScript
с использованием промиса не нужен аргумент callback
. Вместо этого она будет создавать и возвращать объект Promise
, который перейдет в состояние «завершён успешно», когда загрузка закончится.
function loadScript(src) { return new Promise(function(resolve, reject) { let script = document.createElement('script'); script.src = src; script.onload = () => resolve(script); script.onerror = () => reject(new Error(`Ошибка загрузки скрипта ${src}`)); document.head.append(script); }); }
let promise = loadScript('/some/path/script.js'); promise.then( script => alert(`Скрипт ${script.src} успешно загружен`), error => alert(error.message) ); promise.then(script => { // функция из файла /some/path/script.js doSomething(); });
2.4. Цепочка промисов
Построение цепочки промисов возможно, потому что вызов promise.then()
тоже возвращает промис, так что мы можем вызвать на нём следующий then()
. Чтобы это работало, функция-обработчик, которая передается в then()
, должна возвращать значение. Это значение становится результатом успешно завершенного промиса, который возвращает then()
. И это значение передается на вход следующему then()
в цепочке — функция-обработчик которого тоже должна вернуть значение.
// а вот это уже цепочка let promise = new Promise(function(resolve, reject) { setTimeout(() => resolve(1), 1000); }); promise .then(function(result) { alert(result); // 1 return result * 2; }) .then(function(result) { alert(result); // 2 return result * 2; }) .then(function(result) { alert(result); // 4 return result * 2; });
Давайте вернёмся к ситуации, когда нам надо последовательно загрузить несколько скриптов:
loadScript('/some/path/script1.js', function(script) { console.log(`Скрипт ${script.src} загружен`); loadScript('/some/path/script2.js', function(script) { console.log(`Скрипт ${script.src} загружен`); loadScript('/some/path/script3.js', function(script) { console.log(`Скрипт ${script.src} загружен`); // ...и так далее, пока не загрузим все скрипты }); }) });
И решим эту задачу с использованием новой реализации loadScript
, которая возвращает промис:
loadScript('/some/path/script1.js') .then(function(script) { return loadScript('/some/path/script2.js'); }) .then(function(script) { return loadScript('/some/path/script3.js'); }) .then(function(script) { // вызовем функции, объявленные в загружаемых скриптах, // чтобы показать, что они действительно загрузились doSomething1(); doSomething2(); doSomething3(); });
Функция-обработчик, передаваемая в then()
, может возвращать не только значение, но и промис. Каждый вызов loadScript
возвращает промис, и следующий обработчик в then()
срабатывает, только когда этот промис завершается. Затем инициируется загрузка следующего скрипта и так далее. Таким образом, скрипты загружаются один за другим.
Классическая ошибка — технически возможно добавить много обработчиков then()
к единственному промису. Но это не будет цепочкой.
// это не будет цепочкой let promise = new Promise(function(resolve, reject) { setTimeout(() => resolve(1), 1000); }); promise.then(function(result) { alert(result); // 1 return result * 2; }); promise.then(function(result) { alert(result); // 1 return result * 2; }); promise.then(function(result) { alert(result); // 1 return result * 2; });
Здесь мы добавили несколько обработчиков к одному промису. Они не передают друг другу результаты своего выполнения, а действуют независимо. Все обработчики then()
на одном и том же промисе получают одно и то же значение — результат выполнения того же самого промиса. Таким образом, все alert
показывают одно и то же — единицу.
2.5. Обработка ошибок
Цепочки промисов отлично подходят для перехвата ошибок. Если промис завершается с ошибкой, то управление переходит в ближайший обработчик ошибок.
fetch('https://no-such-url') // ошибка, такого url нет .then(response => response.json()) .catch(err => alert(err))
Как видно, catch
не обязательно должен быть сразу после строки с ошибкой, он может быть далее, после одного или даже нескольких then
.
fetch('/some/path/user.json') .then(response => response.json()) .then(siteUser => fetch(`https://api.github.com/users/${siteUser.name}`)) .then(response => response.json()) .then(githubUser => { let img = document.createElement('img'); img.src = githubUser.avatar_url; img.className = 'avatar'; document.body.append(img); })) .catch(error => alert(error.message));
Если все в порядке, то такой catch()
вообще не выполнится. Но если любой из промисов будет отклонён (проблемы с сетью или некорректная json-строка, или что угодно другое), то ошибка будет перехвачена.
2.6. Неявный try…catch
Вокруг функции промиса и обработчиков находится «невидимый» try…catch
. Если происходит исключение, то оно перехватывается, и промис считается отклонённым с этой ошибкой. Два фрагмента кода ниже работают одинаково.
new Promise((resolve, reject) => { throw new Error('failure'); }).catch(error => alert(error.message)); // failure
new Promise((resolve, reject) => { reject(new Error('failure')); }).catch(error => alert(error.message)); // failure
Это работает не только в функции промиса, но и в обработчиках. Если мы бросим ошибку из обработчика then()
, то промис будет считаться отклонённым, и управление перейдёт к ближайшему обработчику ошибок.
new Promise((resolve, reject) => { resolve('success'); }).then((result) => { throw new Error('failure'); }).catch(error => alert(error.message)); // failure
Это происходит для всех ошибок, не только для тех, которые вызваны оператором throw
:
new Promise((resolve, reject) => { resolve('success'); }).then((result) => { blabla(); // нет такой функции }).catch(error => alert(error.message)); // ReferenceError: blabla is not defined
2.7. Пробрасывание ошибок
Как мы уже знаем, catch()
ведёт себя как try…catch
. Мы можем иметь столько обработчиков then
, сколько мы хотим, и затем использовать один catch()
в конце, чтобы перехватить ошибки из всех обработчиков.
В обычном try…catch
мы можем проанализировать ошибку и повторно пробросить дальше, если не можем её обработать. То же самое возможно для промисов:
- Если мы пробросим (
throw
) ошибку внутри блокаcatch()
, то управление перейдёт к следующему ближайшему обработчику ошибок. - Если мы обработаем ошибку и завершим работу обработчика нормально, то продолжит работу ближайший успешный обработчик
then()
.
3. Async/await
Существует специальный синтаксис для работы с промисами, который называется «async/await».
3.1. Ключевое слово async
У слова async
один простой смысл — эта функция всегда возвращает промис. Значения других типов оборачиваются в завершившийся успешно промис автоматически.
async function f() { return 1; } f().then(result => alert(result)); // 1
Можно и явно вернуть промис, результат будет одинаковым:
async function f() { return new Promise((resolve, reject) => resolve(1)) } f().then(alert) // 1
3.2. Ключевое слово await
Ключевое слово await
работает только внутри async-функции и заставляет интерпретатор JavaScript ждать до тех пор, пока промис справа от await
не выполнится. После чего оно вернёт его результат, и выполнение кода продолжится.
async function f() { let promise = new Promise((resolve, reject) => { setTimeout(() => resolve('success'), 2000) }); // будет ждать 2 секцнды, пока промис не выполнится let result = await promise; alert(result); } f();
Обратите внимание, хотя await
и заставляет JavaScript дожидаться выполнения промиса, это не отнимает ресурсов процессора. Пока промис не выполнится, js-движок может заниматься другими задачами — выполнять прочие скрипты, обрабатывать события и т.п. По сути, это просто «синтаксический сахар» для получения результата промиса, более наглядный, чем promise.then
.
3.3. Обработка ошибок
Когда промис завершается успешно, await promise
возвращает результат. Когда завершается с ошибкой — будет выброшено исключение. Как если бы на этом месте находилось выражение throw
. Два фрагмента кода делают почти одно и тоже.
async function f() { await new Promise((resolve, reject) => reject(new Error('failure'))); }
async function f() { throw new Error('failure'); }
Но есть отличие — на практике промис может завершиться с ошибкой не сразу, а через некоторое время. В этом случае будет задержка, а затем await
выбросит исключение. Такие ошибки можно ловить, используя try…catch
, как с обычным throw
.
async function f() { try { let response = await fetch('https://no-such-url'); } catch(err) { alert(err.message); // TypeError: failed to fetch } } f();
В случае ошибки выполнение try
прерывается и управление прыгает в начало блока catch
. Блоком try
можно обернуть несколько строк.
async function f() { try { let response = await fetch('/no-user-here'); let user = await response.json(); } catch(err) { // перехватит любую ошибку в блоке try: и в fetch, и в response.json alert(err); } } f();
Если у нас нет try…catch
, асинхронная функция будет возвращать завершившийся с ошибкой промис (в состоянии rejected
). В этом случае мы можем использовать метод catch()
промиса, чтобы обработать ошибку.
async function f() { let response = await fetch('https://no-such-url'); } // f() вернёт промис в состоянии rejected f().catch(alert); // TypeError: failed to fetch
Если забыть добавить catch()
, то будет сгенерирована ошибка «Uncaught promise error» и информация об этом будет выведена в консоль.
Поиск: Backend • Frontend • JavaScript • Web-разработка • Теория • Асинхронный код • callback • promise • async • await