Обещания JavaScript, async/await и Promise.all

Это похоже на тебя?

Я хочу сделать две выборки API в своем приложении JavaScript параллельно

Ты не одинок. Если у вас есть два независимых вызова API, зачем заставлять один ждать завершения другого?

Для этого я использую Promise.all с (часто анонимными) асинхронными функциями. Если вы новичок в Promises и/или async/await, или вам нужно больше информации о том, что происходит на самом деле, прочитайте раздел «Предыстория». Если вы просто хотите перейти к хорошей части, перейдите к разделу «Собираем все вместе».

Задний план

Очередь событий

JavaScript — это однопоточный язык. Он не способен по-настоящему вычислять две вещи одновременно. Однако он основан на событиях, из-за чего иногда кажется многопоточным. Различные вещи (а именно определенные функции браузера и системные вызовы Node) выполняются независимо от среды выполнения JavaScript, и когда они завершаются, добавляют что-то в конец «очереди событий» JavaScript. Когда JavaScript завершает выполнение функции, он проверяет свою очередь событий, чтобы увидеть, есть ли еще работа, и выполняет ее в том порядке, в котором она была получена.

Когда вы выполняете какую-либо асинхронную выборку (будь то с помощью fetch api, jQuery.ajax или старого доброго XMLHttpRequest), браузер выполняет этот сетевой вызов, не блокируя выполнение остальной части вашего JavaScript. . Когда он закончил свою выборку, он добавляет функцию в конец очереди событий (обычно та, которую вы предоставляете в качестве обратного вызова) с переданными извлеченными данными. Это создает впечатление, что это выборка в отдельном потоке, но JavaScript на самом деле просто идет по своей очереди событий, выполняя их одно за другим, а браузер делает что-то параллельно.

Обратные вызовы

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

apiFetcher("/my/route", (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  dataProcessingFunction(data);
});

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

apiFetcher("/my/route", (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  apiPoster("/my/other/route", data, (err2, data2) => {
    if (err2) {
      console.error(err2);
    }
    apiPoster("/my/other/other/route", data2, (err3, data3) => {
      if (err3) {
        console.error(err3);
      }
      dataProcessingFunction(data, data2, data3);
    })
  })
});

Это иногда называют «летающей буквой V», потому что по мере того, как вы вкладываете эти обратные вызовы все глубже и глубже, они начинают напоминать букву «V».

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

let otherData;
let otherError;
let callback = (err, data) => {
  if (err) {
    console.error(err);
    otherError = err;
    return;
  }
  if (otherError) {
    return;
  }
  if (otherData) {
    dataProcessingFunction(otherData, data);
  } else {
    otherData = data;
  }
};
apiFetcher("/first", callback);
apiFetcher("/second", callback);

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

Обещания

Обещания направлены на решение этой проблемы обратного вызова. Если бы apiFetcher было написано с промисами, вы могли бы написать это:

apiFetcher("/my/route")
  .then(data => dataProcessingFunction(data))
  .catch(err => {
    console.error(err)
  });

Этот синтаксис намного приятнее. Вместо того, чтобы проверять, есть ли объект err в обратном вызове, вы можете добавить .catch в конец вашей «цепочки промисов», которая будет перехватывать ошибки из промисов.

Обещание — это объект. apiFetcher("/my/route") возвращает объект типа Promise. Когда вы добавляете .then(data => { ... }) в конец обещания, функция внутри then будет работать с результатом обещания, переданным в качестве параметра.

Вы можете добавить .then после .then . Если первый возвращает обещание, второй будет ждать его. Если первый возвращает нормальное значение, он просто вызывает второй then с переданным значением (он неявно заключает значение в промис, который немедленно разрешается с этим значением. Вы можете явно сделать это, используя Promise.resolve). Допустим, вы хотите выполнить вызов API, затем передать результат в другой вызов API, а затем передать этот результат в другой вызов API и вернуть окончательный результат. . Вместо летящей V вы получаете:

apiFetcher("/part1")
  .then(data => apiPoster("/part2", data))
  .then(data => apiPoster("/part3", data))
  .then(dataProcessingFunction)
  .catch(err => {
    console.log(err)
  });

