Разработка

Как избавиться от вложенных коллбэков: рассматриваем на примере приготовления гамбургера

Адаптированный перевод статьи Zell Liew «How to deal with nested callbacks and avoid callback hell».

В JavaScript есть странные вещи. Одна из них — обратный вызов, который находится в обратном вызове, который находится в обратном вызове. На английском языке это называется callback hell или ад обратных вызовов. Статья поможет справиться с этой проблемой и писать код понятнее.

Что такое обратные вызовы: функции, которые выполняются после выполнения других функций

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

В чём проблема вложенных коллбэков: код становится непонятным

Вложенные обратные вызовы выглядят так:

firstFunction(args, function() {
  secondFunction(args, function() {
    thirdFunction(args, function() {
      // И так далее...
    });
  });
});

За такие конструкции мы любим недолюбливаем JavaScript. Вложенные коллбэки буквально сбивают с толку и взрывают мозг. Но эта проблема решается.

Как избегать вложенных вызовов: четыре рецепта

Сначала просто посмотрим на варианты решения проблемы без углубления в детали. Вот четыре способа вырваться из ада вложенных коллбэков:

  1. Комментируйте код.
  2. Разделяйте большие функции на несколько маленьких.
  3. Используйте промисы.
  4. Используйте async/await.

Но прежде чем разбираться с каждым способом, давайте создадим свой callback hell. Это нужно, чтобы почувствовать боль вложенных обратных вызовов.

Делаем больно: откуда берутся вложенные коллбэки

Представьте, что готовите гамбургер. Вот алгоритм приготовления:

  1. Купить ингредиенты.
  2. Приготовить говядину.
  3. Достать булочки.
  4. Положить говядину между булочками.
  5. Подать гамбургер.

Давайте опишем приготовление гамбургера с помощью JavaScript:

const makeBurger = () => {
  const beef = getBeef();
  const patty = cookBeef(beef);
  const buns = getBuns();
  const burger = putBeefBetweenBuns(buns, putty);
  return burger;
};

const burger = makeBurger();
serve(burger);

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

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

const makeBurger = () => {
  getBeef((beef) => {
    // Приготовить говядину можно после того, как мы её купили. 
  });
}; 

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

const makeBurger = () => {
  getBeef((beef) => {
    cookBeef(beef, (cookedBeef) => {
      getBuns((buns) => {
        // Достаем булочки
      });
    });
  });
};

Когда это сделано, можем положить мясо и остальную начинку между булочками.

const makeBurger = () => {
  getBeef((beef) => {
    cookBeef(beef, (cookedBeef) => {
      getBuns((buns) => {
        putBeefBetweenBuns(buns, cookedBeef, (burger) => {
          // Добавляем начинку
        });
      });
    });
  });
};

Наконец гамбургер готов. Но мы пока не можем вернуть burger из makeBurger, так как функция асинхронная. Чтобы подать блюдо на стол, нужно использовать обратный вызов.

const makeBurger = (nextStep) => {
  getBeef((beef) => {
    cookBeef(beef, (cookedBeef) => {
      getBuns((buns) => {
        putBeefBetweenBuns(buns, cookedBeef, (burger) => {
          nextStep(burger);
        });
      });
    });
  });
};

// Делаем и подаем на стол гамбургер
makeBurger((burger) => {
  serve(burger);
});

Ну что, еще хотите гамбургер, или вложенные коллбэки испортили аппетит и настроение? Давайте посмотрим на способы решения проблемы, может, это вас тонизирует.

Первое решение: комментируйте код

Функцию makeBurger из примера выше легко понять, так как это достаточно простой случай. Эта функция просто не очень элегантная. Но новичка она может заставить задуматься. Если вы недавно изучаете программирование и сразу не понимаете, что происходит в коде, постарайтесь объяснить происходящее с помощью комментариев.

// Функция описывает алгоритм приготовления гамбургера
// makeBurger состоит из пяти шагов:
//   1. Покупка ингредиентов
//   2. Приготовление говядины
//   3. Приготовление булочек
//   4. Добавление начинки в булочки
//   5. Подачи гамбургера (обратный вызов)
// Каждый шаг алгоритма асинхронный, поэтому нужно использовать коллбэки.
//   Мы ждем, пока маленький помощник выполнит текущий шаг,
//   после этого приступаем к следующему шагу

const makeBurger = (nextStep) => {
  getBeef((beef) => {
    cookBeef(beef, (cookedBeef) => {
      getBuns((buns) => {
        putBeefBetweenBuns(buns, cookedBeef, (burger) => {
          nextStep(burger);
        });
      });
    });
  });
};

Комментарии объясняют вам или другому разработчику, что происходит в коде. Кто-то благодаря этим пояснениям поймет функцию и не бросит программирование.

Второе решение: разделяйте большие функции на несколько маленьких

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

const getBeef = (nextStep) => {
  const fridge = leftFright;
  const beef = getBeefFromFridge(fridge);
  nextStep(beef);
};

