Разбіраны Async/Await у JavaScript на прыкладах

Аўтар артыкула разбірае на прыкладах Async/Await у JavaScript. У цэлым, Async / Await - зручны спосаб напісання асінхроннага кода. Да з'яўлення гэтай магчымасці падобны код пісалі з выкарыстаннем коллбэкаў і промісаў. Аўтар арыгінальнага артыкула раскрывае перавагі Async/Await, разбіраючы розныя прыклады.

Нагадваем: для ўсіх чытачоў "Хабра" - зніжка 10 000 рублёў пры запісе на любы курс Skillbox па промакодзе "Хабр".

Skillbox рэкамендуе: Адукацыйны анлайн-курс «Java-распрацоўшчык».

Зваротны званок

Callback уяўляе сабой функцыю, выклік якой адкладзены на нявызначаны час. Раней зваротныя выклікі выкарыстоўваліся ў тых участках кода, дзе вынік не мог быць атрыманы адразу.

Вось прыклад асінхроннага чытання файла на Node.js:

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

Праблемы ўзнікаюць у той момант, калі патрабуецца выканаць адразу некалькі асінхронных аперацый. Давайце ўявім сабе вось такі сцэнар: выконваецца запыт у БД карыстальніка Arfat, трэба лічыць яго поле profile_img_url і загрузіць карцінку з сервера someserver.com.
Пасля загрузкі канвертоўны малюнак у іншы фармат, напрыклад з PNG у JPEG. Калі канвертаванне прайшло паспяхова, на пошту карыстальніка адпраўляецца ліст. Далей інфармацыя аб падзеі заносіцца ў файл transformations.log з указаннем даты.

Варта звярнуць увагу на накладзенасць зваротных выклікаў і вялікую колькасць }) у фінальнай частцы кода. Гэта называецца Callback Hell ці Pyramid of Doom.

Недахопы такога спосабу відавочныя:

  • Гэты код складана чытаць.
  • У ім таксама складана апрацоўваць памылкі, што часта прыводзіць да пагаршэння якасці кода.

Для таго каб вырашыць гэтую праблему, у JavaScript былі дададзеныя промісы. Яны дазваляюць замяніць глыбокую ўкладзенасць коллбэкаў словам .then.

Дадатным момантам промісаў стала тое, што з імі код чытаецца значна лепш, прычым зверху ўніз, а не злева направа. Тым не менш у місіс таксама ёсць свае праблемы:

  • Трэба дадаваць вялікую колькасць .then.
  • Замест try/catch выкарыстоўваецца .catch для апрацоўкі ўсіх памылак.
  • Праца з некалькімі промісамі ў рамках аднаго цыклу далёка не заўсёды зручная, у некаторых выпадках яны ўскладняюць код.

Вось задача, якая пакажа значэнне апошняга пункта.

Выкажам здагадку, што ёсць цыкл for, які выводзіць паслядоўнасць лікаў ад 0 да 10 са выпадковым інтэрвалам (0-n секунд). Выкарыстоўваючы промісы, трэба змяніць гэты цыкл такім чынам, каб лікі выводзіліся ў паслядоўнасці ад 0 да 10. Так, калі выснова за нуль займае 6 секунд, а адзінкі — 2 секунды, спачатку павінен быць выведзены нуль, а потым ужо пачнецца адлік вываду адзінкі.

І вядома, для рашэння гэтай задачы мы не выкарыстоўваем Async/Await або .sort. Прыклад рашэння - у канцы.

Async-функцыі

Даданне async-функцый у ES2017 (ES8) спрасціла задачу працы з промісамі. Адзначу, што async-функцыі працуюць «па-над» промісамі. Гэтыя функцыі не ўяўляюць сабой якасна іншыя канцэпцыі. Async-функцыі задумваліся як альтэрнатыва коду, які выкарыстоўвае промісы.

Async/Await дае магчымасць арганізаваць працу з асінхронным кодам у сінхронным стылі.

Такім чынам, веданне промісаў палягчае разуменне прынцыпаў Async/Await.

сінтаксіс

У звычайнай сітуацыі ён складаецца з двух ключавых слоў: async і await. Першае слова і ператварае функцыю ў асінхронную. У такіх функцыях дазваляецца выкарыстанне await. У любым іншым выпадку выкарыстанне гэтай функцыі выклікае памылку.

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async устаўляецца ў самым пачатку аб'явы функцыі, а ў выпадку выкарыстання стрелочной функцыі - паміж знакам "=" і дужкамі.

Гэтыя функцыі можна змясціць у аб'ект у якасці метадаў або ж выкарыстоўваць у аб'яве класа.

// As an object's method
 
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}
 
// In a class
 
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

NB! Варта памятаць, што канструктары класа і гетэры/сетэры не могуць быць асінхроннымі.