Примечание:

  • Вам не нужно добавлять проверку ошибок к каждому из них. .catch будет ловить от всех из них.
  • Это не то же самое, что в приведенном выше примере, в котором все три результата передаются в dataProcessingFunction в конце. Подробнее об этом позже.

Написание обещания

Обещания исходят не только от встроенных функций браузера. Вы можете создать их самостоятельно.

let myPromise = new Promise((resolve, reject) => {
  try {
    let data = ... do some stuff ...
    resolve(data);
  } catch (e) {
    reject(e);
  }
});

Вы даете обещание new Promise(). Этот конструктор принимает один аргумент: функция, принимающая два аргумента (традиционно называемые resolve и reject). Внутри этой функции вы пишете любой код, который хотите написать, и когда вы закончите, вы вызываете resolve(myHappyData). Значение, которое вы передаете в resolve, выйдет через myPromise.then(myHappyData => ...). Если вы столкнетесь с какой-либо проблемой, вы можете позвонить reject(someErrorValue), который будет передан .catch(someErrorValue => ...).

Вот пример полезного обещания, которое вы можете дать:

let wait5Seconds = new Promise(resolve => {
  setTimeout(() => {
    resolve();
  }, 5000);
});
wait5Seconds.then(() => { ... });

Примечание:

  • Если вы не планируете выполнять какую-либо обработку ошибок, вам не нужно использовать параметр reject в своей функции.
  • Если вам не нужно ничего передавать .then, вы можете вызвать resolve без аргументов.

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

let wait = timeout => new Promise(resolve => {
  setTimeout(() => {
    resolve();
  }, timeout);
});
wait(5000).then(() => { ... });

Используя наш предыдущий пример, мы могли бы обернуть наш вызов API в пользовательскую функцию создания обещаний, которая добавляет возможность передавать данные. Это может выглядеть примерно так:

const ourPoster = (path, dataList) =
  apiPoster(path, dataList[dataList.length -1])
  .then(newData => [...dataList, newData])
});
apiFetcher("/part1")
  .then(data => ourPoster("/part2", [data]))
  .then(dataList => ourPoster("/part3", dataList))
  .then(dataList => dataProcessingFunction(...dataList))
  .catch(err => {
    console.log(err)
  });

Обещание.все

Promise.all — это функция, которая принимает массив обещаний и возвращает обещание, которое разрешается, когда все обещания в массиве разрешены (и отклоняется, если какое-либо из них отклонено). Другими словами, вы используете это, чтобы «сделать несколько дел одновременно и сказать мне, когда все будет сделано».

Если бы у вас было три независимых вызова API, вы могли бы сделать что-то вроде этого:

