JavaScript: Спасение из ада обратных вызовов

Источник: «Saved from Callback Hell»
Ад обратного вызова реален. Разработчики часто рассматривают обратные вызовы как чистое зло, вплоть до того, что избегают их. Гибкость JavaScript совсем не помогает в этом. Но не обязательно избегать обратных вызовов. Хорошая новость в том, что есть простые шаги спасения от ада обратных вызовов.

Устранение обратных вызовов в коде похоже на ампутацию здоровой ноги. Функция обратного вызова — один из столпов JavaScript и одна из его хороших частей. Когда вы заменяете обратные вызовы, вы часто просто меняете местами проблемы.

Некоторые заявляют, что обратные вызовы уродливы бородавки, являющиеся причиной для изучения лучших языков. Но так ли уродливы обратные вызовы?

Использование обратных вызовов имеет преимущества. Нет причини избегать JavaScript, потому что обратные вызовы могут превратиться в уродливые бородавки. Можно сделать так, чтобы этого не произошло.

Давайте разберёмся, как можно решить проблему с обратными вызовами. Мы предпочитаем придерживаться принципов SOLID и посмотрим, куда это нас приведёт.

Что такое ад обратных вызовов / callback hell

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

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

function receiver(fn) {
return fn();
}

function callback() {
return 'foobar';
}

var callbackResponse = receiver(callback);
// callbackResponse == 'foobar'

Если вы писали Ajax запросы, вы сталкивались с функциями обратного вызова. Асинхронный код использует этот подход, поскольку нет гарантии, когда будет выполнен обратный вызов.

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

Репозиторий доступен на GitHub и большинство фрагментов кода будут взяты оттуда.

setTimeout(function (name) {
var catList = name + ',';

setTimeout(function (name) {
catList += name + ',';

setTimeout(function (name) {
catList += name + ',';

setTimeout(function (name) {
catList += name + ',';

setTimeout(function (name) {
catList += name;

console.log(catList);
}, 1, 'Lion');
}, 1, 'Snow Leopard');
}, 1, 'Lynx');
}, 1, 'Jaguar');
}, 1, 'Panther');

В этом примере кода, setTimeout получает функцию обратного вызова, которая выполняется через одну миллисекунду. Последний переданный параметр просто передаёт обратному вызову данные. Это похоже на вызов Ajax, за исключением того, что возвращаемый параметр name будет поступать с сервера.

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

Анонимные функции

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

Использование анонимных функций не рекомендуется некоторыми стандартами программирования. Лучше задать имя, поэтому используйте function getCat(name){} вместо function (name){}. Включение имени в функцию добавляет ясности программе. Эти анонимные функции легко ввести, но они заставят вас мчаться по шоссе в ад. Когда вы обнаружите, что идёте по извилистой дороге вложений, лучше остановиться и переосмыслить.

Один из наивных способов разбить путаницу обратных вызовов — использовать объявление функций:

setTimeout(getPanther, 1, 'Panther');

var catList = '';

function getPanther(name) {
catList = name + ',';

setTimeout(getJaguar, 1, 'Jaguar');
}

function getJaguar(name) {
catList += name + ',';

setTimeout(getLynx, 1, 'Lynx');
}

function getLynx(name) {
catList += name + ',';

setTimeout(getSnowLeopard, 1, 'Snow Leopard');
}

function getSnowLeopard(name) {
catList += name + ',';

setTimeout(getLion, 1, 'Lion');
}

function getLion(name) {
catList += name;

console.log(catList);
}

Каждая функция получает собственное объявление. Одним из плюсов — больше нет ужасной пирамиды. Каждая функция изолируется и фокусируется на конкретной задаче. У каждой функции теперь есть одна причина для изменения, так что это шаг в правильном направлении. Обратите внимание, что getPanther() присваивается параметру. JavaScript не волнует, как мы создаём обратные вызовы. Но в чём недостатки?

