Асинхронность и промисы
Статья большой получилась из-за очень большого числа примеров кода. Не нужно страшится её размеров. Дочитать до конца серьёзного труда не составит.
В современном JavaScript разработка часто сталкивается с необходимостью выполнения асинхронных операций. Это могут быть запросы к серверу, работа с файлами, тайм-ауты и другие задачи, которые требуют времени для завершения. В этой статье мы рассмотрим три ключевых аспекта работы с асинхронностью в JavaScript:
- Колбеки и их недостатки.
- Промисы, включая создание и работу с цепочками промисов.
- Использование конструкции async/await для упрощения работы с асинхронным кодом.
Колбеки и проблемы с ними
Колбек (callback) – это функция, которая передается другой функции в качестве аргумента и выполняется после того, как завершится некоторая операция. Колбеки позволяют выполнять асинхронный код последовательно, но при этом они имеют ряд недостатков.
Пример использования колбека:
setTimeout(() => {
console.log('Привет через 1 секунду!');
}, 1000);
Здесь функция console.log() является колбеком, который будет выполнен спустя одну секунду.
Проблемы с использованием колбеков
Callback Hell («ад колбеков»): Когда несколько асинхронных операций выполняются одна за другой, код становится трудно читаемым и сложно поддерживаемым. Вот пример callback hell:
fetchData((data) => {
processData(data, (processedData) => {
saveToDatabase(processedData, (result) => {
notifyUser(result, () => {
// ещё один уровень вложенности...
});
});
});
});
Такой код быстро превращается в запутанный лабиринт вложенных функций, что затрудняет его понимание и отладку.
Отсутствие стандартного механизма обработки ошибок: При использовании колбеков ошибки обрабатываются вручную, что может привести к пропускам ошибок и усложнению кода.
Трудности с управлением потоками выполнения: Сложно управлять порядком выполнения нескольких асинхронных задач одновременно.
Чтобы решить эти проблемы, были введены промисы.
Промисы: создание, цепочки промисов
Промис (promise) – это объект, представляющий результат асинхронной операции, который может быть доступен в будущем. Он имеет три состояния:
- Pending (ожидание) – начальное состояние, когда выполнение операции еще не завершено.
- Fulfilled (выполнено) – успешное завершение операции.
- Rejected (отклонено) – ошибка при выполнении операции.
Пример создания простого промиса:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Данные получены');
}, 2000);
});
promise.then((message) => {
console.log(message); // Выведет 'Данные получены' через 2 секунды
}).catch((error) => {
console.error(error);
});
Преимущества промисов перед колбеками
Упрощение структуры кода: Промисы помогают избежать callback hell благодаря возможности построения цепочек вызовов.
fetchData()
.then(processData)
.then(saveToDatabase)
.then(notifyUser)
.catch(handleError);
Стандартная обработка ошибок: Ошибки автоматически перехватываются и обрабатываются блоком .catch().
Управление несколькими асинхронными операциями: Можно легко контролировать порядок выполнения нескольких промисов с помощью методов Promise.all, Promise.race и других.
Цепочка промисов
Цепочка промисов позволяет последовательно выполнять несколько асинхронных операций. Каждый метод .then() возвращает новый промис, что позволяет строить сложные последовательности действий.
Пример цепочки промисов:
fetchData()
.then((data) => {
return processData(data);
})
.then((processedData) => {
return saveToDatabase(processedData);
})
.then((result) => {
return notifyUser(result);
})
.catch((error) => {
handleError(error);
});
Полный код, который выдаёт результат на консоли, следующий:
// Функция для имитации получения данных
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ message: 'Данные успешно получены' }); // Эмулируем успешное получение данных
}, 1000);
});
}
// Функция для обработки полученных данных
function processData(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const processedData = { processedMessage: `${data.message} и обработаны` };
resolve(processedData); // Исправлена опечатка
}, 1500);
});
}
// Функция для сохранения данных в базу данных
function saveToDatabase(processedData) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const result = { saved: true, message: processedData.processedMessage };
resolve(result); // Возвращаем результат сохранения
}, 2000);
});
}
// Функция для уведомления пользователя
function notifyUser(result) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`Пользователь уведомлён: ${result.message}`);
resolve(); // Завершаем процесс
}, 2500);
});
}
// Обработка ошибок
function handleError(error) {
console.error('Произошла ошибка:', error);
}
// Основная логика
fetchData()
.then((data) => {
console.log('Получены данные:', data);
return processData(data);
})
.then((processedData) => {
console.log('Обработанные данные:', processedData);
return saveToDatabase(processedData);
})
.then((result) => {
console.log('Результат сохранения в базу данных:', result);
return notifyUser(result);
})
.then(() => {
console.log('Все задачи выполнены!');
})
.catch((error) => {
handleError(error);
});
В нём мы имитировали получение данных, их обработку, сохранение в базе данных и уведомление пользователя. Все эти шаги выполнялись асинхронно через промисы.
Объяснение кода
Функция fetchData. Она имитирует асинхронную операцию получения данных. Через одну секунду она разрешает промис с объектом { message: ‘Данные успешно получены’ }.
Функция processData. Она обрабатывает полученные данные. Она добавляет строку » и обработаны» к сообщению и возвращает новый объект через полторы секунды.
Функция saveToDatabase. Она имитирует сохранение данных в базу данных. Она добавляет свойство saved: true и возвращает результат через две секунды.
Функция notifyUser. Она уведомляет пользователя о результате. Она выводит сообщение в консоль через две с половиной секунды.
Основная логика. Здесь вызывается цепочка промисов, начиная с вызова fetchData(). Каждый шаг завершается разрешением следующего промиса, и на каждом этапе выводятся сообщения в консоль.
Обработка ошибок. Если на каком-то этапе возникает ошибка, она перехватывается блоком .catch() и передается в функцию handleError, которая выводит информацию об ошибке в консоль.
Параллельное выполнение промисов
Иногда нужно запустить несколько асинхронных операций параллельно и дождаться их завершения. Для этого используется метод Promise.all.
const promises = [
fetchData(),
fetchAdditionalData(),
fetchMoreData()
];
Promise.all(promises).then((results) => {
const [data, additionalData, moreData] = results;
console.log('Все данные получены:', data, additionalData, moreData);
}).catch((error) => {
console.error('Ошибка при получении данных:', error);
});
Метод Promise.all принимает массив промисов и возвращает новый промис, который выполнится тогда, когда все исходные промисы будут выполнены успешно. Если хотя бы один промис отклонен, то весь результирующий промис также будет отклонен.
Вот полный код программы:
// Функция для имитации получения данных
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ message: 'Основные данные успешно получены' }); // Эмулируем успешное получение данных
}, 1000);
});
}
// Функция для имитации получения дополнительных данных
function fetchAdditionalData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ message: 'Дополнительные данные успешно получены' }); // Эмулируем успешное получение данных
}, 1200);
});
}
// Функция для имитации получения еще больше данных
function fetchMoreData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ message: 'Еще больше данных успешно получено' }); // Эмулируем успешное получение данных
}, 1400);
});
}
// Массив промисов
const promises = [
fetchData(),
fetchAdditionalData(),
fetchMoreData()
];
// Объединение промисов с помощью Promise.all
Promise.all(promises).then((results) => {
const [data, additionalData, moreData] = results;
console.log('Все данные получены:', data, additionalData, moreData);
}).catch((error) => {
console.error('Ошибка при получении данных:', error);
});
Когда вы запустите этот скрипт, сначала начнут выполняться все три функции одновременно. Поскольку они имеют разные задержки, первая функция (fetchData) завершится первой, за ней последует вторая (fetchAdditionalData), а последней завершится третья (fetchMoreData).
Как только все три промиса разрешатся, их результаты будут собраны в массив results, и программа выведет следующее сообщение в консоль:
Все данные получены: { message: 'Основные данные успешно получены' } { message: 'Дополнительные данные успешно получены' { message: 'Еще больше данных успешно получено' }
Пояснения:
Функции fetchData, fetchAdditionalData, fetchMoreData. Они имитируют асинхронные запросы, возвращая промисы. Каждая функция эмулирует задержку (время ожидания разное), а затем разрешает промис с сообщением о получении данных.
Массив promises. Он создаётся из трех промисов, каждый из которых представляет собой вызов одной из вышеописанных функций.
Объединение промисов с помощью Promise.all. Используется метод Promise.all, который ожидает разрешения всех промисов в массиве. Когда все три промиса разрешаются, результаты собираются в массив results.
Разбор результатов. С помощью деструктуризации массива извлекаются отдельные элементы, представляющие результаты каждого промиса, и они выводятся в консоль.
Обработка ошибок. Если хотя бы одна из функций вернет ошибку, вся операция будет прервана, и управление перейдет в блок .catch(), где ошибка будет выведена в консоль.
Код демонстрирует работу с промисами и цепочками вызовов, а также обработку ошибок. Вы можете адаптировать его под свои нужды, заменив имитацию реальных функций на реальные асинхронные операции.
Async/Await
async/await – это синтаксический сахар, позволяющий писать асинхронный код так, будто он синхронный. Эта конструкция делает использование промисов более удобным и интуитивно понятным.
Функция, помеченная ключевым словом async, всегда возвращает промис. Внутри такой функции можно использовать ключевое слово await, чтобы приостановить выполнение до тех пор, пока промис не будет выполнен.
Пример использования async/await:
// Функция для имитации асинхронной операции (например, сетевой запрос)
const f77777etchData = () => {
return new Promise((resolve) => {
setTimeout(() => resolve('Данные получены!'), 2000);
});
};
// Асинхронная функция, которая ожидает завершения f77777etchData()
const m77777ain = async () => {
try {
console.log('Запрашиваем данные...');
const result = await f77777etchData();
console.log(result); // Выведет 'Данные получены!'
} catch (error) {
console.error('Произошла ошибка:', error);
}
};
m77777ain(); // Запускаем выполнение функции
Преимущества async/await
Простота чтения и понимания: Код выглядит как обычный синхронный код, что облегчает его восприятие.
Удобство обработки ошибок: Ошибки можно обрабатывать с помощью стандартных конструкций try/catch.
Поддержка последовательностей: Легко выстраивать последовательность асинхронных операций без необходимости глубоких вложений.
Ограничения async/await
Использование только внутри async-функций: Ключевое слово await может использоваться только внутри функций, объявленных с async.
Ожидание всех промисов: В отличие от Promise.all, где можно ждать завершения всех промисов сразу, await ожидает каждый промис по очереди.
Давайте рассмотрим несколько примеров промисов, которые можно реализовать прямо в консоли браузера и которые выполняют полезные задачи. Я приведу код и подробное объяснение каждого примера.
Пример 1: Таймер с задержкой
Этот пример показывает, как создать промис, который разрешится через определенное количество миллисекунд.
// Создаем промис, который разрешится через 2 секунды
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
delay(2000)
.then(() => {
console.log('Промис завершился через 2 секунды');
})
.catch(error => {
console.error('Что-то пошло не так:', error);
});
Объяснение:
Создание промиса. Функция delay(ms) создает новый промис, который разрешается через указанное количество миллисекунд с помощью setTimeout.
Запуск промиса. Вызывая delay(2000), мы запускаем промис, который завершится через 2 секунды.
Обработка результата. Метод .then() вызывается после разрешения промиса, выводя сообщение в консоль.
Пример 3: Работа с локальным хранилищем
Этот пример демонстрирует, как использовать промисы для взаимодействия с локальным хранилищем браузера.
// Функция для записи данных в localStorage
const setItem = key => value => new Promise((resolve, reject) => {
try {
localStorage.setItem(key, JSON.stringify(value));
resolve({ key, value });
} catch (error) {
reject(error);
}
});
// Функция для чтения данных из localStorage
const getItem = key => new Promise((resolve, reject) => {
try {
const value = localStorage.getItem(key);
resolve(JSON.parse(value));
} catch (error) {
reject(error);
}
});
// Запись данных в localStorage
setItem('user')({ name: 'Иван', age: 30 })
.then(({ key, value }) => {
console.log(`Записали данные в localStorage: ключ "${key}" со значением`, value);
return getItem('user'); // Читаем записанные данные
})
.then(user => {
console.log('Прочитали данные из localStorage:', user);
})
.catch(error => {
console.error('Ошибка при работе с localStorage:', error);
});
Объяснение:
Функции для работы с localStorage. Мы создали две функции-обертки над методами localStorage.setItem и localStorage.getItem, которые возвращают промисы. Эти функции позволяют работать с localStorage асинхронно.
Запись данных. Функция setItem принимает два параметра: ключ и значение. Значение сериализуется в строку с помощью JSON.stringify и сохраняется в localStorage. Если операция прошла успешно, промис разрешается с объектом { key, value }.
Чтение данных. Функция getItem принимает ключ и пытается прочитать соответствующее значение из localStorage. Значение десериализуется обратно в объект с помощью JSON.parse и возвращается в виде разрешения промиса.
Последовательность операций: Сначала мы записываем данные в localStorage, затем читаем их обратно и выводим результат в консоль.
Пример 4: Загрузка изображений
Этот пример показывает, как загрузить изображение с помощью промиса.
Вот HTML-файл, содержащий код для загрузки изображения с использованием промиса:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Загрузка изображения</title>
</head>
<body>
<!-- Контейнер для отображения изображения -->
<div id="image-container"></div>
<script>
// Функция для загрузки изображения
const loadImage = url => new Promise((resolve, reject) => {
const image = new Image();
image.src = url;
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Не удалось загрузить изображение по адресу ${url}`));
});
// Загружаем изображение
loadImage('https://via.placeholder.com/150')
.then(image => {
const container = document.getElementById('image-container');
container.appendChild(image);
console.log('Изображение загружено и добавлено на страницу');
})
.catch(error => {
console.error('Ошибка при загрузке изображения:', error);
});
</script>
</body>
</html>
Объяснение
HTML-структура. В документе есть контейнер с идентификатором image-container, куда будет добавлено загруженное изображение.
JavaScript-код. Функция loadImage создаёт промис, который разрешает промис при успешной загрузке изображения и добавляет его в DOM, либо отказывает с ошибкой, если загрузка не удалась.
URL изображения. Указан URL https://via.placeholder.com/150, который генерирует простое изображение-заглушку размером 150×150 пикселей. Вы можете заменить его на любой другой URL изображения.
Как использовать
Скопируйте содержимое данного кода в текстовый редактор.
Сохраните файл с расширением .html, например, image-loader.html.
Откройте этот файл в любом современном браузере.
Вы увидите, что изображение успешно загрузилось и появилось на странице.