Разглеждаме Async/Await в JavaScript с примери

Авторът на статията разглежда примери за Async/Await в JavaScript. По същество, Async/Await е удобен начин за писане на асинхронен код. Преди появата на тази функция, подобен код се пишеше с използване на колбекове и промиси. Авторът на оригиналната статия разкрива предимствата на Async/Await, разглеждайки различни примери.

Напомняме: за всички читатели на «Хабра» — отстъпка от 10 000 рубли при записване на всеки курс Skillbox с промокод «Хабр».

Skillbox препоръчва: Образователен онлайн курс «Java-разработчик».

Колбек

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

Ето пример за асинхронно четене на файл на 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 или Пирамида на гибелта.

Недостатъците на този подход са очевидни:

  • Този код е трудно четим.
  • В него също така е сложно да се обработват грешки, което често води до влошаване на качеството на кода.

За да реши този проблем, 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 function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

Обработка на грешки

Необработените грешки се обгръщат в отказан промис. Въпреки това в асинхронните функции може да се използва конструкцията try/catch, за да се извърши синхронна обработка на грешките.

async function canRejectOrReturn() {
  // изчакване на една секунда
  await new Promise(res => setTimeout(res, 1000));

// Отказ с ~50% вероятност
  if (Math.random() > 0.5) {
    throw new Error('Съжалявам, числото е твърде голямо.')
  }

return 'перфектно число';
}

canRejectOrReturn() е асинхронна функция, която или завършва успешно ('перфектно число'), или се проваля с грешка ('Съжалявам, числото е твърде голямо.').

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'грешка уловена';
  }
}

Тъй като в примера по-горе се изчаква завършването на canRejectOrReturn, собственото неуспешно приключване ще доведе до изпълнението на блока catch. В резултат функцията foo ще завърши или с undefined (когато в блока try нищо не се връща), или с 'грешка уловена'. В края на краищата функцията няма да приключи неуспешно, тъй като try/catch ще се справи с обработката на самата функция foo.

Ето още един пример:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'грешка уловена';
  }
}

Важно е да се обърне внимание на това, че в примера от foo се връща canRejectOrReturn. Foo в този случай завършва или с 'перфектно число', или се връща грешка Error ('Съжалявам, числото е твърде голямо.'). Блокът catch никога няма да бъде изпълняван.

Проблемът е, че foo връща промис, предаден от canRejectOrReturn. Следователно решението на функцията foo става решението за canRejectOrReturn. В този случай кодът ще се състои само от две реда:

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

А ето какво ще се случи, ако се използват заедно await и return:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'грешка уловена';
  }
}

В показания код foo успешно завършва както с перфектно число, така и с уловена грешка. Тук няма да има откази. Но 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';

// Утилитна функция за извличане на броя на репозиториите
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 в обратното извикване на .map. Тук 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 ms, то за всички потребители времето, което се изразходва, е вече една секунда, а времето за изработка зависи линейно от броя на потребителите. Но тъй като получаването на броя на репозиторите не зависи един от друг, процесите могат да бъдат паралелизирани. За това е необходима работа с .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 итератори. JavaScript разработчикът трябва да разбира добре това.

Skillbox препоръчва:

Източник: habr.com

Купете надежден хостинг за сайтове с защита от DDoS, VPS VDS сървъри 🔥 Купете надежден хостинг за сайтове с защита от DDoS, VPS VDS сървъри | ProHoster