Введение
На заре развития Интернета веб-сайты часто представляли собой статические данные на HTML-странице. Но теперь, когда веб-приложения стали более интерактивными и динамичными, всё чаще возникает необходимость в выполнении ресурсоёмких операций, таких как внешние сетевые запросы для получения данных API. Для выполнения этих операций в JavaScript разработчику необходимо использовать методы асинхронного программирования.
Поскольку JavaScript — однопоточный язык программирования с моделью параллельного выполнения, обрабатывающей операции одну за другой, он может обрабатывать только одну команду за раз. Однако такое действие, как запрос данных из API, может занять неопределённое время в зависимости от объёма запрашиваемых данных, скорости сетевого соединения и других факторов. Если бы вызовы API выполнялись синхронно, браузер не смог бы обработать пользовательский ввод, такой как прокрутка или нажатие кнопки, до завершения операции. Это называется блокировкой.
Чтобы избежать блокирующего поведения, в среде браузера предусмотрено множество веб-API, к которым JavaScript может обращаться асинхронно, то есть они могут выполняться параллельно с другими операциями, а не последовательно. Это полезно, поскольку позволяет пользователю продолжать использовать браузер в обычном режиме, пока выполняется асинхронная операция.
Цикл событий
В этом разделе объясняется, как JavaScript обрабатывает асинхронный код с помощью цикла событий. Сначала демонстрируется работа цикла событий, а затем объясняются два его элемента: стек и очередь.
Код JavaScript, не использующий ни один из асинхронных веб-API, выполняется синхронно — по одному, последовательно. Это демонстрирует следующий пример кода, который вызывает три функции, каждая из которых выводит число на консоль:
// Define three example functions
function first() {
console.log(1)
}
function second() {
console.log(2)
}
function third() {
console.log(3)
}В этом коде вы определяете три функции, которые выводят числа с помощью console.log().
Затем пропишите вызовы функций:
// Execute the functions
first()
second()
third()Вывод будет основан на порядке вызова функций — first(), second(), затем three():
Output
1
2
3При использовании асинхронного веб-API правила усложняются. Один из встроенных API, с которым можно поэкспериментировать, — это setTimeout , который устанавливает таймер и выполняет действие по истечении заданного времени. setTimeout должен быть асинхронным, иначе весь браузер зависнет, пока вы ждете, что негативно скажется на пользовательском опыте.
Чтобы имитировать асинхронный запрос, добавьте setTimeout ко второй функции:
// Define three example functions, but one of them contains asynchronous code
function first() {
console.log(1)
}
function second() {
setTimeout(() => {
console.log(2)
}, 0)
}
function third() {
console.log(3)
}Функция setTimeout принимает два аргумента: функцию для асинхронного выполнения и время ожидания перед вызовом этой функции. В этом коде вы помещаете console.log в анонимную функцию и передаёте её в setTimeout, а затем задаёте выполнение функции через 0 миллисекунд.
Теперь вызовите функции как и прежде:
// Execute the functions
first()
second()
third()Можно было бы ожидать, что при установке setTimeout в 0 выполнение этих трёх функций по-прежнему будет выводить числа последовательно. Но поскольку функция асинхронная, она выведет их с паузой на последнем:
Output
1
3
2Неважно, установлен ли таймер на ноль секунд или на пять минут — console.log , вызываемый асинхронным кодом, будет запущен после синхронных функций верхнего уровня. Это происходит потому, что среда JavaScript, в данном случае браузер, использует концепцию, называемую циклом событий, для обработки одновременных или параллельных событий. Поскольку JavaScript может выполнять только одну команду за раз, цикл событий необходим для определения времени выполнения конкретной команды. Цикл событий обрабатывает это с помощью концепций стеков и очередей.
Куча
Стек, или стек вызовов, хранит состояние текущей выполняемой функции. Если вы не знакомы с концепцией стека, вы можете представить его как массив со свойствами «последним пришёл, первым ушёл» (LIFO), что означает, что вы можете добавлять или удалять элементы только из нижней части стека. JavaScript выполняет текущий фрейм (или вызов функции в определённой среде) в стеке, затем удаляет его и переходит к следующему фрейму.
Для примера, содержащего только синхронный код, браузер выполняет действия в следующем порядке:
- первый() Добавить в стек, выполнить first(), который выведет 1 на консоль, удалить first() из стека.
- второй() Добавить в стек, выполнить second(), который выведет 2 на консоль, удалить second() из стека.
- третий () Добавить в стек, выполнить third(), который выведет 3 на консоль, удалить third() из стека.
Второй пример с setTimout выглядит следующим образом:
- первый() Добавить в стек, выполнить first(), который выведет 1 на консоль, удалить first() из стека.
- второй() Добавить в стек, выполнить second().
- Добавьте setTimeout() в стек, выполните веб-API setTimeout(), который запускает таймер и добавляет анонимную функцию в очередь, удалите setTimeout() из стека.
- второй() Удалить из стопки.
- Третий () Добавить в стек, выполнить third(), который выведет 3 на консоль, удалить third() из стека.
- Цикл событий проверяет очередь на наличие ожидающих сообщений и находит анонимную функцию из setTimeout(), добавляет функцию в стек, которая выводит 2 на консоль, а затем удаляет ее из стека.
Используя setTimeout, асинхронный веб-API вводит концепцию очереди, которая будет рассмотрена далее в этом руководстве.
Очередь
Очередь, также называемая очередью сообщений или очередью задач, представляет собой область ожидания функций. Когда стек вызовов пуст, цикл обработки событий проверяет очередь на наличие ожидающих сообщений, начиная с самого старого. Найдя сообщение, он добавляет его в стек, что приводит к выполнению функции, содержащейся в сообщении.
В примере setTimeout анонимная функция выполняется сразу после завершения выполнения основного кода, поскольку таймер был установлен на 0 секунд. Важно помнить, что таймер не означает, что код будет выполнен ровно через 0 секунд или в какой-то конкретный момент времени, а лишь то, что он добавит анонимную функцию в очередь в течение этого времени. Такая система очередей существует, поскольку, если таймер добавит анонимную функцию непосредственно в стек после истечения времени, это прервет текущую выполняемую функцию, что может иметь непреднамеренные и непредсказуемые последствия.
Примечание: существует также другая очередь, называемая очередью заданий или очередью микрозадач, которая обрабатывает обещания. Микрозадачи, такие как обещания, выполняются с более высоким приоритетом, чем макрозадачи, такие как setTimeout.
Теперь, когда вы знаете, как цикл событий использует стек и очередь для управления порядком выполнения кода, следующая задача — разобраться, как контролировать порядок выполнения кода. Для этого сначала изучите основной способ обеспечения корректной обработки асинхронного кода циклом событий: функции обратного вызова.
Функции обратного вызова
В примере setTimeout функция с таймаутом выполняется после всех событий в основном контексте выполнения верхнего уровня. Но если вы хотите гарантировать, что одна из функций, например, третья, будет выполнена после таймаута, необходимо использовать асинхронные методы кодирования. Таймаут здесь может представлять собой асинхронный вызов API, содержащий данные. Вы хотите работать с данными из вызова API, но сначала нужно убедиться, что данные возвращаются.
Основным решением этой проблемы является использование функций обратного вызова. У функций обратного вызова нет специального синтаксиса. Это просто функция, которая передаётся в качестве аргумента другой функции. Функция, принимающая в качестве аргумента другую функцию, называется функцией высшего порядка. Согласно этому определению, любая функция может стать вызываемой, если она передана в качестве аргумента. Телефонные вызовы не являются асинхронными по своей сути, но их можно использовать для асинхронных задач.
Вот пример синтаксического кода функции высшего порядка и обратного вызова:
// A function
function fn() {
console.log('Just a function')
}
// A function that takes another function as an argument
function higherOrderFunction(callback) {
// When you call a function that is passed as an argument, it is referred to as a callback
callback()
}
// Passing a function
higherOrderFunction(fn)В этом коде вы определяете функцию fn, определяете функцию aboveOrderFunction, которая принимает функцию обратного вызова в качестве аргумента, и передаете fn как обратный вызов в aboveOrderFunction.
Выполнение этого кода позволит достичь следующего:
Output
Just a functionВернёмся к первой, второй и третьей функциям setTimeout. Вот что у нас получилось на данный момент:
function first() {
console.log(1)
}
function second() {
setTimeout(() => {
console.log(2)
}, 0)
}
function third() {
console.log(3)
}Задача состоит в том, чтобы третья функция всегда откладывала выполнение до завершения асинхронного действия во второй функции. Именно здесь на помощь приходят обратные вызовы. Вместо того, чтобы выполнять первую, вторую и третью функции на верхнем уровне выполнения, третья функция передаётся второй в качестве аргумента. Вторая функция выполняет обратный вызов после завершения асинхронного действия.
Вот три функции, реализованные с помощью обратного вызова:
// Define three functions
function first() {
console.log(1)
}
function second(callback) {
setTimeout(() => {
console.log(2)
// Execute the callback function
callback()
}, 0)
}
function third() {
console.log(3)
}Теперь выполните первое и второе, а затем передайте третье как аргумент второму:
first()
second(third)После выполнения этого блока кода вы получите следующий вывод:
Output
1
2
3Сначала печатается 1, а после истечения таймера (в данном случае это ноль секунд, но вы можете изменить его на любое значение) печатается 2, а затем 3. Работает до тех пор, пока не завершится асинхронный веб-API (setTimeout).
Ключевой момент здесь заключается в том, что функции обратного вызова не являются асинхронными: setTimeout — это асинхронный веб-API, отвечающий за обработку асинхронных задач. Обратные вызовы позволяют только получать уведомления о завершении асинхронной задачи и обрабатывать её успешность или неудачу.
Теперь, когда вы узнали, как использовать обратные вызовы для выполнения асинхронных задач, в следующем разделе объясняются проблемы, связанные с вложением слишком большого количества вызовов и созданием “пирамиды гибели”.
Вложенный обратный вызов и пирамида гибели
Функции обратного вызова — эффективный способ обеспечить задержку выполнения одной функции до тех пор, пока другая функция не завершит работу и не вернёт данные. Однако из-за вложенности функций обратного вызова код может стать загромождённым при наличии множества последовательных асинхронных запросов, зависящих друг от друга. Это было серьёзной проблемой для ранних разработчиков JavaScript, и поэтому код, содержащий вложенные вызовы, часто называют “пирамидой мучений” или “адом обратных вызовов”.
Вот демонстрация вложенных обратных вызовов:
function pyramidOfDoom() {
setTimeout(() => {
console.log(1)
setTimeout(() => {
console.log(2)
setTimeout(() => {
console.log(3)
}, 500)
}, 2000)
}, 1000)
}В этом коде каждый новый setTimeout помещается внутрь функции высшего порядка, создавая пирамиду всё более глубоких вызовов. Выполнение этого кода приведёт к следующему:
Output
1
2
3На практике, с реальным асинхронным кодом, всё может быть гораздо сложнее. Скорее всего, вам придётся обрабатывать ошибки в асинхронном коде, а затем передавать часть данных из каждого ответа следующему запросу. Использование обратных вызовов усложняет чтение и поддержку кода.
Вот рабочий пример более реалистичной “пирамиды гибели”, с которой вы можете поиграть:
// Example asynchronous function
function asynchronousRequest(args, callback) {
// Throw an error if no arguments are passed
if (!args) {
return callback(new Error('Whoa! Something went wrong.'))
} else {
return setTimeout(
// Just adding in a random number so it seems like the contrived asynchronous function
// returned different data
() => callback(null, {body: args + ' ' + Math.floor(Math.random() * 10)}),
500,
)
}
}
// Nested asynchronous requests
function callbackHell() {
asynchronousRequest('First', function first(error, response) {
if (error) {
console.log(error)
return
}
console.log(response.body)
asynchronousRequest('Second', function second(error, response) {
if (error) {
console.log(error)
return
}
console.log(response.body)
asynchronousRequest(null, function third(error, response) {
if (error) {
console.log(error)
return
}
console.log(response.body)
})
})
})
}
// Execute
callbackHell()В этом коде вам придется учитывать для каждой функции возможный ответ и возможную ошибку, что делает функцию 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 была введена концепция обещаний (promises). Этому посвящен следующий раздел.










Один комментарий
Это было очень, очень здорово. Спасибо за отличный контент.