Чтобы приготовить говядину, нужно положить её в духовку, установить температуру 200 °C и включить таймер на 20 минут. Вот код:

const cookBeef = (beef, nextStep) => {
  const workInProgress = putBeefinOven(beef);
  setTimeout(() => {
    nextStep(workInProgress);
  }, 1000 * 60 * 20);
};

Теперь представьте, что эти шаги описываются в функции makeBurger. У вас не просто пропадет аппетит. Вы возненавидите гамбургеры и JavaScript.

Запомните этот принцип: функции с вложенными коллбэками надо разделять на несколько маленьких функций.

Третье решение: используйте промисы

Если вы не изучали промисы, это можно сделать в курсе «Асинхронное программирование».

Промисы решают проблему вложенных обратных вызовов. Посмотрите на код:

const makeBurger = () => getBeef()
  .then(beef => cookBeef(beef))
  .then(cookedBeef => getBuns(cookedBeef))
  .then(bunsAndBeef => putBeefBetweenBuns(bunsAndBeef));

// Делаем и подаем гамбургер
makeBurger().then(burger => serve(burger));

Алгоритм приготовления гамбургера можно выразить ещё проще, если использовать промисы с одним аргументом:

const makeBurger = () => getBeef()
  .then(cookBeef)
  .then(getBuns)
  .then(putBeefBetweenBuns);

// Make and serve burger
makeBurger().then(serve);

Код стал понятнее. Давайте посмотрим, как превратить функцию с вложенными коллбэками в промисы.

Как заменить функцию с коллбэками на промисы

Чтобы решить задачу, нужно вместо каждого обратного вызова записать промис. Когда коллбэк успешно завершается, промис выполняется с результатом resolve. Если коллбэк не выполняется, получаем ошибку reject.

const getBeefPromise = (_) => {
  const fridge = leftFright;
  const beef = getBeefFromFridge(fridge);
  return new Promise((resolve, reject) => {
    if (beef) {
      resolve(beef);
    } else {
      reject(new Error('Говядина больше не нужна!'));
    }
  });
};

const cookBeefPromise = (beef) => {
  const workInProgress = putBeefinOven(beef);
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(workInProgress);
    }, 1000 * 60 * 20);
  });
};

Если вы используете Node, все функции с обратными вызовами имеют одинаковый синтаксис:

  1. Коллбэк передается в качестве последнего аргумента.
  2. Коллбэк всегда принимает два аргумента.
  3. Аргументы принимаются в одном порядке: сначала обработка ошибки, затем выполнение действия.
// Определение функции
const functionName = (arg1, arg2, callback) => {
  // Какое-то действие
  callback(err, stuff);
};

// Использование функции
functionName(arg1, arg2, (err, stuff) => {
  if (err) {
    console.error(err);
  }
  // Какое-то действие
});

Одинаковый синтаксис коллбэков позволяет использовать библиотеки типа ES6 Promisify или Denodeify. Если вы пользуетесь версией Node 8.0 и выше, можно применять библиотеку util.promisify. Эти инструменты конвертируют коллбэки в промисы.

Четвертое решение: используйте асинхронные функции

Чтобы использовать четвертое решение, нужно понимать следующие вещи:

  1. Как превращать коллбэки в промисы. Этому посвящен предыдущий раздел.
  2. Как работать с асинхронными функциями. Информацию можно найти в курсе «Асинхронное программирование», ссылка выше.

Давайте посмотрим на асинхронную функцию makeBurger.

const makeBurger = async () => {
  const beef = await getBeef();
  const cookedBeef = await cookBeef(beef);
  const buns = await getBuns();
  const burger = await putBeefBetweenBuns(cookedBeef, buns);
  return burger;
};

// Готовим и подаем гамбургер
makeBurger().then(serve);

Этот код можно улучшить. Представьте, что у вас два маленьких помощника. Один из них выполняет действие getBuns, то есть достает булочки. Второй выполняет getBeef, то есть достает из холодильника говядину. Вы ждете (await) каждого помощника (Promise.all).

const makeBurger = async () => {
  const [beef, buns] = await Promise.all(getBeef, getBuns);
  const cookedBeef = await cookBeef(beef);
  const burger = await putBeefBetweenBuns(cookedBeef, buns);
  return burger;
};

// Make and serve burger
makeBurger().then(serve);

Да, то же самое можно записать с помощью промисов. Но синтаксис async/await более понятный.

Завершаем: рассмотрели четыре способа решения проблемы вложенных коллбэков

Вот эти решения:

  1. Писать комментарии.
  2. Разделять большие функции на несколько маленьких.
  3. Использовать промисы.
  4. Использовать async/await.

Оригинал публикации: How to deal with nested callbacks and avoid callback hell. Мнение автора оригинальной статьи может не совпадать с позицией редакции.

Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Node, PHP, Python и Java.

Хекслет

Подробнее о том, почему наше обучение работает →