Pull to refresh

Ты наконец-то поймешь асинхронность в JS

Level of difficultyEasy
Reading time10 min
Views26K

Привет, друзья! Сегодня мы поговорим о том, что такое асинхронность в JavaScript и как она работает. Это одна из тех вещей, которые кажутся сложными, но на самом деле довольно просты, как только разберешься.

Понятие асинхронности и синхронности

Синхронный код - это код, который выполняется последовательно, один за другим. Ничего сложного, просто пошаговое выполнение. Для примера, вот как может выглядеть синхронный код:

console.log('Шаг 1');
console.log('Шаг 2');
console.log('Шаг 3');

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

console.log('Шаг 1');

setTimeout(function() {
    console.log('Шаг 2');
}, 2000);

console.log('Шаг 3');

Практический пример с setTimeout

В примере выше мы использовали setTimeout, чтобы задержать выполнение определенной функции на две секунды. Заметьте, что функция в setTimeout выполняется асинхронно, а остальной код продолжает выполняться без ожидания.


Конечно, введем более детальные примеры и разберем, какие методы являются микрозадачами, а какие макрозадачами.

Микрозадачи и макрозадачи

В JavaScript некоторые операции создают микрозадачи, а некоторые - макрозадачи. Давайте рассмотрим примеры.

Микрозадачи

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

  1. Разрешение или отклонение промиса с помощью методов .then(), .catch() и .finally().

  2. Использование async/await в асинхронных функциях.

Давайте рассмотрим пример с использованием промисов:

console.log('Начало');

Promise.resolve()
  .then(() => console.log('Это микрозадача'))
  .then(() => console.log('Это еще одна микрозадача'));

console.log('Конец');
Объяснение что делает код
  1. В этом примере:

    1. Сначала выполняется console.log('Начало'), выводя в консоль строку "Начало".

    2. Затем создается разрешенный промис с помощью Promise.resolve().

    3. Добавляются обработчики .then(), которые ставятся в очередь микрозадач. Они будут выполнены после текущего стека вызовов и перед следующим событием цикла событий или рендерингом браузера.

    4. После этого выполняется console.log('Конец'), выводя в консоль строку "Конец".

    Таким образом, порядок вывода в консоль будет следующим:

    1. "Начало"

    2. "Конец"

    3. "Это микрозадача"

    4. "Это еще одна микрозадача"

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

Этот код создает две микрозадачи с помощью методов .then(). Заметьте, что они выполняются после основной задачи, несмотря на то, что они добавляются в очередь раньше.

Макрозадачи

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

  1. Выполнение скрипта.

  2. Обработка событий DOM.

  3. Выполнение кода в таймауте с помощью setTimeout() или setInterval().

Рассмотрим пример с использованием setTimeout():

console.log('Начало');

setTimeout(() => {
  console.log('Это макрозадача');
}, 0);

console.log('Конец');
Объяснение кода
  1. Сначала выполняется console.log('Начало'), выводя в консоль строку "Начало".

  2. Затем вызывается функция setTimeout(), которая принимает два аргумента: функцию обратного вызова и время задержки. В данном случае функция обратного вызова выводит в консоль строку "Это макрозадача", а время задержки установлено в 0 миллисекунд. Таким образом, эта функция будет добавлена в очередь макрозадач для выполнения после задержки, даже если задержка установлена на 0.

  3. Далее выполняется console.log('Конец'), выводя в консоль строку "Конец".

Важно отметить, что задержка в setTimeout() указана как 0 миллисекунд. Это не означает, что функция обратного вызова будет выполнена мгновенно. Вместо этого, она будет добавлена в очередь событий и выполнена после того, как выполнится текущий код. Таким образом, строка "Это макрозадача" будет выведена в консоль после строк "Начало" и "Конец", но до того, как браузер начнет обрабатывать другие события или запросы.

Этот код создает макрозадачу с помощью функции setTimeout(). Заметьте, что она добавляется в очередь после основной задачи и даже после микрозадач.

Что будет обработано первее?

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

Преимущества понимания микро и макрозадач

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

Как это работает: Event Loop

Как происходит выполнение этих задач? Здесь на сцену выходи Event Loop.

Event Loop – это ключевой компонент асинхронной модели выполнения в JavaScript и других средах, таких как Node.js. Он состоит из двух основных компонентов: стека вызовов (call stack) и очереди событий (event queue).

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