Семантыка і правілы выканання

Async-функцыі, у прынцыпе, падобныя на стандартныя JS-функцыі, але ёсць і выключэнні.

Так, async-функцыі заўсёды вяртаюць промісы:

async function fn() {
  return 'hello';
}
fn().then(console.log)
// hello

У прыватнасці, fn вяртае радок hello. Ну а паколькі гэта асінхронная функцыя, тое значэнне радка абгортваецца ў проміс пры дапамозе канструктара.

Вось альтэрнатыўная канструкцыя без Async:

function fn() {
  return Promise.resolve('hello');
}
 
fn().then(console.log);
// hello

У гэтым выпадку вяртанне проміса вырабляецца "ўручную". Асінхронная функцыя заўсёды абгортваецца ў новы проміс.

У тым выпадку, калі якое вяртаецца значэнне - прымітыў, async-функцыя выконвае зварот значэння, абгортваючы яго ў проміс. У тым выпадку, калі якое вяртаецца значэнне і ёсць аб'ект промісу, яго рашэнне вяртаецца ў новым промісе.

const p = Promise.resolve('hello')
p instanceof Promise;
// true
 
Promise.resolve(p) === p;
// true
 

Але што адбудзецца ў тым выпадку, калі ўсярэдзіне асінхроннай функцыі апынецца памылка?

async function foo() {
  throw Error('bar');
}
 
foo().catch(console.log);

Калі яна не будзе апрацавана, foo() верне проміс з рэджэктам. У гэтай сітуацыі замест Promise.resolve вернецца Promise.reject, які змяшчае памылку.

Async-функцыі на вынахадзе заўсёды даюць проміс, па-за залежнасцю ад таго, што вяртаецца.

Асінхронныя функцыі прыпыняюцца пры кожным await.

Await уплывае на выразы. Так, калі выраз з'яўляецца промісам, async-функцыя прыпыняецца да моманту выканання промісу. У тым выпадку, калі выраз не з'яўляецца промісам, яно канвертуецца ў проміс праз Promise.resolve і потым завяршаецца.

// utility function to cause delay
// and get random value
 
const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};
 
async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
 
  return a + b * c;
}
 
// Execute fn
fn().then(console.log);

А вось апісанне таго, як працуе fn-функцыя.

  • Пасля яе выкліку першы радок канвертуецца з const a = await 9; у const a = await Promise.resolve(9);.
  • Пасля выкарыстання Await выкананне функцыі прыпыняецца, пакуль а не атрымлівае сваё значэнне (у бягучай сітуацыі гэта 9).
  • delayAndGetRandom(1000) прыпыняе выкананне fn-функцыі, пакуль не завершыцца сама (пасля 1 секунды). Гэта фактычна з'яўляецца прыпынкам fn-функцыі на 1 секунду.
  • delayAndGetRandom(1000) праз resolve вяртае выпадковае значэнне, якое затым прысвойваецца зменнай b.
  • Ну а выпадак з зменнай з аналагічны нагоды з зменнай а. Пасля гэтага ўсё спыняецца на секунду, але зараз delayAndGetRandom(1000) нічога не вяртае, паколькі гэтага не патрабуецца.
  • У выніку значэння лічацца па формуле a + b * c. Вынік жа абгортваецца ў проміс пры дапамозе Promise.resolve і вяртаецца функцыяй.

Гэтыя паўзы могуць нагадваць генератары ў ES6, але гэтаму ёсць свае прычыны.

Вырашаем задачу

Ну а зараз давайце разгледзім рашэнне задачы, якая была пазначана вышэй.

У функцыі finishMyTask выкарыстоўваецца Await для чакання вынікаў такіх аперацый, як queryDatabase, sendEmail, logTaskInFile і іншых. Калі ж параўноўваць гэтае рашэнне з тым, дзе выкарыстоўваліся промісы, стане відавочным падабенства. Тым не менш версія з Async/Await даволі моцна спрашчае ўсе сінтаксічныя складанасці. У гэтым выпадку няма вялікай колькасці коллбэкаў і ланцужкоў накшталт .then/.catch.

Вось рашэнне з вывадам лікаў, тут ёсць два варыянты.

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
 
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});
 
// Implementation Two (Using Recursion)
 
const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {
 
    if (i === 10) {
      return undefined;
    }
 
    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};

А вось рашэнне з выкарыстаннем async-функцый.

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

Апрацоўка памылак

Неапрацаваныя памылкі абгортваюцца ў rejected проміс. Тым не менш у async-функцыях можна выкарыстоўваць канструкцыю try/catch для таго, каб выканаць сінхронную апрацоўку памылак.

async function canRejectOrReturn() {
  // wait one second
  await new Promise(res => setTimeout(res, 1000));
 
// Reject with ~50% probability
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }
 
