DanLevy.net

Нарушенные обещания?

Пропуск ошибок, потеря результатов…

Hero image for Нарушенные обещания?

JavaScript‑промисы сломаны?

В старые времена

Один из самых распространённых мифов о промисах — это якобы недостаточная работа с ошибками.

Много лет назад промисы были действительно плохи в обработке ошибок. Много усилий было вложено в их исправление.

И вот, это было исправлено, даже широко развернуто.

Люди радовались

И,к сожалению, некоторые этого не заметили.

Современные времена

Миф всё ещё жив, я вижу его повсюду: популярные статьи на Medium, на DZone и многие другие источники.

Признаюсь, даже «официальные» ресурсы и документация в основном предлагают хрупкие примеры и плохие привычки. Их часто используют, чтобы «доказать» аргументы против промисов. Некоторые даже советуют «лекарства», которые только ухудшают ситуацию. (примечание: ссылка удалена)



Правила, чтобы не попасть в беду

  1. Промисам нужно, за что держаться
    • Всегда return из ваших функций.
  2. Используйте реальные экземпляры Error
    • Всегда используйте экземпляры Error.
  3. Обрабатывайте ошибки там, где это имеет смысл
    • Всегда вызывайте .catch(), хотя бы один раз.
  4. Добавляйте ясность именованными функциями 🦄✨
    • Предпочитайте именованные функции.

#1 Промисам нужно, за что держаться

Крайне важно, чтобы вы всегда return из своих функций.

Функции‑обратного вызова промиса следуют определённому шаблону в .then(callback) и .catch(callback).

Каждое возвращённое значение передаётся в колбэк следующего .then().

function addTen(number) {
  return number + 10;
}

Promise.resolve(10)  // 10
  .then(addTen)      // 20
  .then(addTen)      // 30
  .then(addTen)      // 40
  .then(console.log) // logs "40"

Плюс от «всегда возвращать»: код становится значительно проще тестировать.

Вопрос: Сколько различных состояний промиса (resolved & rejected) было создано?

Вопрос: Сколько промисов было создано в приведённом выше примере?

#2 Используйте реальные экземпляры Error

JavaScript имеет интересное поведение в отношении ошибок (которое распространяется как на асинхронный, так и на синхронный код).

[см. пример на repl.it: throwing errors in javascript] throwing errors in javascript

Чтобы получить полезные сведения о номере строки и стеке вызовов, необходимо использовать экземпляры Error. Бросать строки не работает так, как в Python или Ruby.

Хотя JavaScript кажется обрабатывает throw "string", вы увидите эту строку в обработчике catch. Однако это будет всё, что вы получите*. Ни один предыдущий фрейм стека не будет включён.

Правильные примеры с new Error:

throw new Error('message')           // ✅
Promise.reject(new Error('message')) // ✅
throw Error('message')               // ✅
Promise.reject(Error('message'))     // ✅

Ниже перечислены типичные антипаттерны:

throw 'error message'  // ❌
Promise.reject(-42)    // ❌

#3 Обрабатывайте ошибки там, где это имеет смысл

Promise дают удобный способ обработки ошибок через .catch(). По сути это особый вариант .then() — он перехватывает любые ошибки, возникшие в предыдущих .then(). Рассмотрим пример…

Promise.resolve(42)
  .then(() => 'hello')
  .catch(() => console.log('will not get hit'))
  .then(() => throw new Error('totes fail'))
  .catch(() => console.log('WILL get hit'))

Хотя .catch() может напоминать обработчик DOM‑события (например, click, keypress), его позиция критична: он может «поймать» только те ошибки, которые возникли выше него.

Переопределять ошибки довольно просто — верните не‑ошибочное значение в колбэке .catch(), и цепочка Promise переключится на выполнение последующих .then()‑колбэков (по сути).

Попробуйте проследить последовательность в следующем примере:

Promise.resolve(42)
  .then(() => 'hello')
  .then(() => throw new Error('totes fail'))
  .catch(() => {
    return 99
  })
  .then(num => num + 1)
  .then(console.log) // ожидаемый вывод: 100

Важно понять именно последовательность выполнения.

Хотя пример глупый, он предназначен для демонстрации того, как ошибки и данные проходят сквозь цепочку Promise.

Ниже схема последовательности:

  1. 42 — начальное значение.
  2. hello — всегда возвращается следующим методом.
  3. Мы игнорируем предыдущее значение и бросаем ошибку с сообщением 'totes fail'.
  4. .catch() перехватывает ошибку и вместо неё возвращает 99, которое будет получено любыми последующими .then().
  5. Инкрементируем num, получаем 100.
  6. Метод console.log получает 100 и выводит его! :tada:

Вопрос: Что произойдёт, если в цепочке подряд идут два .catch()? Может ли второй когда‑нибудь выполниться? Придумайте сценарий применения.

Вопрос: Как .catch() может игнорировать ошибки? Как предотвратить преждевременный выход из Promise.all из‑за ошибок?

#4 Добавьте ясность с именованными функциями 🦄✨

Сравните читаемость двух примеров:

Анонимные:

Promise.resolve(10)          // 10
  .then(x => x * 2)          // 20
  .then(x => x / 4)          // 5
  .then(x => x * x)          // 25
  .then(x => x.toFixed(2))   // "25.00"
  .then(x => console.log(x)) // ожидаемый вывод: "25.00"

Именованные:

Promise.resolve(10) // 10
  .then(double)     // 20
  .then(quarter)    // 5
  .then(square)     // 25
  .then(format)     // "25.00"
  .then(log)        // ожидаемый вывод: "25.00"

const double = x => x * 2
const quarter = x => x / 4
const square = x => x * x
const format = x => x.toFixed(2)
const log = x => console.log(x)

БОНУС:

Совместимо с методами массива!!!

Именованные функции можно переиспользовать с нашими друзьями из Array.prototype. — включая .map(), .filter(), .every(), .some(), .find()!

Коллекционные конвейеры #FTW:

// IT'S LIKE THE SAME THING :mindblown:

[10, 20]           // [ 10, 20 ]
  .map(double)     // [ 20, 40 ]
  .map(quarter)    // [ 5, 10 ]
  .map(square)     // [ 25, 100 ]
  .map(format)     // [ "25.00", "100.00" ]
  .map(log)        // expected 2 lines of output: "25.00", "100.00"

А если вам не нравится линейный стиль… У вас всё равно есть простые функции!

Их можно применять в любой комбинации:

// Nesting patern
// ❌ please don't do this, however

const result = format(square(quarter(double(10))))

log(result)
// expected output: "25.00"

Почему вложение функций считается антипаттерном?

  1. Трудно читаемо для большинства разработчиков
  2. git diff не показывает сразу, кто и что изменил
  3. Сложно отлаживать или логировать из середины вложенных вызовов