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. Сложно отлаживать или логировать из середины вложенных вызовов