Promise.all([
  apiFetcher("/thing1),
  apiFetcher("/thing2"),
  apiFetcher("/thing3")
]).then(([result1, result2, result3]) => {
  ...handle the results...
}).catch(err => {
  ...handle the error, which is that of the first thing to fail...
})

Примечания:

  • .then на Promise.all передает массив с результатами каждого обещания в том же порядке, в котором они были переданы в Promise.all.
  • Я использую деструктурирование, чтобы получить результаты с более удобным синтаксисом.
  • Помните, что Promise.all само по себе является полноценным обещанием.
  • Также есть Promise.race, который разрешается, когда разрешается первое обещание, а не все. Лично я никогда не использовал это профессионально.

Асинхронный/ожидание

Хотя, возможно, это чрезмерное упрощение, вы можете думать о промисах, по сути, как о шаблоне обратного вызова, но гораздо более легком для чтения и записи. Ключевые слова async и await по сути такие же, но для промисов.

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

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

Вот некоторые примеры:

async fetchBasedOnArgs = (args) => {
  let path = ... figure out what to fetch based on args ...;
  return apiFetcher(path);
}
async pointlesslyAsyncSum = (a, b) => a + b;

Зачем помечать функцию как async? Почему, в этом свободно типизированном мире JavaScript, в котором мы живем, нам нужно указывать, что функция возвращает обещание?

Когда вы используете функцию async, она раскрывает силу ключевого слова await.

Ключевое слово await берет обещание и выворачивает его наизнанку. В то время как вы обычно используете обещание вроде этого:

apiFetcher("/thing")
  .then(data => {
    ... do stuff with data ...
})

С await ты бы сделал

let data = await apiFetcher("/thing");
... do stuff with data ...

Кроме того, в то время как в обычном обещании вы использовали бы .catch для обработки ошибок, await выдаст ошибку как стандартную ошибку JavaScript, поэтому вы можете использовать более выразительное try/catch.

try {
  let data = await apiFetcher("/thing");
  ... do stuff with data ...
} catch (err) {
  ... handle the error ...
}

Пример того, когда это хорошо, — это когда вы используете fetch API. Функция fetch возвращает обещание, которое разрешается с помощью объекта Response. Если вы вызываете API, который возвращает JSON, вы должны затем вызвать yourResponse.json(), которая является функцией-членом типа Response, которая, конечно же, возвращает обещание, что при разрешении это JSON с вашими фактическими данными ответа. Итак, в реальном мире это будет выглядеть так:

fetch("https://myapi.io/path")
  .then(res => res.json())
  .then(data => ... do stuff ...)
  .catch(err => ... handle error ...);

С await это будет выглядеть так:

try {
  let res = await fetch("https://myapi.io/path");
  let data = await res.json();
  ... do stuff ...
} catch (err) {
  ... handle error ...
}

Что более выразительно и менее загромождено, чем набор .then, соединенных вместе со стрелочными функциями внутри каждого из них.

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

try {
  let data1 = await apiFetcher("/part1");
  let data2 = await apiPoster("/part2", data1);
  let data3 = await apiPoster("/part3", data2);
  dataProcessingFunction(data1, data2, data3);
} catch (err) {
  console.error(err);
}

Взлом не требуется.

Чтобы использовать await, он должен находиться внутри функции async (первая причина, по которой вы хотите пометить функцию как async). Это потому, что когда вы используете await, который позволяет вам вывернуть промис наизнанку, за кулисами JavaScript скатывает все обратно вместе. Когда у вас есть await строк в функции, JavaScript, по сути, строит большую цепочку .then и возвращает ее. Таким образом, функция, использующая await внутри должна всегда возвращать обещание.

Если у вас есть что-то вроде

let myFunc = async () => {
  let data = await somePromise;
  let result = ... do stuff with data ...;
  return result;
}

Это действительно становится

let myFunc = () => 
  somePromise
    .then(data => ... do stuff with data ... );
}

