Skip to content

Асинхронное программирование на JavaScript и коллбэки

Асинхронность в языках программирования

Компьютеры по своей природе асинхронны.

Асинхронность означает, что процессы могут происходить независимо от основного потока программы.

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

Внутренне программы используют прерывания — сигналы, посылаемые процессору для привлечения внимания системы.

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

Обычно языки программирования являются синхронными, а некоторые предоставляют способ управления асинхронностью в языке или с помощью библиотек. C, Java, C#, PHP, Go, Ruby, Swift и Python по умолчанию являются синхронными. Некоторые из них обрабатывают асинхронные операции с использованием потоков, создавая новый процесс.

JavaScript

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

Строки кода выполняются последовательно, одна за другой, например:

js
const a = 1;
const b = 2;
const c = a * b;
console.log(c);
doSomething();

Но JavaScript зародился внутри браузера, его основная задача изначально заключалась в реагировании на действия пользователя, такие как onClick, onMouseOver, onChange, onSubmit и так далее. Как он мог это делать с синхронной моделью программирования?

Ответ заключался в его среде. Браузер предоставляет способ сделать это, предоставляя набор API, которые могут обрабатывать такие функции.

В последнее время Node.js представил неблокирующую I/O среду для расширения этой концепции до доступа к файлам, сетевых вызовов и так далее.

Обратные вызовы (Callbacks)

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

js
document.getElementById('button').addEventListener('click', () => {
  // элемент нажат
});

Это так называемый обратный вызов (callback).

Обратный вызов — это простая функция, которая передаётся в качестве значения другой функции и будет выполнена только тогда, когда произойдёт событие. Мы можем сделать это, потому что JavaScript имеет функции первого класса, которые могут быть присвоены переменным и переданы другим функциям (называемым функциями высшего порядка).

Обычно весь ваш клиентский код оборачивается в обработчик событий load объекта window, который запускает функцию обратного вызова только тогда, когда страница готова:

js
window.addEventListener('load', () => {
  // страница загружена
  // делайте что хотите
});

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

Один из распространенных примеров — использование таймеров:

js
setTimeout(() => {
  // выполняется через 2 секунды
}, 2000);

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

js
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4) {
    xhr.status === 200 ? console.log(xhr.responseText) : console.error('error');
  }
};
xhr.open('GET', 'https://yoursite.com');
xhr.send();

Обработка ошибок в обратных вызовах

Как обрабатывать ошибки с обратными вызовами? Одна очень распространённая стратегия — использовать подход, принятый в Node.js: первый параметр любой функции обратного вызова — это объект ошибки: обратные вызовы с приоритетом ошибки (error-first callbacks).

Если ошибки нет, объект равен null. Если ошибка есть, он содержит некоторое описание ошибки и другую информацию.

js
const fs = require('node:fs');
fs.readFile('/file.json', (err, data) => {
  if (err) {
    // обработка ошибки
    console.log(err);
    return;
  }
  // ошибок нет, обрабатываем данные
  console.log(data);
});

Проблема обратных вызовов

Обратные вызовы отлично подходят для простых случаев!

Однако каждый обратный вызов добавляет уровень вложенности, и когда у вас много обратных вызовов, код очень быстро становится сложным:

js
window.addEventListener('load', () => {
  document.getElementById('button').addEventListener('click', () => {
    setTimeout(() => {
      items.forEach(item => {
        // ваш код здесь
      });
    }, 2000);
  });
});

Это всего лишь простой код с 4 уровнями вложенности, но я видел гораздо больше уровней вложенности, и это не весело.

Как мы это решим?

Альтернативы обратным вызовам

Начиная с ES6, JavaScript представил несколько функций, которые помогают нам с асинхронным кодом, не используя обратные вызовы: Promises (ES6) и Async/Await (ES2017).