JavaScript Promises, 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, който се разрешава, когато първото обещание е разрешено, а не всички. Аз лично никога не съм го използвал професионално.

Асинхронен/Изчакване

Въпреки че може би е прекалено опростено, можете да мислите за Promises по същество като модел за обратно извикване, но много по-лесен за четене и писане. Ключовите думи 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 ...
}

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

Но наистинаблести в предишния пример, където извикахме три 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 за преобразуване на необещаващ код в обещаващ код. За да направите това, ще трябва да използвате синтаксиса на vanilla promise. Най-добрият пример е с 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. Браузърът може да извършва мрежови повиквания едновременно. По този начин, ако можем да стартираме множество обещания за извличане едновременно, те ще работят „паралелно“. Очевиден кандидат тук е 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, както би направила всяка друга необещаваща стойност, но това ще бъде просто обещание, което се преобразува в дефиницията на функцията, а не в резултат от нейното изпълнение.
  • За да напишете някакъв ad-hoc асинхронен код, без да дефинирате именувана асинхронна функция, можете просто да напишете анонимна асинхронна функция, да я поставите в скоби и веднага да я извикате.
  • Вярно е, че в този сценарий, за да се избегне дублиране на код, всъщност би било по-добре да напишете персонализирана обвивка.

Паралелни извиквания на зависим 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

Да приемем, че искате да получите всички подробности за всички поръчки на потребител. Ако имате масив, можете да извикате .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.erroring на грешката и повторно хвърляне. Когато нещата започнат да се провалят, е по-лесно да разберете къде се е случил провалът.
  • Препоръчвам да опаковате неща, които не са обещания (като setTimeout) в обещания. Това ще улесни живота ви.
  • В тези измислени примери async/await еквивалентите на обещаващ код не винаги изглеждат това много по-добре. Повярвайте ми, async и await ще направят вашия код по-лесен за четене и писане.