Как красиво написать чистый асинхронный код с обработкой ошибок

Мы все еще можем рассматривать JavaScript как унаследованный язык 90-х годов, но он считается самым крутым ребенком на рынке.

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

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

Но давайте будем честными, обрабатывать каждую возможную ошибку сложно.

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

В этой публикации я черпаю вдохновение в современных языках программирования, таких как Go и Swift, и пропущу объяснения async/await, Promise и обратных вызовов.

Вдохновение

Такие языки, как Go, уделяют большое внимание обработке ошибок. Всегда есть переменная с именем err, которую нужно проверить. Вот пример:

if data, err := datastore.Get(c, key, record); err != nil {
    http.Error(w, err.Error(), 500)
    return
}

А вот пример на Swift:

func icon() -> UIImage {
    guard let image = UIImage(named: "Photo") else {
        return UIImage(named: "Default")!
    }
    return image
}

Моя цель - сделать что-то столь же красивое, краткое и понятное, как эти предыдущие примеры, но на JavaScript.

асинхронные методы

Вот несколько async методов:

async function fetchData() {
  return fetch("...");
}
async function sendData(data) {
  return fetch("...", {
    method: "post",
    body: JSON.stringify(data),
  });
}
async function signData(data) {
 // Do something cool with cryptography ;)
}

Мы будем использовать эти методы в следующих разделах.

Никакой обработки

Быстрое и грязное решение - пропустить try/catch и пожелать лучшего. Если вы достаточно уверены в глобальной интернет-сети, своих серверах и пользователе (самом непонятом существе в мире), вы можете написать такой код:

async function handleClick() {
  const data = await fetchData();
  const signed = await signData(data);
  const saved = await sendData(signed);
  setState({ status: "ok ✅" });
}

Целостный улов

Минимальное дополнение, которое вы должны написать, - это, по крайней мере, отображать предупреждение и, возможно, сообщать об ошибке с большой упаковкой try/catch.

async function handleClick() {
  try {
    const data = await fetchData();
    const signed = await signData(data);
    const saved = await sendData(signed);
    setState({ status: "ok " });
  } catch (err) {
    setState({ status: "Something bad happened 🤷‍♂️" });
  }
}

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

Пошаговая инструкция пусть / попробует / поймает

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

async function handleClick() {
  let data = null;
  try {
    data = await fetchData();
  } catch (err) {
    return setState({ status: "Network error 🔌" });
  }
  let signed = null;
  try {
    signed = await signData(data);
  } catch (err) {
    return setState({ status: "Signature error 🔑" });
  }
  try {
    const saved = await sendData(signed);
    setState({ status: "ok ✅" });
  } catch (err) {
    return setState({ status: "Could not save data 💾" });
  }
}

Лично я говорю: «Я не большой поклонник let и тем более var». Итак, можем ли мы достичь того же результата, но с синтаксисом, подобным Go или Swift?

Моя рекомендация: лови как охранник

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

async function handleClick() {
  try {
    const data = await fetchData().catch(err => {
      throw new Error("Network error 🔌");
    });
    const signed = await signData(data).catch(err => {
      throw new Error("Signature error 🔑");
    });
    const saved = await sendData(signed).catch(err => {
      // Report error or any side effect.
      throw new Error("Could not save data 💾");
    });
    setState({ status: "ok " });
  } catch (err) {
    setState({ status: err.message });
  }
}

Также можно пропустить упаковку try/catch, но это оставит это обещание необработанным.

Это может не быть проблемой, поскольку код асинхронный. Кроме того, это не должно нарушать рендеринг любого приложения React.js или завершаться с кодом ошибки любого скрипта Node.js.

Вот пример использования оператора запятой (он короче, но мало кто об этом знает):

async function handleClick() {
  const data = await fetchData().catch(err => {
    throw (setState({ status: "Network error 🔌" }), err);
  });
  const signed = await signData(data).catch(err => {
    throw (setState({ status: "Signature error 🔑" }), err);
  });
  const saved = await sendData(signed).catch(err => {
    // Report error or any side effect.
    throw (setState({ status: "Could not save data 💾" }), err);
  });
  setState({ status: "ok " });
}

Возможно, вам потребуется отключить правило ESLint, чтобы использовать оператор запятой без предупреждений.

Фактически, вы можете использовать любое значение, объект и даже undefined в JavaScript (см. MDN). Теоретически можно даже выкинуть результат setState, который равен void:

async function handleClick() {
  const data = await fetchData().catch(err => {
    throw setState({ status: "Network error 🔌" }); // throw void;
  });
  // ...
  setState({ status: "ok " });
}

На мой взгляд, с точки зрения совместимости и стандартизации лучше придерживаться нативного Error object.

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

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

async function p(promise) {
  return promise
    .then(result => [result, null])
    .catch(err => [null, err]);
}
async function handleClick() {
  const [data, err0] = await p(fetchData());
  if (err0) {
    return setState({ status: "Network error 🔌" });
  }
  const [signed, err1] = await p(signData());
  if (err1) {
    return setState({ status: "Signature error 🔑" });
  }
  // ...
  setState({ status: "ok " });
}

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

Вы можете поиграть с каждым из этих вариантов и смешать их, чтобы получить то, что подходит вашей кодовой базе. Это также работает с серверными приложениями, такими как Express.js или Koa.js.

А пока я буду придерживаться метода catch as guard.