(Забавный факт: async и await — относительно новые функции. До того, как браузеры и Node добавили для них встроенную поддержку, транспиляторы, такие как Babel, буквально делали этот перевод для создания совместимого кода (если, конечно, вы не нацеливались на платформу, на которой не было нативные промисы поддерживают либо (которые также являются относительно новыми, но старше, чем async и await), и в этом случае это будет переводить их в какой-то ужасный код обратного вызова).

С другой стороны, поскольку функция async гарантированно возвращает промис, вы всегда можете await результат одной (вторая причина, по которой вы хотите пометить функции как async). Итак, вы можете сделать что-то вроде

const myPoster = async (path, data) => {
  try {
    let res = await fetch(`${MY_APPS_API_HOST}/${path}`, {
      method: "post",
      body: JSON.stringify(data)
    });
    let json = await res.json();
    return json;
  } catch (e) {
    sendErrorToSentryOrSomething(e);
    throw e;
  }
};
const myProgram = async (data) => {
  try {
    let resultData = await myPoster("/myPath", data);
    ... do something with the data, such as save it in app state...
  } catch (e) {
    tellFrontendSomethingDied(e.message);
  }
}

Примечания:

  • sendErrorToSentryOrSomething может сама по себе быть асинхронной функцией. Вы не обязаны await это делать. Если вы этого не сделаете, вы продолжите, и когда все будет готово, оно разрешится, как обычное обещание.
  • Вы можете throw ошибиться из раздела catch блока try/catch. При этом возникает ошибка для родителя, которая затем может быть отдельно перехвачена инициатором. Если вы опустите блок try/catch в функции async, которая awaits, то в случае отклонения ожидаемого обещания эта функция выдаст ошибку (а затем отклонит), которая дойдет до вызывающей стороны и, по сути, вызовет цепную реакцию. Однако рекомендуется заключать все строки await в строку try/catch и в идеале перехватывать и записывать/сообщать об ошибке перед отправкой ее родителю.

Любой код, написанный с помощью промисов, можно переписать с помощью async и await или наоборот (хотя иногда будет более болезненно использовать одно вместо другого), поскольку на самом деле они означают одно и то же. Ванильные обещания и код async/await можно смешивать и сопоставлять. Вы можете await выполнить любое обещание. Большинство библиотек теперь имеют встроенную поддержку промисов (например, любая функция в AWS SDK может иметь .promise() в конце. Затем вы можете либо await, либо добавить к ней .then).

Единственное предостережение: обычно вы не можете использовать async и await для преобразования не-обещающего кода в обещанный код. Для этого вам нужно использовать синтаксис ванильного обещания. Лучший пример — setTimeout, который принимает обратный вызов. Тем не менее, вы можете относительно легко обернуть эти функции в новый промис.

const wait = delay => new Promise(
  resolve => setTimeout(resolve, delay)
);
const delayedFunction = async () => {
  await wait(5000);
  ... do stuff ...
};

Разные примечания:

  • Функцию async можно вызывать как обычную функцию (не дожидаясь результата), и она просто запускается, не блокируя выполнение остального кода. В отличие от await, его не нужно вызывать внутри другой функции async.
  • Функция async сама по себе не является обещанием. Это функция, которая возвращает обещание. Забвение этого нюанса может привести к ошибкам, из-за которых ваш асинхронный код никогда не запустится.
  • В некоторых сценариях вы не можете пометить функцию как async или хотите использовать анонимную функцию для некоторого ожидания, но у вас есть что-то, что принимает обещание, а не функция, которая возвращает обещание (например, с Promise.all). , об этом позже). Иногда помогает (async () => { ... await some stuff ... })()

Собираем все вместе: несколько одновременных вызовов API с использованием Promise.All и Async/Await

Несколько параллельных вызовов API

При использовании API выборки или любого другого API выборки на основе обещаний вы даете обещание, которое будет разрешено, когда браузер получит ответ API. Браузер может одновременно совершать сетевые вызовы. Таким образом, если мы можем запускать несколько промисов fetch одновременно, они будут выполняться «параллельно». Очевидным кандидатом здесь является Promise.all, который является лучшим способом запуска множества промисов одновременно. Если мы хотим использовать исключительно синтаксис async/await, мы помним, что мы можем await любое обещание, и мы помним, что Promise.all технически возвращает обещание, мы можем сделать это:

let [res1, res2, res3] = await Promise.all([
  fetch(`${API_HOST}/path1`),
  fetch(`${API_HOST}/path2`),
  fetch(`${API_HOST}/path3`)
])
processData(res1, res2, res3);

Это запускает все наши вызовы API параллельно. processData(res1, res2, res3) не запустится, пока не закончатся все три вызова. Если самый длинный из трех вызовов занимает 2 секунды, все это займет 2 секунды.

Однако в приведенном выше коде нам нужно было бы вызывать await res.json() для каждой выборки. Мы бы предпочли, чтобы processData не беспокоились об этом. Мы могли бы написать асинхронную оболочку вокруг fetch, которая извлекает и JSON, но мы также можем просто написать (и сразу же вызвать) некоторые анонимные функции.

let [data1, data2, data3] = await Promise.all([
  (async () => {
    let res = await fetch(`${API_HOST}/path1`);
    let data = await res.json();
    return data;
  })(),
  (async () => {
    let res = await fetch(`${API_HOST}/path2`);
    let data = await res.json();
    return data;
  })(),
  (async () => {
    let res = await fetch(`${API_HOST}/path3`);
    let data = await res.json();
    return data;
  })(),
])
processData(data1, data2, data3);

