Event Loop, Callbacks, Promises, и Async/Await, часть 2 из 4

В предыдущей части разбирались, как JavaScript обрабатывает асинхронный код с помощью цикла событий. Далее рассмотрим способ обеспечения правильной обработки асинхронного кода в цикле событий: функции обратного вызова — callback.

Функции обратного вызова (callback functions)

В примере setTimeout функция с тайм-аутом запускалась после всего в основном контексте выполнения верхнего уровня. Но если понадобится, чтобы одна из функций, например third(), выполнялась после таймаута, то придётся использовать методы асинхронного кодирования. Таймаут здесь может имитировать асинхронный вызов API, который содержит данные. Чтобы работать с данными которые вернул API, надо сперва убедится, что данные возвращаются.

Первоначальное решение этой проблемы — использование функций обратного вызова (callback functions). Функции обратного вызова (callback) не имеют специального синтаксиса; это просто функция, переданная в качестве аргумента другой функции. Функция, которая принимает другую функцию в качестве аргумента, называется функцией высшего порядка. Согласно этому определению, любая функция может стать функцией обратного вызова, если она передана в качестве аргумента. Обратные вызовы не являются асинхронными по своей природе, но могут использоваться для асинхронных целей. Вот пример синтаксического кода функции высшего порядка и обратного вызова:

// Функция
function fn() {
  console.log('Просто функция')
}

// Функция, которая в качестве аргумента принимает другую функцию
function higherOrderFunction(callback) {
  // Когда вы вызываете функцию,
  // которая передается в качестве аргумента,
  // она называется обратным вызовом (callback).
  callback()
}

// Передача функции
higherOrderFunction(fn)

В этом коде определены две функции fn и upperOrderFunction. При вызове функции upperOrderFunction, ей аргументом передаётся fn и используется качестве обратного вызова. Выполнение этого кода оставит запись в консоли:

Просто функция

Но вернёмся к примеру с функциями first(), second(), third() и setTimeout():

function first() {
  console.log(1)
}

function second() {
  setTimeout(() => {
    console.log(2)
  }, 0)
}

function third() {
  console.log(3)
}

Задача состоит в том, чтобы выполнение функции third() откладывалось до завершения асинхронного действия в функции second(). Вот здесь и нужны обратные вызовы. Вместо вызова third() на верхнем уровне выполнения надо передать ее в качестве аргумента в функцию second(). Функция second() должна выполнить переданный ей обратный вызов по завершении асинхронного действия. Вот три функции с примененным обратным вызовом:

// Определяем три функции
function first() {
  console.log(1)
}

function second(callback) {
  setTimeout(() => {
    console.log(2)

    // Здесь выполняется обратный вызов
    callback()
  }, 0)
}

function third() {
  console.log(3)
}

Теперь надо выполнить first() и second(), которой в качестве аргумента передать third():

first()
second(third)

После запуска этого блока кода в консоли получится:

1
2
3

Сначала будет напечатано 1, после завершения таймера (в данном случае 0 секунд, но можно изменить его на любое значение) он напечатает 2, затем 3. Передача функции в качестве обратного вызова, откладывает ее выполнение до завершения асинхронного веб-API (setTimeout()).

Основной вывод здесь заключается в том, что функции обратного вызова (callback) сами не являются асинхронными, но setTimeout — это асинхронный веб-API, отвечающий за обработку асинхронных задач. Обратный вызов позволяет получать информацию о завершении асинхронной задачи и обрабатывать её успешный или неудачный результат.

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

Вложенные обратные вызовы (Nested callbacks)

Функции обратного вызова — это эффективный способ отложить выполнение функции до тех пор, пока другая функция завершится и вернёт данные. Однако из-за вложенности обратных вызовов код может очень быстро запутаться, если в нём есть много последовательных асинхронных запросов, которые зависят друг от друга. Поначалу это было большим разочарованием для JavaScript-разработчиков, и в результате код, содержащий вложенные обратные вызовы, часто называют «pyramid of doom» или «callback hell». Вот пример вложенных обратных вызовов:

function pyramidOfDoom() {
  setTimeout(() => {
    console.log(1)
    setTimeout(() => {
      console.log(2)
      setTimeout(() => {
        console.log(3)
      }, 500)
    }, 2000)
  }, 1000)
}

Здесь каждый новый setTimeout() вложен в функцию более высокого порядка, реализуя пирамиду обратных вызовов вглубь. Запуск этого кода вернёт следующее:

1
2
3

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

Вот работающий пример более реалистичной «pyramid of doom», с которой можно поиграть здесь:

// Пример асинхронной функции
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(
      // добавление случайного числа,
      // чтобы создать впечатление асинхронной функции
      // возвращает разные данные
      () => callback(null, { body: args + ' ' + Math.floor(Math.random() * 10) }),
      500
    )
  }
}

// вложенные асинхронные запросы
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)
      })
    })
  })
}

// запуск
callbackHell()

В этом коде сделано так, чтобы каждая функция учитывала возможный ответ (response) и возможную ошибку (error), это делает функцию callbackHell визуально запутанной. Запуск этого кода вернет что-то такое:

First 9
Second 3
Error: Whoa! Something went wrong.
    at asynchronousRequest (:4:21)
    at second (:29:7)
    at :9:13

Понятно, что этот способ обработки асинхронного кода использовать непросто. В результате в ES6 была введена концепция обещаний (promises). Этому посвящена следующая часть про обещания — promises.