return 'perfect number';
}

canRejectOrReturn() - гэта асінхронная функцыя, якая або ўдала выконваецца ("perfect number"), або няўдала завяршаецца з памылкай ("Sorry, number too big").

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Паколькі ў прыкладзе вышэй чакаецца выкананне canRejectOrReturn, тое ўласнае няўдалае завяршэнне пацягне за сабой выкананне блока catch. У выніку функцыя foo завершыцца альбо з undefined (калі ў блоку try нічога не вяртаецца), альбо з error caught. У выніку ў гэтай функцыі не будзе няўдалага завяршэння, паколькі try/catch зоймецца апрацоўкай самой функцыі foo.

Вось яшчэ прыклад:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Варта надаць увагу таму, што ў прыкладзе з foo вяртаецца canRejectOrReturn. Foo у гэтым выпадку завяршаецца альбо perfect number, альбо вяртаецца памылка Error (“Sorry, number too big”). Блок catch ніколі не будзе выконвацца.

Праблема ў тым, што foo вяртае проміс, перададзены ад canRejectOrReturn. Таму рашэнне функцыі foo становіцца рашэннем для canRejectOrReturn. У гэтым выпадку код будзе складацца ўсяго з двух радкоў:

try {
    const promise = canRejectOrReturn();
    return promise;
}

А вось што будзе, калі выкарыстоўваць разам await і return:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

У кодзе вышэй foo удала завершыцца як з perfect number, так і з error caught. Тут адмоў не будзе. Але foo завершыцца з canRejectOrReturn, а не з undefined. Давайце пераканаемся ў гэтым, прыбраўшы радок return await canRejectOrReturn():

try {
    const value = await canRejectOrReturn();
    return value;
}
// …

Распаўсюджаныя памылкі і падводныя камяні

У некаторых выпадках выкарыстанне Async/Await можа прыводзіць да памылак.

Забыты await

Такое здараецца досыць часта - перад промісам забываецца ключавое слова await:

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}

У кодзе, як відаць, няма ні await, ні return. Таму foo заўседы завяршаецца з undefined без затрымкі ў 1 секунду. Але проміс будзе выконвацца. Калі ж ён выдае памылку ці рэджэкт, то ў гэтым выпадку будзе выклікацца UnhandledPromiseRejectionWarning.

Async-функцыі ў зваротных выкліках

Async-функцыі даволі часта выкарыстоўваюцца ў .map або .filter у якасці коллбэкаў. У якасці прыкладу можна прывесці функцыю fetchPublicReposCount(username), якая вяртае колькасць адчыненых на GitHub рэпазітароў. Дапушчальны, ёсць тры карыстача, чые паказчыкі нам патрэбныя. Вось код для гэтай задачы:

const url = 'https://api.github.com/users';
 
// Utility fn to fetch repo counts
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

Нам патрэбны акаўнты ArfatSalman, octocat, norvig. У гэтым выпадку выконваем:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];
 
const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

Варта звярнуць увагу на Await у зваротным выкліку. Тут counts - масіў промісаў, ну а. map - ананімны зваротны выклік для кожнага названага карыстальніка.

Празмерна паслядоўнае выкарыстанне await

У якасці прыкладу возьмем такі код:

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}

Тут у зменную count змяшчаецца лік РЭПО, затым гэты лік дадаецца ў масіў counts. Праблема кода ў тым, што пакуль з сервера не прыйдуць дадзеныя першага карыстача, усе наступныя карыстачы будуць знаходзіцца ў рэжыме чакання. Такім чынам, у адзіны момант апрацоўваецца толькі адзін карыстач.

Калі, напрыклад, на апрацоўку аднаго карыстальніка сыходзіць каля 300 мс, то для ўсіх карыстальнікаў гэта ўжо секунда, які затрачваецца час лінейна залежыць ад колькасці карыстальнікаў. Але раз атрыманне колькасці РЭПО не залежыць адзін ад аднаго, працэсы можна распаралеліць. Для гэтага патрэбна праца з .map і Promise.all:

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all на ўваходзе атрымлівае масіў промісаў з вяртаннем промісу. Апошні пасля завяршэння ўсіх промісаў у масіве або пры першым рэдэкце завяршаецца. Можа здарыцца так, што ўсе яны не запусцяцца адначасова, - для таго каб забяспечыць адначасовы запуск, можна выкарыстоўваць p-map.

Заключэнне

Async-функцыі становяцца ўсё важнейшымі для распрацоўкі. Ну а для адаптыўнага выкарыстання async-функцый варта карыстацца Async Iterators. JavaScript-распрацоўшчык павінен добра разбірацца ў гэтым.

Skillbox рэкамендуе:

Крыніца: habr.com

Дадаць каментар