Глубокий взгляд на асинхронность в Java Script: роль Event Loop, Event Bus, промисов и async/await

7653b67b6119963f7fe8fcee7065e10c.png

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

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 следит за стеком вызовов и очередью событий.

Пример:

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 добавит функции из очереди событий в стек, и они будут выполнены.

Event Bus (Шина событий)

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

Пример:

// Пример простого Event Bus на основе событий в Node.js

const EventEmitter = require('events');

// Создаем экземпляр Event Bus
const eventBus = new EventEmitter();

// Компонент 1: слушает событие 'message'
eventBus.on('message', (data) => {
    console.log('Component 1 received message:', data);
});

// Компонент 2: слушает событие 'message'
eventBus.on('message', (data) => {
    console.log('Component 2 received message:', data);
});

// Компонент 3: отправляет событие 'message'
function sendMessage() {
    eventBus.emit('message', 'Hello, Event Bus!');
}

// Вызываем функцию отправки сообщения
sendMessage();

В этом примере компоненты 1 и 2 слушают событие 'message', а компонент 3 отправляет это событие. Когда sendMessage вызывается, оба компонента 1 и 2 получат уведомление и выведут сообщение в консоль. Event Bus позволяет удобно организовывать взаимодействие между компонентами, даже если они находятся в разных частях приложения.

Например, в Vue.js 2 нет явного Event Bus, но часто используется глобальный экземпляр Vue в качестве примитивного Event Bus.





    
    
    Vue 2 Event Bus Example


    
// main.js
Vue.component('child-component', {
    template: '
', methods: { sendMessage() { this.$root.$emit('message', 'Hello from Child Component'); } } }); Vue.component('another-child-component', { template: '
Another Child Component
', created() { this.$root.$on('message', (data) => { console.log('Another Child Component received message:', data); }); } }); new Vue({ el: '#app', });

В этом примере создается глобальный экземпляр Vue (new Vue) в корневом элементе приложения. Компонент child-component содержит кнопку, которая при нажатии отправляет сообщение через $emit на глобальный Event Bus (this.$root). Компонент another-child-component слушает это событие через $on при создании.

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

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

Микрозадачи (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:

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

async function processData() {
    try {
        const data = await fetchData();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

processData();

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

© Habrahabr.ru