И у начинающих разработчиков, и у тех, кто переходит в язык JavaScript из других ЯП, осознание и использование callback-функций обычно вызывает трудности, потому что в большинстве языков асинхронное программирование строго контролируется разработчиком, в то время как в JS асинхронный код можно создать просто по незнанию особенностей встроенной функции. Callback, механизм обратных вызовов, как раз и создан для «правильного» написания асинхронного кода, и ниже мы расскажем, как это работает.
Для начала опишем проблему, из-за которой функции обратного вызова вообще нужны. Если изучать какой-нибудь «серверный» язык программирования, то вопрос обработки асинхронных действий вообще не будет проблемой, пока вы сами не начнете использовать асинхронные операции – и код, и все зависимости, и результат исполнения обычно лежат в одном месте, поэтому синхронного кода более чем достаточно, а разработчики используют методы асинхронного программирования в основном для оптимального использования ресурсов железа.
С JS такой фокус не пройдет, потому что во время написания кода практически всегда нужно обращаться к сторонним ресурсам – API собственного сервера и API сторонних приложений. А API мало того, что требует некоторого времени на обработку запроса, так еще и непонятно, что эта API вернет – нужный результат или ошибку 404. Поэтому многие встроенные функции JS работают как асинхронные запросы – они выполняются не последовательно (строка за строкой кода), а тогда, когда это будет необходимо (event-driven модель языка).
Приведем 2 коротких примера. Синхронный код:
console.log('Шаг 1');
console.log('Шаг 2');
console.log('Шаг 3');
Асинхронный код:
console.log('Шаг 1');
setTimeout(() => {
console.log('Шаг 2');
}, 2000);
console.log('Шаг 3');
setTimeout – асинхронный метод, поэтому «Шаг 2» выведется после «Шаг 3» –интерпретатор исполнит метод после того, как пройдет время, указанное в setTimeout.
Итак, встроенная в JS асинхронность является жизненной необходимостью, потому что без нее скрипты на пользовательской стороне будут намертво зависать при каждом обращении скрипта к API (JavaScript – однопоточный язык). Но эта асинхронность создает проблему: мы не можем быть уверены в том, что объект, которым мы манипулируем, вообще существует. Например: у нас есть приложение, которое подтягивает информацию о пользователе с сервера и на основе этой информации модифицирует интерфейс; запрос информации работает асинхронно, модификация интерфейса работает синхронно. Учитывая, что железо клиента работает быстрее, чем интернет-соединение между клиентом и пользователем, нам придется сколько-то ждать, пока не придет информация – но сколько именно? Секунда, 5 секунд? Именно для того, чтобы решить эту проблему, используют callback-функции.
В JavaScript функции являются одновременно функциями первого класса и высшего порядка – то есть их можно использовать в качестве объектов и передавать в другие функции. Механика callback на этом и построена: мы передаем функцию А в функцию Б для того, чтобы впоследствии вызвать функцию Б внутри функции А – это гарантирует то, что сначала исполнится функция А, а потом исполнится функция Б. Пример:
function greeting(name) {
console.log('Hello ' + name);
}
function processUserInput(callback) {
const name = prompt('Please enter your name.');
callback(name);
}
processUserInput(greeting);
Здесь мы создаем 2 функции: greeting, которая принимает в качестве аргумента имя пользователя name, и processUserInput, которая принимает в качестве аргумента функцию callback. Затем мы вызываем processUserInput, которому передаем функцию greeting. ProcessUserInput исполняется, создает константу с именем и внутри себя вызывает переданную функцию greeting, которой передает константу с именем.
Пример выше – синхронный callback, то есть функции все еще выполняются последовательно, мы просто поместили одну в другую. Это может быть полезно, если мы хотим «причесать» код или работаем с методом, который ожидает колбэк, но куда более полезны асинхронные колбэки – они решают проблему, которую мы описывали в предыдущем разделе:
function fetchData(callback) {
setTimeout(() => {
const data = { id: 1, title: 'Sample Data' };
callback(data);
}, 2000);
}
function handleData(data) {
console.log('Data received:', data);
}
fetchData(handleData);
Здесь мы имитируем асинхронную функцию через setTimeout. У нас есть fetch, и у нас есть функция, которая обрабатывает данные, пришедшие от fetch – будет затруднительно обработать данные, которые еще не пришли, верно? Поэтому мы передаем нашей функции fetchData функцию handleData в качестве аргумента, чтобы гарантированно вызвать handleData после того, как данные придут. И fetchData, и handleData будут выведены из потока исполнения до момента прихода данных, то есть нам не придется останавливать все приложение.
Часто callback-функции используют для обработки ошибок. Пример:
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = () => callback(null, script);
script.onerror = () => callback(new Error(`Ошибка загрузки скрипта ${src}`));
document.head.append(script);
}
function handleScriptLoad(error, script) {
if (error) {
console.error('Ошибка:', error.message);
} else {
console.log('Скрипт загружен успешно:', script.src);
}
}
// Использование функции loadScript
loadScript('https://example.com/some-script.js', handleScriptLoad);
Здесь мы создаем отдельную функцию handleScriptLoad, которая обрабатывает 2 сценария: правильная загрузка скрипта и ошибка во время загрузки скрипта. Затем мы передаем эту функцию в качестве аргумента загрузчику, который вызывает handleScriptError как при успехе (с параметрами, верифицирующими успех), так и при ошибке (с новой ошибкой). Мы можем повесить этот скрипт обработки ошибок на все загрузчики, если впоследствии мы захотим его модифицировать – это нужно будет сделать только в одном месте, в самом скрипте.
Многие события в браузере обрабатываются синхронно, что логично – пользователь может нажать на кнопку, а может и не нажать. Здесь нам пригодятся синхронные колбэки – на каждое событие мы вешаем event listener, который будет исполнять действие при наступлении события:
document.getElementById('myButton').addEventListener('click', function() {
console.log('Button was clicked!');
});
Заметим, что для обработки событий часто создают анонимные callback-функции.
Выше мы уже приводили примеры с setTimeout – они могут попасться и на реальных проектах, частый кейс: всплывающие окна. Работает все просто:
setTimeout(function() {
console.log('This message is displayed after 2 seconds');
}, 2000);
setInterval(function() {
console.log('This message is displayed every 3 seconds');
}, 3000);
В скрипт добавляется таймер, который через некоторое время (или через определенные интервалы времени) исполнит некоторый код.
Посмотрите на код:
getUser(userId, function(err, user) {
if (err) {
console.error('Ошибка получения пользователя:', err);
} else {
getTasks(user.id, function(err, tasks) {
if (err) {
console.error('Ошибка получения задач:', err);
} else {
updateTaskStatus(tasks[0].id, 'completed', function(err, result) {
if (err) {
console.error('Ошибка обновления задачи:', err);
} else {
sendNotification(user.email, 'Задача обновлена', function(err, response) {
if (err) {
console.error('Ошибка отправки уведомления:', err);
} else {
console.log('Уведомление отправлено:', response);
}
});
}
});
}
});
}
});
Он работает и выполняет свои задачи, но есть проблема: его очень сложно читать. Так получилось, потому что у нас в одном модуле собрались 4 действия, которые нужно сделать последовательно – взять данные пользователя, получить его список задач, обновить статус первой задачи и выслать нотификацию. На каждом этапе нам дополнительно нужно проверить, не возникла ли ошибка – итого 8 последовательных действий, объединенных в цепочку колбэков. Это и есть callback hell – ситуация, когда мы создаем длинную сложночитаемую цепочку колбэков.
Простого решения проблемы callback hell не существует – код придется рефакторить. Вот какие рекомендации существуют:
Если же обычные колбэки – вообще не вариант, то есть более продвинутые инструменты языка – промисы и async/await. Обсуждение обоих инструментов выходит далеко за рамки материала, но для примера – вот как выглядит код выше, если переписать его с использованием promises:
getUser(userId)
.then(user => getTasks(user.id))
.then(tasks => updateTaskStatus(tasks[0].id, 'completed'))
.then(result => sendNotification(user.email, 'Задача обновлена'))
.then(response => console.log('Уведомление отправлено:', response))
.catch(err => console.error('Ошибка:', err));