Примечания:

  • Функция async не является обещанием. Это функция, которая возвращает обещание. Promise.all принимает массив обещаний. Если вы просто поместите определения анонимных функций, Promise.all не запустит их. Он будет неявно заключать их в Promise.resolve, как и любое другое значение, не являющееся обещанием, но это будет просто обещание, которое разрешается в определение функции, а не результат ее выполнения.
  • Чтобы написать специальный асинхронный код без определения именованной асинхронной функции, вы можете просто написать анонимную асинхронную функцию, заключить ее в круглые скобки и сразу вызвать.
  • По общему признанию, в этом сценарии, чтобы избежать дублирования кода, на самом деле было бы лучше написать собственную оболочку.

Зависимые вызовы API параллельно

Допустим, у вас есть сайт электронной коммерции. На главной странице вы хотите показать последние продукты и последний заказ текущего пользователя. У вас есть API «последних продуктов», API «информация о пользователе» и API «информация о заказе». Информация о пользователе и информация о заказе быстрая, последние продукты медленные. Информация о пользователе дает вам идентификаторы заказов, и вы хотите взять первый и передать его в API информации о заказе. Если вы вызываете информацию о пользователе и последних продуктах параллельно, используя Promise.all, вам придется дождаться завершения обоих, а затем использовать результаты из информации о пользователе для вызова API информации о заказе. Вы бы предпочли получить информацию о пользователе, а не информацию о заказе, ожидая последней информации о продуктах; вы должны иметь возможность вызывать API информации о заказе, как только поступает ваша информация о пользователе, а не когда возвращаются последние продукты.

Вы можете добиться этого, используя анонимную асинхронную функцию внутри файла Promise.all.

const [latestProducts, orderInfo] = await Promise.all([
  ourFetcher("/latest"),
  (async () => {
    let userInfo = await ourFetcher("/userInfo");
    let latestOrderID = userInfo.orderIDs[0];
    let orderInfo = await ourFetcher("/orderInfo",latestOrderID);
    return orderInfo;
  })()
]);

Promise.all и карта

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

const userInfo = await ourFetcher("/userInfo");
const orders = await Promise.all(
  userInfo.orderIDs.map(async id => {
    let orderInfo = await ourFetcher("/orderInfo", id);
    return processOrderInfo(orderInfo);
  })
);

Если вам не нужно делать вызов processOrderInfo для каждого результата, вам даже не нужна асинхронная функция:

const userInfo = await ourFetcher("/userInfo");
const orders = await Promise.all(
  userInfo.orderIDs.map(id => ourFetcher("/orderInfo", id))
);

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

Последние мысли

  • Самая распространенная ошибка, с которой я сталкиваюсь, это забывание await. Я часто вижу let res = fetch(...)
  • Второй наиболее распространенной ошибкой, которую я вижу, является помещение анонимных асинхронных функций в Promise.all без их фактического вызова. Помните, что Promise.all принимает промисы, а функция async не сама по себе промис.
  • Самое распространенное заблуждение, с которым я сталкиваюсь в отношении промисов и async/await, заключается в том, что они распараллеливают ваш код JavaScript. Это неверно. Невозможно невозможно распараллелить код JavaScript. Технически они распараллеливают такие вещи, как сетевые вызовы, но на самом деле их распараллеливает браузер. JavaScript выполняет одну вещь за раз, и он выполняет вещи в том порядке, в котором он их получает. Если вы напишете кучу async функций, которые просто вычисляют что-то и не ожидают никаких промисов, а затем поместите их все в Promise.all, JavaScript просто запустит их одну за другой.
  • Всякий раз, когда вы ожидаете вызова API, я рекомендую завернуть его в try/catch и, по крайней мере, console.error сообщить об ошибке и повторно выдать. Когда что-то начинает давать сбой, становится легче понять, где произошел сбой.
  • Я рекомендую оборачивать вещи, не являющиеся обещаниями (такие как setTimeout), в обещания. Это сделает вашу жизнь проще.
  • В этих надуманных примерах async/await эквиваленты кода промисов не всегда выглядят намного лучше. Поверьте мне, async и await облегчат чтение и написание вашего кода.