Недостаток в том, что каждое объявление функции больше не попадает в область обратного вызова. Вместо использования обратных вызовов в качестве замыкания, теперь каждая функция приклеивается к внешней области видимости. Следовательно, поэтому catList объявляется во внешней области, поскольку так обратные вызовы получат доступ к списку. Иногда привязывание к глобальной области видимости не является идеальным решением. Также встречается постоянное дублирование кода, так как добавляется имя кошки в список и вызывается следующий обратный вызов.

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

Инверсия зависимости

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

Этот принцип SOLID гласит:

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

Так что же означает этот кусок текста? Хорошая новость заключается в том, что назначая обратный вызов параметру, мы уже делаем это! По крайней мере частично, чтобы отделиться, думайте об обратных вызовах, как о зависимостях. Эта зависимость становится контрактом. С этого момента мы занимаемся SOLID программированием.

Один из способов получить свободу обратного вызова — создать контракт:

fn(catList);

Он определяет, что мы планируем делать с обратным вызовом. Он должен отслеживать один параметр — список свирепых кошек.

Теперь эту зависимость можно передать через параметр:

function buildFerociousCats(list, returnValue, fn) {
setTimeout(function asyncCall(data) {
var catList = list === '' ? data : list + ',' + data;

fn(catList);
}, 1, returnValue);
}

Обратите внимание, что выражение функции asyncCall ограничено замыканием buildFerociousCats. Этот метод эффективен в сочетании с обратными вызовами в асинхронном программировании. Контракт выполняется асинхронно и получает необходимые data, и всё это с помощью надёжного программирования. Контракт получает необходимую ему свободу, поскольку он отделяется от реализации. Красивый код использует гибкость JavaScript в своих интересах.

Остальное, что должно произойти, становится очевидным. Мы можем сделать так:

buildFerociousCats('', 'Panther', getJaguar);

function getJaguar(list) {
buildFerociousCats(list, 'Jaguar', getLynx);
}

function getLynx(list) {
buildFerociousCats(list, 'Lynx', getSnowLeopard);
}

function getSnowLeopard(list) {
buildFerociousCats(list, 'Snow Leopard', getLion);
}

function getLion(list) {
buildFerociousCats(list, 'Lion', printList);
}

function printList(list) {
console.log(list);
}

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

Полиморфные обратные вызовы

Ладно, давайте немного побезумствуем. Что, если бы мы захотели изменить поведение с созданием списка, разделённого запятыми, на список, разделённый вертикальными чертами |? Одна из проблем, которую мы можем предвидеть, заключается в том, что buildFerociousCats приклеен к деталям реализации. Обратите внимание на использование list + ',' + data для этого.

Простой ответ — полиморфное поведение с обратными вызовами. Принцип остаётся неизменным: относитесь к обратным вызовам как к контракту и делайте реализацию неактуальной. Как только обратный вызов становится абстракцией, конкретные детали могут изменяться по желанию.

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

Дадим определение контракту. Мы можем использовать параметры list и data в этом контракте:

cat.delimiter(cat.list, data);

Затем можем внести несколько изменений в buildFerociousCats:

function buildFerociousCats(cat, returnValue, next) {
setTimeout(function asyncCall(data) {
var catList = cat.delimiter(cat.list, data);

next({ list: catList, delimiter: cat.delimiter });
}, 1, returnValue);
}

Объект JavaScript cat теперь инкапсулирует данные списка и функцию delimiter(). Обратный вызов next связывает асинхронные обратные вызовы, ранее называвшиеся fn. Обратите внимание, что существует свобода произвольного группирования параметров с JavaScrip объектом. Объект cat ожидает два конкретных ключа — list и delimiter. Этот объект JavaScript теперь является частью контракта. Остальной код остаётся прежним.

Использовать это мы можем так:

buildFerociousCats({ list: '', delimiter: commaDelimiter }, 'Panther', getJaguar);
buildFerociousCats({ list: '', delimiter: pipeDelimiter }, 'Panther', getJaguar);

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

