Pull to refresh

Взгляд на асинхронность в JavaScript: роль Event Loop, промисов и async/await

Level of difficultyMedium
Reading time10 min
Views14K

В этой статье мы сосредоточимся на ключевых элементах асинхронного программирования в JavaScript: Event Loop, микро и макро задачи, Event Bus, промисы и синтаксический сахар async/await. Разберемся, как эти концепции взаимодействуют между собой и как их использование помогает нам создавать более эффективные и отзывчивые веб-приложения.

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

Введение

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

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

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

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

Что делает JavaScript асинхронным

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

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

  2. Callback-функции. В простых терминах, callback-функция - это функция, которая передается в другую функцию в качестве аргумента и выполняется после завершения какой-то операции. Примером может быть обработка данных после завершения асинхронной операции, такой как загрузка изображения.

    function loadImage(url, callback) {
        let image = new Image();
        image.onload = function () {
            callback(null, image);
        };
        image.onerror = function () {
            callback(new Error('Failed to load image'));
        };
        image.src = url;
    }
    
    loadImage('example.jpg', function (error, loadedImage) {
        if (error) {
            console.error(error);
        } else {
            console.log('Image loaded successfully:', loadedImage);
        }
    });
  3. Промисы. Промисы предоставляют улучшенный способ обработки асинхронных операций и управления их состоянием (выполнено, отклонено, ожидается). Пример использования промисов для асинхронной загрузки данных

    function fetchData(url) {
        return new Promise((resolve, reject) => {
            fetch(url)
                .then(response => response.json())
                .then(data => resolve(data))
                .catch(error => reject(error));
        });
    }
    
    fetchData('https://api.example.com/data')
        .then(data => console.log('Data loaded successfully:', data))
        .catch(error => console.error('Error loading data:', error));
    
    1. Async/Await. Ключевые слова async/await предоставляют синтаксический сахар для работы с промисами, делая код более читаемым. Пример использования async/await:

      async function fetchData(url) {
          try {
              let response = await fetch(url);
              let data = await response.json();
              return data;
          } catch (error) {
              throw new Error('Error loading data');
          }
      }
      
      async function loadData() {
          try {
              let data = await fetchData('https://api.example.com/data');
              console.log('Data loaded successfully:', data);
          } catch (error) {
              console.error('Error loading data:', error);
          }
      }
      
      loadData();

    Что такое Event Loop (+ микро и макро задачи) и Event Bus

    Event Loop (Цикл событий)

    Event Loop - это механизм, присутствующий в средах выполнения JavaScript, таких как браузер и среда Node.js, который позволяет обрабатывать асинхронные операции и события. Он поддерживает однопоточную модель выполнения кода, но при этом обеспечивает отзывчивость приложения. Event Loop следит за стеком вызовов и очередью событий.

    В основе работы Event Loop лежит концепция очереди задач (Task Queue), в которой различные задачи ожидают своего выполнения. Эти задачи могут быть как макрозадачами (macro-tasks), так и микрозадачами (micro-tasks). Макрозадачи включают в себя события DOM, таймеры (setTimeout, setInterval) и асинхронные операции ввода-вывода, в то время как микрозадачи представляют собой промисы и обратные вызовы, зарегистрированные с помощью методов Promise.then() и process.nextTick().

    JavaScript выполняет операции последовательно, добавляя вызовы функций в стек вызовов (Call Stack) и выполняя их по мере необходимости. Когда макрозадача готова к выполнению, она помещается в стек вызовов и выполняется. Если же макрозадача содержит асинхронную операцию, она переносится в очередь задач для выполнения в будущем.

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

    Пример:

    console.log('Start');
    
    setTimeout(function () {
        console.log('Timeout 1');
    }, 2000);
    
    setTimeout(function () {
        console.log('Timeout 2');
    }, 1000);
    
    console.log('End');

    В этом примере сначала будет выведено "Start", затем "End". Однако функции внутри setTimeout не выполнятся сразу. Они будут добавлены в очередь событий после истечения заданного времени (в данном случае, через 2 и 1 секунду соответственно). После того как стек вызовов освободится, Event Loop добавит функции из очереди событий в стек, и они будут выполнены.

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

      Микрозадачи (microtasks) и макрозадачи (macrotasks) являются частями асинхронной модели выполнения в JavaScript и связаны с механизмами, такими как Event Loop.

      1. Макрозадачи (Macrotasks):

        • Определение: Макрозадачи представляют задачи, которые добавляются в очередь выполнения Event Loop.

        • Использование: Задачи, такие как обработка пользовательского ввода, выполнение скриптов, асинхронные операции I/O (ввода/вывода), таймеры (setTimeout, setInterval), запросы на анимацию и события DOM.

        console.log('Start');
        setTimeout(function () {
        console.log('Timeout (Macrotask)');
        }, 0);
        console.log('End');

        В этом примере функция, переданная в setTimeout, является макрозадачей. Она будет выполнена после выполнения основного кода, даже если таймер установлен на 0 миллисекунд.

      2. Микрозадачи (Microtasks):

        • Определение: Микрозадачи обрабатываются в конце каждой макрозадачи в текущем стеке вызовов.

        • Использование: Промисы (then, catch, finally), оператор async/await.

        console.log('Start');
        Promise.resolve().then(function () {
        console.log('Promise (Microtask)');
        });
        console.log('End');

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

      Порядок выполнения:

      1. Выполняется основной код.

      2. Выполняются микрозадачи (если они есть) из текущего стека вызовов.

      3. Выполняется макрозадача (первая из очереди).

      4. Повторение шагов 2-3 до тех пор, пока очередь макрозадач не опустеет.

    Промисы

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

    Основные свойства и методы промисов

    1. new Promise(executor): Создает новый объект-промис. executor - это функция, которая принимает два аргумента: функцию resolve и функцию reject. Они используются для завершения промиса успешно (resolve) или с ошибкой (reject).

      let promise = new Promise((resolve, reject) => {
          // асинхронная операция
          let success = true;
      
          if (success) {
              resolve('Успех!');
          } else {
              reject('Ошибка!');
          }
      });
    2. promise.then(onFulfilled, onRejected): Метод then добавляет обработчики для успешного завершения (onFulfilled) или ошибки (onRejected). Каждый из них является функцией, которая принимает результат или ошибку соответственно.

      promise.then(
          result => console.log(result),
          error => console.error(error)
      );
    3. promise.catch(onRejected): Метод catch используется для обработки ошибок, аналогично второму аргументу в then.

      promise.catch(error => console.error(error));
    4. Promise.all(iterable): Возвращает промис, который выполняется, когда все промисы в переданном массиве или итерируемом объекте завершаются, или отклоняется с первой ошибкой.

      let promise1 = Promise.resolve(1);
      let promise2 = new Promise(resolve => setTimeout(() => resolve(2), 1000));
      let promise3 = Promise.reject('Ошибка');
      
      Promise.all([promise1, promise2])
          .then(values => console.log(values))
          .catch(error => console.error(error)); // будет вызвано, если один из промисов отклонится
    5. Promise.race(iterable): Возвращает промис, который выполняется или отклоняется в соответствии с тем, как завершится первый промис в переданном массиве или итерируемом объекте.

      let promise1 = new Promise(resolve => setTimeout(() => resolve('Winner'), 1000));
      let promise2 = new Promise(resolve => setTimeout(() => resolve('Loser'), 2000));
      
      Promise.race([promise1, promise2])
          .then(winner => console.log(winner)) // 'Winner'
          .catch(error => console.error(error));

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

    function fetchData(url) {
        return new Promise((resolve, reject) => {
            fetch(url)
                .then(response => response.json())
                .then(data => resolve(data))
                .catch(error => reject(error));
        });
    }
    
    fetchData('https://api.example.com/data')
        .then(data => console.log('Data loaded successfully:', data))
        .catch(error => console.error('Error loading data:', error));

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

    Fetch API

    Fetch API - это интерфейс для отправки и получения HTTP-запросов. Он предоставляет более гибкий и мощный способ работы с сетевыми запросами в сравнении с устаревшим XMLHttpRequest. Fetch API основан на промисах, что делает его удобным для асинхронного программирования.

    Основные особенности Fetch API:

    1. Простота использования. Fetch API предоставляет простой и легкий в использовании синтаксис, основанный на промисах. Это делает код более читаемым и понятным.

      fetch('https://api.example.com/data')
          .then(response => response.json())
          .then(data => console.log(data))
          .catch(error => console.error(error));
    2. Поддержка заголовков и методов. Fetch API позволяет легко управлять заголовками запросов и методами HTTP.

      fetch('https://api.example.com/data', {
          method: 'POST',
          headers: {
              'Content-Type': 'application/json',
              'Authorization': 'Bearer token123'
          },
          body: JSON.stringify({ key: 'value' })
      })
      .then(response => response.json())
      .then(data => console.log(data))
      .catch(error => console.error(error));
    3. Потоки (Streams). Fetch API поддерживает потоковую передачу данных, что полезно при работе с большими объемами данных, такими как загрузка файлов или потоковое чтение.

    4. Метод Response: Объект Response, возвращаемый методом fetch, предоставляет множество методов для работы с ответами, такие как json(), text(), blob(), и другие.

      fetch('https://api.example.com/data')
          .then(response => {
              if (!response.ok) {
                  throw new Error('Network response was not ok');
              }
              return response.json();
          })
          .then(data => console.log(data))
          .catch(error => console.error('Fetch error:', error));
    5. Отмена запросов: В Fetch API нет встроенной поддержки отмены запросов, но можно использовать сторонние библиотеки или создать свой собственный механизм отмены на основе контроля жизненного цикла компонента (в случае веб-приложений).

    Пример использования Fetch API

    // Отправка GET-запроса
    fetch('https://api.example.com/data')
        .then(response => response.json())
        .then(data => console.log('Data loaded successfully:', data))
        .catch(error => console.error('Error loading data:', error));
    
    // Отправка POST-запроса с данными
    fetch('https://api.example.com/submit', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer token123'
        },
        body: JSON.stringify({ key: 'value' })
    })
    .then(response => response.json())
    .then(data => console.log('Data submitted successfully:', data))
    .catch(error => console.error('Error submitting data:', error));

    Этот пример демонстрирует отправку GET- и POST-запросов с использованием Fetch API для загрузки и отправки данных на сервер.

    Синтаксический сахар – Async и Await

    Синтаксический сахар в программировании представляет собой удобный и более читаемый синтаксис для выполнения определенных операций. В контексте JavaScript, ключевые слова async и await предоставляют синтаксический сахар для работы с промисами, что делает код более лаконичным и легким для понимания.

    async

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

    Пример без async:

    function fetchData() {
        return new Promise(resolve => {
            setTimeout(() => {
                resolve('Data loaded successfully');
            }, 2000);
        });
    }

    Пример с async:

    async function fetchData() {
        return new Promise(resolve => {
            setTimeout(() => {
                resolve('Data loaded successfully');
            }, 2000);
        });
    }

    await

    Ключевое слово await используется внутри функции, объявленной с использованием async, для ожидания выполнения промиса. Оно приостанавливает выполнение функции до тех пор, пока промис не разрешится или не отклонится.

    Пример без await:

    function fetchData() {
        return new Promise(resolve => {
            setTimeout(() => {
                resolve('Data loaded successfully');
            }, 2000);
        });
    }
    
    function processData() {
        fetchData().then(data => {
            console.log(data);
        });
    }
    
    processData();

    Пример с await:

    async function fetchData() {
        return new Promise(resolve => {
            setTimeout(() => {
                resolve('Data loaded successfully');
            }, 2000);
        });
    }
    
    async function processData() {
        const data = await fetchData();
        console.log(data);
    }
    
    processData();

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

    Пример совместного использования async и await:

    function fetchData() {
        return fetch('https://api.example.com/data')
            .then(response => {
                if (!response.ok) {
                    throw new Error('Failed to fetch data');
                }
                return response.json();
            });
    }
    
    async function processData() {
        try {
            const data = await fetchData();
            console.log('Data loaded successfully:', data);
        } catch (error) {
            console.error('Error:', error.message);
        }
    }
    
    processData();

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

Заключение

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

Во-первых, мы изучили Event Loop, который играет важную роль в управлении порядком выполнения асинхронного кода в JavaScript. Event Loop обеспечивает непрерывное выполнение кода, обрабатывая события и вызовы колбэков.

Далее мы изучили промисы и синтаксический сахар async/await, которые делают работу с асинхронным кодом более удобной и понятной. Промисы позволяют элегантно обрабатывать асинхронные операции, а async/await добавляет удобство и ясность при написании кода.

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

С наилучшими пожеланиями и удачи в ваших будущих разработках! 🚀🌟👩‍💻👨‍💻

Tags:
Hubs:
Total votes 14: ↑10 and ↓4+13
Comments15

Articles