Event Loop непрерывно проверяет стек вызовов и очередь событий. Если стек вызовов пуст, Event Loop берет первое событие из очереди и помещает его в стек вызовов для выполнения. После выполнения каждой операции событие удаляется из стека вызовов, и Event Loop переходит к следующему событию в очереди.

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

Преимущества асинхронности

Асинхронный код позволяет создавать более отзывчивые и эффективные веб-приложения, не замораживая интерфейс. Он позволяет выполнять задачи в фоновом режиме, не блокируя основной поток выполнения. Улучшает производительность приложений, особенно в сценариях, где требуется обработка большого количества данных или выполнение длительных операций.


Асинхронные методы и их особенности

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

Пример с использованием Promise

  • Promise: Promise — это объект, представляющий конечное состояние асинхронной операции. Он может находиться в одном из трех состояний: ожидание (pending), выполнено (fulfilled) или отклонено (rejected). Promise позволяет зарегистрировать обратные вызовы (callbacks), которые будут вызваны, когда Promise перейдет в состояние выполнено или отклонено. Пример использования Promise:

console.log('Начало');

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('Выполнено!');
    },   2000);
});

promise.then((result) => {
    console.log(result);
}).catch((error) => {
    console.error(error);
});

console.log('Конец');
Объяснение кода
  1. Сначала выполняется console.log('Начало'), выводя в консоль строку "Начало".

  2. Затем создается новый экземпляр Promise с помощью конструктора Promise. Внутри конструктора выполняется функция, которая содержит асинхронную операцию - функцию обратного вызова setTimeout(). Эта функция вызовет resolve('Выполнено!') через 2000 миллисекунд (или 2 секунды), сообщая, что промис был успешно разрешен.

  3. После создания промиса вызывается метод .then(), который ожидает успешного выполнения промиса. Когда промис разрешится успешно, выполнится переданный обработчик (result) => { console.log(result); }, который выводит в консоль строку, переданную в resolve, в данном случае "Выполнено!".

  4. В случае возникновения ошибки в промисе, если был вызван метод reject(), выполнится метод .catch(). В данном коде этот случай не рассматривается.

  5. Затем выполняется console.log('Конец'), выводя в консоль строку "Конец".

Таким образом, результат выполнения этого кода будет:
Начало
Конец
(пауза в 2 секунды)
Выполнено!

В этом примере мы создаем Promise, который выполняется через две секунды. Затем мы используем метод .then(), чтобы обработать результат выполнения Promise. Заметьте, что код после создания Promise не ждет его выполнения и продолжает работу.

Пример с использованием async/await

  • async/awaitasync/await — это синтаксический сахар над Promise, который позволяет писать асинхронный код, выглядящий как синхронный. Ключевое слово async делает функцию асинхронной, а await используется для ожидания разрешения Promise. Пример использования async/await:

console.log('Начало');

async function myAsyncFunction() {
    try {
        const result = await new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve('Выполнено!');
            },  2000);
        });

        console.log(result);
    } catch (error) {
        console.error(error);
    }
}

myAsyncFunction();

console.log('Конец');
Hidden text
  1. console.log('Начало') выводит строку "Начало" в консоль.

  2. Объявляется асинхронная функция myAsyncFunction().

  3. Внутри myAsyncFunction() создается промис с помощью конструктора Promise, который будет разрешен через 2 секунды с помощью setTimeout. Этот промис возвращает строку "Выполнено!".

  4. С помощью оператора await в myAsyncFunction() ожидается разрешение промиса. Это приостанавливает выполнение функции до момента разрешения промиса.

  5. После разрешения промиса его значение сохраняется в переменной result.

  6. Вызывается console.log(result), выводя значение промиса ("Выполнено!") в консоль.

  7. Вызывается myAsyncFunction().

  8. console.log('Конец') выводит строку "Конец" в консоль сразу после вызова myAsyncFunction(), не дожидаясь завершения асинхронной операции.

Начало
Конец
(пауза в 2 секунды)
Выполнено!

После вывода "Конец" выполняется асинхронная операция с задержкой в 2 секунды, а затем выводится результат "Выполнено!”


Здесь мы определяем асинхронную функцию, которая ожидает выполнения Promise с помощью ключевого слова await. Это позволяет нам писать код, который выглядит как синхронный, но на самом деле выполняется асинхронно. Обработка ошибок осуществляется с помощью блока try/catch.