Что здесь такого радикального, так это то, что модульные тесты естественным образом вытекают из независимых модулей. Контракт delimiter — это чистая функция. Это означает, что, учитывая количество входных данных, мы каждый раз получаем один и тот же результат. Такой уровень тестируемости добавляет уверенности в том, что решение будет работать. Ведь модульная независимость даёт право на самооценку.

Эффективный модульный тест разделителя может выглядеть так:

describe('A pipe delimiter', function () {
it('adds a pipe in the list', function () {
var list = pipeDelimiter('Cat', 'Cat');

assert.equal(list, 'Cat|Cat');
});
});

Промисы / Promises

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

Давайте построим полиморфный обратный вызов и обернём его вокруг промиса. Настройте функцию buildFerociousCats и сделайте так, чтобы она возвращала промис:

function buildFerociousCats(cat, returnValue, next) {
return new Promise((resolve) => { //wrapper and return Promise
setTimeout(function asyncCall(data) {
var catList = cat.delimiter(cat.list, data);

resolve(next({ list: catList, delimiter: cat.delimiter }));
}, 1, returnValue);
});
}

Обратите внимание на использование resolve: вместо прямого использования обратного вызова это то, что разрешает промис. Вызывающий код может применить then для продолжения выполнения.

Поскольку теперь мы возвращаем промис, код должен отслеживать промис при выполнении обратного вызова.

Обновим функцию обратного вызова для возвращения промиса:

function getJaguar(cat) {
return buildFerociousCats(cat, 'Jaguar', getLynx); // Промис
}

function getLynx(cat) {
return buildFerociousCats(cat, 'Lynx', getSnowLeopard);
}

function getSnowLeopard(cat) {
return buildFerociousCats(cat, 'Snow Leopard', getLion);
}

function getLion(cat) {
return buildFerociousCats(cat, 'Lion', printList);
}

function printList(cat) {
console.log(cat.list); // нет Промиса
}

Самый последний вызов не связывает промисы, потому что у него нет промиса для возврата. Отслеживание промисов важно для гарантии продолжения в конце. По аналогии, когда мы даём обещание, лучший способ сдержать обещание — вспомнить, что мы когда-то давали это обещание.

Давайте обновим основной вызов вызовом функции then:

buildFerociousCats({ list: '', delimiter: commaDelimiter }, 'Panther', getJaguar)
.then(() => console.log('DONE')); // последний вывод

Если мы запустим код, то увидим, что в конце выводится DONE. Если мы забудем вернуть промис где-то в потоке, DONE появится не по порядку, потому что потеряет связь с первоначальным промисом.

Async/Await

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

Давайте избавимся от then и обернём вызов вокруг async/await:

async function run() {
await buildFerociousCats({ list: '', delimiter: pipeDelimiter }, 'Panther', getJaguar)
console.log('DONE');
}
run().then(() => console.log('DONE DONE')); // теперь действительно готово

Вывод DONE выполняется сразу после ожидания, потому что он работает так же, как синхронный код. Пока вызов buildFerociousCats возвращает промис, мы можем ждать (await) вызов. async помечает функцию как возвращающую промис, поэтому всё ещё можно связать вызов в run с ещё одним then. Пока то, что мы вызываем, возвращает промис, мы можем связывать промисы бесконечно.

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

Заключение

Освоение обратных вызовов в JavaScript — понимание всех тонкостей. Надеюсь, вы заметили тонкие вариации в функциях JavaScript. Функция обратного вызова становится неправильно понятой, когда не хватает основ. Как только функции JavaScript станут ясными, вскоре последуют принципы SOLID. Чтобы попробовать программирование с SOLID, требуется хорошее понимание основ. Присущая языку гибкость возлагает бремя ответственности на программиста.

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

Дополнительные материалы

Предыдущая Статья

JavaScript: Что такое функции обратного вызова/Callback

Следующая Статья

JavaScript: Руководство по async/await, с примерами