Материал этой статьи во многом вдохновлён книгой Mixu про Node.js.
По своей сути JavaScript спроектирован так, чтобы не блокировать «главный» поток — тот, в котором рендерятся представления (views). Можно представить, насколько это важно в браузере. Когда главный поток блокируется, возникает пресловутое «зависание», которого так боятся конечные пользователи, и никакие другие события не могут быть обработаны, что приводит, например, к потере получаемых данных.
Это создаёт ряд уникальных ограничений, которые лечит только функциональный стиль программирования. Вот тут-то на сцену и выходят колбэки.
Однако колбэки могут стать трудноуправляемыми в более сложных процедурах. Часто это выливается в «callback hell» (ад колбэков), когда множество вложенных функций с колбэками делают код труднее для чтения, отладки, организации и т. д.
async1(function (input, result1) {
async2(function (result2) {
async3(function (result3) {
async4(function (result4) {
async5(function (output) {
// делаем что-то с output
});
});
});
});
});Разумеется, в реальной жизни, скорее всего, были бы дополнительные строки кода для обработки result1, result2 и т. д., поэтому из-за длины и сложности этой проблемы код обычно выглядит гораздо более запутанным, чем в примере выше.
Вот где функции оказываются очень кстати. Более сложные операции складываются из множества функций:
- инициатор / вход (initiator style / input)
- middleware (промежуточное звено)
- терминатор (terminator)
«Инициатор / вход» — это первая функция в последовательности. Эта функция принимает исходный вход, если он есть, для операции. Операция — это исполняемая серия функций, а исходным входом в основном будут:
- переменные в глобальном окружении
- прямой вызов с аргументами или без них
- значения, полученные из файловой системы или сетевых запросов
Сетевые запросы могут быть входящими запросами, инициированными внешней сетью, другим приложением в той же сети или самим приложением в той же или внешней сети.
Функция middleware возвращает другую функцию, а функция-терминатор вызывает колбэк. Ниже показан поток для сетевых запросов или запросов к файловой системе. Здесь задержка равна 0, потому что все эти значения доступны в памяти.
function final(someInput, callback) {
callback(`${someInput} and terminated by executing callback `);
}
function middleware(someInput, callback) {
return final(`${someInput} touched by middleware `, callback);
}
function initiate() {
const someInput = 'hello this is a function ';
middleware(someInput, function (result) {
console.log(result);
// требует, чтобы колбэк `вернул` результат
});
}
initiate();Функции могут зависеть или не зависеть от состояния. Зависимость от состояния возникает, когда вход или другая переменная функции опирается на внешнюю функцию.
Таким образом, есть две основные стратегии управления состоянием:
- передача переменных напрямую в функцию и
- получение значения переменной из кэша, сессии, файла, базы данных, сети или другого внешнего источника.
Обратите внимание, я не упомянул глобальные переменные. Управление состоянием через глобальные переменные — часто неряшливый антипаттерн, который делает трудным или невозможным гарантировать состояние. Глобальных переменных в сложных программах по возможности следует избегать.
Если объект доступен в памяти, итерация возможна, и изменения в управлении потоком не будет:
function getSong() {
let _song = '';
let i = 100;
for (i; i > 0; i -= 1) {
_song += `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
if (i === 1) {
_song += "Hey let's get some more beer";
}
}
return _song;
}
function singSong(_song) {
if (!_song) {
throw new Error("song is '' empty, FEED ME A SONG!");
}
console.log(_song);
}
const song = getSong();
// это сработает
singSong(song);Однако если данные существуют вне памяти, итерация больше не сработает:
function getSong() {
let _song = '';
let i = 100;
for (i; i > 0; i -= 1) {
setTimeout(function () {
_song += `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
if (i === 1) {
_song += "Hey let's get some more beer";
}
}, 0);
}
return _song;
}
function singSong(_song) {
if (!_song) {
throw new Error("song is '' empty, FEED ME A SONG!");
}
console.log(_song);
}
const song = getSong('beer');
// это не сработает
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!Почему так произошло? setTimeout инструктирует CPU сохранить инструкции где-то ещё на шине и указывает, что данные запланированы к «забору» позже. Проходят тысячи тактов процессора, прежде чем функция снова срабатывает на отметке 0 миллисекунд, CPU достаёт инструкции с шины и выполняет их. Единственная проблема в том, что song ('') был возвращён тысячами тактов ранее.
Та же ситуация возникает при работе с файловыми системами и сетевыми запросами. Главный поток попросту нельзя блокировать на неопределённый период времени — поэтому мы используем колбэки, чтобы контролируемо планировать выполнение кода во времени.
Почти все свои операции вы сможете выполнить с помощью следующих 3 паттернов:
- Последовательно (in series): функции выполняются в строгом последовательном порядке; этот паттерн больше всего похож на циклы
for.
// операции определены где-то ещё и готовы к выполнению
const operations = [
{ func: function1, args: args1 },
{ func: function2, args: args2 },
{ func: function3, args: args3 },
];
function executeFunctionWithArgs(operation, callback) {
// выполняет функцию
const { args, func } = operation;
func(args, callback);
}
function serialProcedure(operation) {
if (!operation) {
process.exit(0); // завершено
}
executeFunctionWithArgs(operation, function (result) {
// продолжаем ПОСЛЕ колбэка
serialProcedure(operations.shift());
});
}
serialProcedure(operations.shift());- Последовательно с ограничением (limited in series): функции выполняются в строгом последовательном порядке, но с ограничением на число выполнений. Полезно, когда нужно обработать большой список, но с потолком на число успешно обработанных элементов.
let successCount = 0;
function final() {
console.log(`dispatched ${successCount} emails`);
console.log('finished');
}
function dispatch(recipient, callback) {
// `sendMail` — гипотетический SMTP-клиент
sendMail(
{
subject: 'Dinner tonight',
message: 'We have lots of cabbage on the plate. You coming?',
smtp: recipient.email,
},
callback
);
}
function sendOneMillionEmailsOnly() {
getListOfTenMillionGreatEmails(function (err, bigList) {
if (err) {
throw err;
}
function serial(recipient) {
if (!recipient || successCount >= 1000000) {
return final();
}
dispatch(recipient, function (_err) {
if (!_err) {
successCount += 1;
}
serial(bigList.pop());
});
}
serial(bigList.pop());
});
}
sendOneMillionEmailsOnly();- Полностью параллельно (full parallel): когда порядок не важен, например при рассылке письма списку из 1 000 000 получателей.
let count = 0;
let success = 0;
const failed = [];
const recipients = [
{ name: 'Bart', email: 'bart@tld' },
{ name: 'Marge', email: 'marge@tld' },
{ name: 'Homer', email: 'homer@tld' },
{ name: 'Lisa', email: 'lisa@tld' },
{ name: 'Maggie', email: 'maggie@tld' },
];
function dispatch(recipient, callback) {
// `sendMail` — гипотетический SMTP-клиент
sendMail(
{
subject: 'Dinner tonight',
message: 'We have lots of cabbage on the plate. You coming?',
smtp: recipient.email,
},
callback
);
}
function final(result) {
console.log(`Result: ${result.count} attempts \
& ${result.success} succeeded emails`);
if (result.failed.length) {
console.log(`Failed to send to: \
\n${result.failed.join('\n')}\n`);
}
}
recipients.forEach(function (recipient) {
dispatch(recipient, function (err) {
if (!err) {
success += 1;
} else {
failed.push(recipient.name);
}
count += 1;
if (count === recipients.length) {
final({
count,
success,
failed,
});
}
});
});У каждого паттерна свои сценарии использования, преимущества и проблемы, которые вы можете поэкспериментировать и изучить подробнее. Самое главное — не забывайте модуляризировать свои операции и использовать колбэки! Если сомневаетесь, относитесь ко всему так, будто это middleware!