Преимущества использования Promise и async/await

  • Читаемость кода: Promise и async/await делают асинхронный код более читаемым и понятным, особенно при выполнении сложных последовательностей операций.

  • Управление ошибками: Promise предоставляет удобный способ обработки ошибок с помощью метода .catch(), а async/await позволяет использовать блок try/catch для обработки ошибок.

  • Избегание callback hell: Использование цепочек Promise или async/await позволяет избежать так называемой "callback hell" - ситуации, когда множество вложенных обратных вызовов делают код трудным для чтения и отладки.

callback hell во своей своей красе
callback hell во своей своей красе

Обработка ошибок при выполнении нескольких промисов

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

console.log('Начало');

const promise1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('Первый промис выполнен');
    }, 1000);
});

const promise2 = new Promise((resolve, reject) => {
    setTimeout(() => {
        reject(new Error('Ошибка второго промиса'));
    }, 500);
});

Promise.all([promise1, promise2])
    .then((results) => {
        console.log(results[0]); // Выводит результат первого промиса
        console.log(results[1]); // Этот код не выполнится из-за ошибки второго промиса
    })
    .catch((error) => {
        console.error(error); // Ловим ошибку из второго промиса
    });

console.log('Конец');
Пояснение к коду
  1. Вывод "Начало": Скрипт начинает выполнение, и первой строкой в консоли выводится "Начало".

  1. Создание промиса promise1: Создается новый промис promise1, который будет выполнен через 1 секунду с результатом 'Первый промис выполнен'.

  1. Создание промиса promise2: Создается новый промис promise2, который будет завершен через 0.5 секунды с ошибкой new Error('Ошибка второго промиса').

  1. Использование Promise.all: Вызывается Promise.all с массивом промисов [promise1, promise2]. Этот метод возвращает новый промис, который будет выполнен, когда все промисы в массиве завершены.

  1. Обработка результатов: В блоке .then предполагается, что оба промиса успешно завершены, и результаты их выполнения будут выведены в консоль. Однако, поскольку promise2 завершается с ошибкой, этот код не будет выполнен.

  1. Обработка ошибки: В блоке .catch перехватывается ошибка из promise2, и она выводится в консоль с помощью console.error(error).

  1. Вывод "Конец": После определения промисов и перед их выполнением в консоль выводится "Конец".

  1. Вывод ошибки: Поскольку promise2 завершается с ошибкой, в блоке .catch выводится сообщение об ошибке: "Ошибка второго промиса".

Таким образом, в консоли вы увидите следующий порядок сообщений:

  • "Начало"

  • "Конец"

  • "Ошибка второго промиса"

Обратите внимание, что результат первого промиса не будет выведен в консоль, так как второй промис завершается с ошибкой, и Promise.all не выполняет блок .then.

В этом примере мы создаем два промиса. Первый промис выполняется через одну секунду, а второй промис - через полсекунды, но завершается с ошибкой. Мы используем метод Promise.all(), чтобы выполнить оба промиса параллельно и дождаться их завершения. Однако, если хотя бы один из промисов завершится с ошибкой, выполнение всех промисов остановится, и управление перейдет к блоку catch, где мы можем обработать ошибку.

Преимущества использования Promise.all()

  • Эффективность: Promise.all() позволяет одновременно запускать несколько асинхронных задач, что может значительно улучшить производительность.

  • Отслеживание состояния всех промисов: Мы можем легко отследить состояние выполнения всех промисов с помощью одного обработчика .then() и .catch().

  • Простота использования: Метод Promise.all() прост в использовании и позволяет нам написать более чистый и понятный код.

Заключение

Этот обзор асинхронного программирования в JavaScript, надеюсь, помог вам получить более глубокое понимание того, как использовать асинхронные методы и какие преимущества они предоставляют. Глубокое понимание асинхронного кода является ключевым аспектом разработки высокопроизводительных веб-приложений, позволяя эффективно управлять потоками данных, избегать блокировок и улучшать общую производительность приложений. Полученные знания могут быть важным активом для разработчиков, помогая им создавать более эффективные и отзывчивые веб-приложения, что в конечном итоге приводит к улучшенному пользовательскому опыту.

Что делать, если хочется больше информации?

Professional JavaScript for Web Developers, 5th Edition (2023) Автор: Matt Frisbie
Страница: 384.

Поиграться с асинхронностью можно на этом сайте https://www.jsv9000.app/

Tags:
Hubs:
Total votes 27: ↑22 and ↓5+17
Comments33

Articles