Автор выбрал Фонд помощи по COVID-19 для получения пожертвования в рамках программы Пиши за пожертвования.
Введение
В первые дни интернета веб-сайты часто состояли из статических данных на странице HTML. Но сейчас, когда веб-приложения стали более интерактивными и динамичными, стало все более необходимо выполнять интенсивные операции, такие как выполнение внешних сетевых запросов для получения данных API. Для обработки этих операций в JavaScript разработчик должен использовать техники асинхронного программирования.
Поскольку JavaScript является однопоточным языком программирования с синхронной моделью выполнения, которая обрабатывает одну операцию за другой, он может обрабатывать только один оператор за раз. Однако действие, такое как запрос данных из API, может занять неопределенное количество времени, в зависимости от размера запрашиваемых данных, скорости сетевого соединения и других факторов. Если бы вызовы API выполнялись синхронно, браузер не смог бы обрабатывать никакого пользовательского ввода, такого как прокрутка или нажатие кнопки, пока эта операция не завершится. Это известно как блокирование.
Чтобы предотвратить блокировку, в среде браузера есть множество веб-API, к которым JavaScript может получить доступ, и они асинхронны, что означает, что они могут выполняться параллельно с другими операциями, а не последовательно. Это полезно, потому что это позволяет пользователю продолжать использовать браузер нормально, пока асинхронные операции обрабатываются.
Как разработчик JavaScript вам нужно знать, как работать с асинхронными веб-API и обрабатывать ответ или ошибку этих операций. В этой статье вы узнаете о цикле событий, о первоначальном способе работы с асинхронным поведением через обратные вызовы, о дополнении ECMAScript 2015 обещаниями и о современной практике использования async/await
.
Примечание: Эта статья сосредоточена на клиентском JavaScript в среде браузера. Те же концепции обычно верны в среде Node.js, однако Node.js использует собственные API на C++ в отличие от веб-API браузера. Для получения дополнительной информации о асинхронном программировании в Node.js, ознакомьтесь с руководством Как писать асинхронный код в Node.js.
Цикл Событий
Этот раздел объяснит, как JavaScript обрабатывает асинхронный код с помощью цикла событий. Сначала будет показана демонстрация работы цикла событий, а затем будут объяснены два элемента цикла событий: стек и очередь.
JavaScript-код, который не использует асинхронные веб-API, будет выполняться синхронно – один за другим, последовательно. Это демонстрируется этим примером кода, который вызывает три функции, каждая из которых выводит число в консоль:
В этом коде вы определяете три функции, которые выводят числа с помощью console.log()
.
Затем вызывайте функции:
Вывод будет основан на порядке вызова функций – first()
, затем second()
, а затем third()
:
Output1
2
3
Когда используется асинхронное веб-API, правила становятся более сложными. Встроенным API, с которым вы можете протестировать это, является setTimeout
, который устанавливает таймер и выполняет действие после указанного интервала времени. setTimeout
должен быть асинхронным, иначе весь браузер останется замороженным во время ожидания, что приведет к плохому пользовательскому опыту.
Добавьте setTimeout
в функцию second
, чтобы симулировать асинхронный запрос:
setTimeout
принимает два аргумента: функцию, которая будет выполняться асинхронно, и время ожидания перед вызовом этой функции. В этом коде вы обернули console.log
в анонимную функцию и передали ее в setTimeout
, затем установили функцию на выполнение после 0
миллисекунд.
Теперь вызовите функции, как вы делали ранее:
Можете ожидать, что при установке setTimeout
в 0
эти три функции по-прежнему будут выполняться последовательно. Но из-за асинхронности функция с задержкой будет напечатана последней:
Output1
3
2
Независимо от того, установлен ли таймаут на ноль секунд или на пять минут, разницы не будет — вызванный асинхронным кодом console.log
выполнится после синхронных функций верхнего уровня. Это происходит потому, что среда выполнения JavaScript, в данном случае браузер, использует концепцию цикла событий для обработки параллельных событий. Поскольку JavaScript может выполнять только одно выражение за раз, ему необходим цикл событий, чтобы быть проинформированным о том, когда выполнить то или иное выражение. Цикл событий управляет этим с помощью концепций стека и очереди.
Стек
Стек, или стек вызовов, хранит состояние того, какая функция в данный момент выполняется. Если вы не знакомы с концепцией стека, вы можете представить его как массив с свойствами “Последний вошел, первый вышел” (LIFO), что означает, что можно добавлять или удалять элементы только с конца стека. JavaScript будет выполнять текущий фрейм (или вызов функции в определенной среде) в стеке, затем удалять его и переходить к следующему.
Для примера, содержащего только синхронный код, браузер обрабатывает выполнение в следующем порядке:
- Добавить
first()
в стек, выполнитьfirst()
, который записывает1
в консоль, удалитьfirst()
из стека. - Добавить
second()
в стек, выполнитьsecond()
, который записывает2
в консоль, удалитьsecond()
из стека. - Добавить
third()
в стек, выполнитьthird()
, который записывает3
в консоль, удалитьthird()
из стека.
Второй пример с setTimout
выглядит так:
- Добавить
first()
в стек, выполнитьfirst()
, который записывает1
в консоль, удалитьfirst()
из стека. - Добавить
second()
в стек, выполнитьsecond()
.- Добавить
setTimeout()
в стек, выполнитьsetTimeout()
Web API, который запускает таймер и добавляет анонимную функцию в очередь, удалитьsetTimeout()
из стека.
- Добавить
- Удалите
second()
из стека. - Добавьте
third()
в стек, запуститеthird()
, который регистрирует3
в консоль, удалитеthird()
из стека. - Цикл событий проверяет очередь на наличие ожидающих сообщений и находит анонимную функцию от
setTimeout()
, добавляет функцию в стек, который регистрирует2
в консоль, а затем удаляет его из стека.
Использование setTimeout
, асинхронного веб-API, вводит понятие очереди, о котором будет рассказано в следующем уроке.
Очередь
Очередь, также называемая очередью сообщений или очередью задач, является областью ожидания для функций. Всякий раз, когда стек вызовов пуст, цикл событий будет проверять очередь на наличие ожидающих сообщений, начиная с самого старого сообщения. Как только он находит одно, он добавляет его в стек, который выполнит функцию в сообщении.
В примере с setTimeout
анонимная функция запускается немедленно после завершения остальной части выполнения верхнего уровня, поскольку таймер был установлен на 0
секунд. Важно помнить, что таймер не означает, что код выполнится ровно через 0
секунд или какое-либо другое указанное время, а означает, что анонимная функция будет добавлена в очередь через это количество времени. Эта система очередей существует потому, что если бы таймер добавлял анонимную функцию непосредственно в стек по окончании таймера, это прервало бы выполнение текущей функции, что могло привести к непредвиденным и непредсказуемым эффектам.
Примечание: Существует также другая очередь, называемая очередью задач или очередью микрозадач, которая обрабатывает обещания. Микрозадачи, такие как обещания, обрабатываются с более высоким приоритетом, чем макрозадачи, такие как setTimeout
.
Теперь вы знаете, как цикл событий использует стек и очередь для управления порядком выполнения кода. Следующая задача – выяснить, как контролировать порядок выполнения в вашем коде. Для этого сначала вы узнаете о первоначальном способе гарантировать правильную обработку асинхронного кода циклом событий: обратные вызовы.
Обратные вызовы
В примере с setTimeout
функция с таймаутом запускается после выполнения всего в основном контексте выполнения верхнего уровня. Но если вы хотите, чтобы одна из функций, например, функция third
, запускалась после таймаута, то вам придется использовать асинхронные методы кодирования. В данном случае таймаут может представлять собой асинхронный вызов API, содержащий данные. Вы хотите работать с данными из вызова API, но вы должны убедиться, что данные вернутся сначала.
Оригинальное решение этой проблемы заключается в использовании функций обратного вызова. Функции обратного вызова не имеют специального синтаксиса; это просто функция, которая была передана как аргумент в другую функцию. Функция, которая принимает другую функцию как аргумент, называется функцией высшего порядка. Согласно этому определению, любая функция может стать функцией обратного вызова, если она передается как аргумент. Функции обратного вызова не являются асинхронными по своей природе, но могут использоваться для асинхронных целей.
Вот синтаксический пример кода функции высшего порядка и обратного вызова:
В этом коде вы определяете функцию fn
, определяете функцию higherOrderFunction
, которая принимает функцию callback
в качестве аргумента, и передаете fn
как обратный вызов для higherOrderFunction
.
Запуск этого кода приведет к следующему:
OutputJust a function
Вернемся к функциям first
, second
и third
с использованием setTimeout
. Вот что у вас есть на данный момент:
Задача состоит в том, чтобы функция third
всегда откладывала выполнение до завершения асинхронного действия в функции second
. Здесь на помощь приходят обратные вызовы. Вместо выполнения first
, second
и third
на верхнем уровне выполнения вы передадите функцию third
в качестве аргумента в second
. Функция second
выполнит обратный вызов после завершения асинхронного действия.
Вот три функции с применением обратного вызова:
Теперь выполните first
и second
, затем передайте third
в качестве аргумента в second
:
После выполнения этого блока кода вы получите следующий вывод:
Output1
2
3
Сначала будет выведено 1
, а после завершения таймера (в данном случае нулевое количество секунд, но вы можете изменить его на любое другое) будут выведены 2
и затем 3
. Передавая функцию в качестве обратного вызова, вы успешно задерживаете выполнение функции до завершения асинхронного веб-API (setTimeout
).
Главное здесь заключается в том, что обратные вызовы не являются асинхронными — setTimeout
является асинхронным веб-API, ответственным за обработку асинхронных задач. Обратный вызов просто позволяет вам быть проинформированным о том, когда асинхронная задача завершится, и обрабатывает успех или неудачу задачи.
Теперь, когда вы узнали, как использовать обратные вызовы для обработки асинхронных задач, следующий раздел объясняет проблемы с вложением слишком многих обратных вызовов и созданием “пирамиды неудачи”.
Вложенные обратные вызовы и пирамида неудачи
Обратные вызовы являются эффективным способом обеспечения отложенного выполнения функции до тех пор, пока не завершится другая и не вернет данные. Однако из-за вложенной природы обратных вызовов код может становиться беспорядочным, если у вас много последовательных асинхронных запросов, которые зависят друг от друга. Это было большим разочарованием для разработчиков JavaScript на ранних этапах, и поэтому код, содержащий вложенные обратные вызовы, часто называется “пирамидой неудачи” или “адом обратных вызовов”.
Вот демонстрация вложенных обратных вызовов:
В этом коде каждый новый setTimeout
вложен в функцию более высокого порядка, создавая пирамидальную структуру из всё более глубоких обратных вызовов. Запуск этого кода даст следующий результат:
Output1
2
3
На практике, с реальным асинхронным кодом это может быть намного сложнее. Вероятно, вам придется обрабатывать ошибки в асинхронном коде и затем передавать некоторые данные из каждого ответа на следующий запрос. Если вы делаете это с помощью обратных вызовов, ваш код будет сложным для чтения и поддержки.
Вот пример более реалистичной “пирамиды ада”, с которой вы можете поиграть:
В этом коде вы должны учитывать возможные response
и error
в каждой функции, делая функцию callbackHell
визуально запутанной.
Запустив этот код, вы получите следующее:
Output
First 9
Second 3
Error: Whoa! Something went wrong.
at asynchronousRequest (<anonymous>:4:21)
at second (<anonymous>:29:7)
at <anonymous>:9:13
Этот способ обработки асинхронного кода сложно следить. В результате было введено понятие промисов в ES6. Об этом пойдет речь в следующем разделе.
Промисы
A promise represents the completion of an asynchronous function. It is an object that might return a value in the future. It accomplishes the same basic goal as a callback function, but with many additional features and a more readable syntax. As a JavaScript developer, you will likely spend more time consuming promises than creating them, as it is usually asynchronous Web APIs that return a promise for the developer to consume. This tutorial will show you how to do both.
Создание обещания
Вы можете инициализировать обещание с помощью синтаксиса new Promise
, и вы должны инициализировать его с помощью функции. Функция, которая передается обещанию, имеет параметры resolve
и reject
. Функции resolve
и reject
обрабатывают успешное и неудачное завершение операции, соответственно.
Напишите следующую строку для объявления обещания:
Если вы проверите инициализированное обещание в этом состоянии с помощью консоли вашего веб-браузера, вы обнаружите, что оно имеет статус pending
и значение undefined
:
Output__proto__: Promise
[[PromiseStatus]]: "pending"
[[PromiseValue]]: undefined
Пока что ничего не настроено для обещания, поэтому оно будет висеть в состоянии pending
вечно. Первое, что вы можете сделать для тестирования обещания, это выполнить обещание, разрешив его значением:
Теперь, проверив обещание, вы обнаружите, что у него статус fulfilled
, и value
установлено на значение, которое вы передали в resolve
:
Output__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: "We did it!"
Как утверждалось в начале этого раздела, обещание – это объект, который может возвращать значение. После успешного выполнения value
изменяется с undefined
на данные.
A promise can have three possible states: pending, fulfilled, and rejected.
- Ожидание – Исходное состояние перед разрешением или отклонением
- Выполнено – Успешная операция, обещание выполнено
- Отклонено – Операция не удалась, обещание отклонено
После того как обещание выполнено или отклонено, оно завершено.
Теперь, когда у вас есть представление о том, как создаются обещания, давайте посмотрим, как разработчик может использовать эти обещания.
Потребление обещания
Обещание в последнем разделе было выполнено с значением, но вы также хотите иметь возможность получить доступ к этому значению. У обещаний есть метод под названием then
, который будет запускаться после того, как обещание достигнет состояния resolve
в коде. then
вернет значение обещания в качестве параметра.
Вот как вы бы вернули и записали value
образца обещания:
У обещания, которое вы создали, было [[PromiseValue]]
равное We did it!
. Это значение будет передано в анонимную функцию как response
:
OutputWe did it!
До сих пор созданный вами пример не включал асинхронного веб-API — он только объяснил, как создавать, разрешать и потреблять обычное обещание JavaScript. Используя setTimeout
, вы можете протестировать асинхронный запрос.
Следующий код моделирует данные, возвращаемые из асинхронного запроса в виде обещания:
Использование синтаксиса then
гарантирует, что response
будет зарегистрирован только после завершения операции setTimeout
через 2000
миллисекунд. Все это происходит без вложенных обратных вызовов.
Теперь через две секунды обещание будет разрешено, и его значение будет зарегистрировано в then
:
OutputResolving an asynchronous request!
Обещания также могут быть связаны, чтобы передавать данные более чем в одну асинхронную операцию. Если значение возвращается в then
, можно добавить еще один then
, который будет выполняться с возвращаемым значением предыдущего then
:
Выполненный ответ во втором then
зарегистрирует возвращаемое значение:
OutputResolving an asynchronous request! And chaining!
Поскольку then
можно цеплять, это позволяет использовать обещания так, чтобы они выглядели более синхронно, чем обратные вызовы, поскольку они не требуют вложенности. Это позволяет создавать более читаемый код, который легче поддерживать и проверять.
Обработка ошибок
До сих пор вы обрабатывали только обещание с успешным resolve
, которое помещает обещание в состояние fulfilled
. Но часто с асинхронным запросом вам также нужно обрабатывать ошибку – если API недоступен, или отправлен неправильный или неавторизованный запрос. Обещание должно быть способно обрабатывать оба случая. В этом разделе вы создадите функцию для тестирования как успешного, так и ошибочного случаев создания и использования обещания.
Эта функция getUsers
будет передавать флаг обещанию и возвращать обещание:
Настройте код так, чтобы если onSuccess
равно true
, тайм-аут будет выполнен с какими-то данными. Если false
, функция будет отклонена с ошибкой:
Для успешного результата возвращайте JavaScript объекты, представляющие примерные данные пользователя.
Для обработки ошибки вы будете использовать метод catch
экземпляра. Это даст вам обратный вызов с ошибкой в качестве параметра error
.
Запустите команду getUser
с установленным onSuccess
в false
, используя метод then
для успешного случая и метод catch
для ошибки:
Поскольку произошла ошибка, блок then
будет пропущен, и ошибку обработает блок catch
:
OutputFailed to fetch data!
Если вы переключите флаг и вместо этого используете resolve
, блок catch
будет проигнорирован, и вместо этого будет возвращены данные:
Это приведет к получению данных о пользователе:
Output(3) [{…}, {…}, {…}]
0: {id: 1, name: "Jerry"}
1: {id: 2, name: "Elaine"}
3: {id: 3, name: "George"}
Для справки вот таблица с методами обработчиков на объектах Promise
:
Method | Description |
---|---|
then() |
Handles a resolve . Returns a promise, and calls onFulfilled function asynchronously |
catch() |
Handles a reject . Returns a promise, and calls onRejected function asynchronously |
finally() |
Called when a promise is settled. Returns a promise, and calls onFinally function asynchronously |
Обещания могут вызывать путаницу как у новых разработчиков, так и у опытных программистов, которые никогда не работали в асинхронной среде раньше. Однако, как упоминалось ранее, гораздо чаще возникает потребность в потреблении обещаний, чем в их создании. Обычно обещание предоставляется API браузера или сторонней библиотекой, и вам просто нужно его потребить.
В последнем разделе о промисах этот учебник приведет типичный пример использования API веб-запросов, который возвращает обещания: API Fetch.
Использование API Fetch с обещаниями
Один из самых полезных и часто используемых веб-API, возвращающих обещание, – это Fetch API, который позволяет делать асинхронный запрос к ресурсу через сеть. fetch
состоит из двух частей и, следовательно, требует цепочки then
. В этом примере демонстрируется обращение к API GitHub для получения данных пользователя, а также обработка возможных ошибок:
Запрос fetch
отправляется на URL https://api.github.com/users/octocat
, который асинхронно ожидает ответа. Первый then
передает ответ анонимной функции, которая форматирует ответ как JSON данные, затем передает JSON во второй then
, который записывает данные в консоль. Оператор catch
записывает любую ошибку в консоль.
Запуск этого кода приведет к следующему:
Outputlogin: "octocat",
id: 583231,
avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4"
blog: "https://github.blog"
company: "@github"
followers: 3203
...
Это запрошенные данные с https://api.github.com/users/octocat
, отображенные в формате JSON.
Этот раздел учебника показал, что промисы включают в себя множество улучшений для работы с асинхронным кодом. Однако, хотя использование then
для обработки асинхронных действий легче следить, чем пирамида обратных вызовов, некоторые разработчики по-прежнему предпочитают синхронный формат написания асинхронного кода. Чтобы удовлетворить эту потребность, ECMAScript 2016 (ES7) ввела функции async
и ключевое слово await
, чтобы упростить работу с промисами.
Асинхронные функции с async/await
Функция async
позволяет обрабатывать асинхронный код таким образом, что он выглядит синхронным. Функции async
все еще используют промисы внутри, но имеют более традиционный синтаксис JavaScript. В этом разделе вы попробуете примеры этого синтаксиса.
Вы можете создать функцию async
, добавив ключевое слово async
перед функцией:
Хотя эта функция пока что не обрабатывает ничего асинхронного, она ведет себя по-другому, чем традиционная функция. Если вы выполните эту функцию, вы увидите, что она возвращает промис с [[PromiseStatus]]
и [[PromiseValue]]
, а не возвращаемое значение.
Попробуйте это, залогировав вызов функции getUser
:
Это даст следующий результат:
Output__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: Object
Это означает, что вы можете обрабатывать функцию async
с помощью then
так же, как вы могли бы обрабатывать промис. Попробуйте это с помощью следующего кода:
Этот вызов getUser
передает возвращаемое значение анонимной функции, которая логирует значение в консоль.
Вы получите следующий результат при запуске этой программы:
Output{}
Функция async
может обрабатывать обещание, вызванное внутри неё с помощью оператора await
. await
может использоваться внутри функции async
и будет ждать, пока обещание не разрешится, прежде чем выполнить указанный код.
С этим знанием вы можете переписать запрос Fetch из последнего раздела, используя async
/await
следующим образом:
Операторы await
здесь обеспечивают, что data
не будет зарегистрирована до того, как запрос заполнит её данными.
Теперь окончательные data
можно обрабатывать внутри функции getUser
, без необходимости использования then
. Вот вывод регистрации data
:
Outputlogin: "octocat",
id: 583231,
avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4"
blog: "https://github.blog"
company: "@github"
followers: 3203
...
Примечание: Во многих средах async
необходим для использования await
—однако некоторые новые версии браузеров и Node позволяют использовать верхнеуровневый await
, что позволяет обойти создание асинхронной функции для обёртки await
.
Наконец, поскольку вы обрабатываете разрешённое обещание внутри асинхронной функции, вы также можете обработать ошибку внутри функции. Вместо использования метода catch
с then
, вы будете использовать шаблон try
/catch
, чтобы обработать исключение.
Добавьте следующий выделенный код:
Программа теперь перейдет к блоку catch
, если получит ошибку, и зарегистрирует эту ошибку в консоли.
Современный асинхронный код на JavaScript обычно обрабатывается с использованием синтаксиса async
/await
, но важно иметь рабочее понимание того, как работают промисы, особенно поскольку промисы способны к дополнительным функциям, которые нельзя обрабатывать с помощью async
/await
, например, объединение промисов с помощью Promise.all()
.
Примечание: async
/await
можно воспроизвести с использованием генераторов в сочетании с промисами, чтобы добавить больше гибкости в ваш код. Чтобы узнать больше, ознакомьтесь с нашим учебным пособием Понимание генераторов в JavaScript.
Вывод
Поскольку веб-API часто предоставляют данные асинхронно, изучение того, как обрабатывать результат асинхронных действий, является важной частью работы разработчика JavaScript. В этой статье вы узнали, как хост-среда использует цикл событий для управления порядком выполнения кода с помощью стека и очереди. Вы также попробовали примеры трех способов обработки успеха или неудачи асинхронного события с помощью обратных вызовов, промисов и синтаксиса async
/await
. Наконец, вы использовали веб-API Fetch для обработки асинхронных действий.
Для получения дополнительной информации о том, как браузер обрабатывает параллельные события, прочтите Модель параллелизма и цикл событий на сайте Mozilla Developer Network. Если вы хотите узнать больше о JavaScript, вернитесь к нашей серии Как писать на JavaScript.