Analizziamo Async/Await in JavaScript con esempi

L'autore dell'articolo analizza, con esempi, Async/Await in JavaScript. In generale, Async/Await è un modo conveniente per scrivere codice asincrono. Prima dell'introduzione di questa funzionalità, il codice simile veniva scritto utilizzando callback e promise. L'autore dell'articolo originale svela i vantaggi di Async/Await, analizzando diversi esempi.

Ricordiamo: per tutti i lettori di «Habr» — sconto di 10.000 rubli per l'iscrizione a qualsiasi corso Skillbox con il codice promozionale «Habr».

Skillbox consiglia: Corso online educativo «Sviluppatore Java».

Callback

Il callback è una funzione il cui richiamo è posticipato a un momento imprecisato. In precedenza, i callback venivano utilizzati in parti del codice dove il risultato non poteva essere ottenuto immediatamente.

Ecco un esempio di lettura asincrona di un file in Node.js:

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

I problemi si presentano quando è necessario eseguire più operazioni asincrone contemporaneamente. Immaginiamo uno scenario in cui viene effettuata una richiesta al database per l'utente Arfat, è necessario leggere il campo profile_img_url e caricare l'immagine dal server someserver.com.
Dopo il caricamento, convertiamo l'immagine in un altro formato, ad esempio da PNG a JPEG. Se la conversione ha esito positivo, viene inviato un messaggio di posta elettronica all'utente. Successivamente, le informazioni sull'evento vengono registrate nel file transformations.log con la data.

È importante notare l'annidamento dei callback e il grande numero di }) nella parte finale del codice. Questo è noto come Callback Hell o Pyramid of Doom.

Gli svantaggi di questo approccio sono evidenti:

  • Questo codice è difficile da leggere.
  • È altresì complicato gestire gli errori, il che spesso porta a una riduzione della qualità del codice.

Per affrontare questo problema, sono state introdotte le promise in JavaScript. Esse permettono di sostituire la profonda annidatura dei callback con la parola .then.

Un aspetto positivo delle promise è che il codice risulta molto più leggibile, in particolare dall'alto verso il basso, anziché da sinistra a destra. Tuttavia, anche le promise presentano alcune problematiche:

  • È necessario aggiungere un gran numero di .then.
  • Invece di try/catch, viene utilizzato .catch per gestire tutti gli errori.
  • Lavorare con più promise all'interno di un singolo ciclo non è sempre comodo; in alcuni casi, esse complicano il codice.

Ecco un compito che mostrerà l'importanza dell'ultimo punto.

Supponiamo di avere un ciclo for che stampa una sequenza di numeri da 0 a 10 con un intervallo casuale (0–n secondi). Utilizzando le promesse, dobbiamo modificare questo ciclo in modo che i numeri vengano stampati in sequenza da 0 a 10. Così, se la stampa dello zero richiede 6 secondi e quella dell'uno 2 secondi, prima deve essere stampato lo zero e poi inizia il conteggio dell'uno.

E naturalmente, per risolvere questo compito non utilizziamo Async/Await o .sort. Un esempio di soluzione è alla fine.

Funzioni Async

L'introduzione delle funzioni async in ES2017 (ES8) ha semplificato il lavoro con le promesse. È importante notare che le funzioni async operano "sopra" le promesse. Queste funzioni non rappresentano concetti qualitativamente diversi. Le funzioni async sono state pensate come un'alternativa al codice che utilizza promesse.

Async/Await consente di organizzare il lavoro con codice asincrono in uno stile sincrono.

Pertanto, conoscere le promesse facilita la comprensione dei principi di Async/Await.

Sintassi

In una situazione normale, consiste in due parole chiave: async e await. La prima parola trasforma la funzione in asincrona. In tali funzioni è consentito l'uso di await. In ogni altro caso, l'uso di questa funzione genererà un errore.

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

Async viene aggiunto all'inizio della dichiarazione della funzione, mentre nel caso di utilizzo di una funzione freccia — tra il simbolo "=" e le parentesi.

Queste funzioni possono essere inserite in un oggetto come metodi oppure utilizzate nella dichiarazione di una classe.

// 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! Vale la pena ricordare che i costruttori di classi e i getter/setter non possono essere asincroni.

Semantica e regole di esecuzione

Le funzioni Async, in linea di principio, sono simili alle funzioni JS standard, ma ci sono eccezioni.

Infatti, le funzioni async restituiscono sempre promesse:

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

In particolare, fn restituisce la stringa hello. Poiché si tratta di una funzione asincrona, il valore della stringa viene incapsulato in una promessa tramite il costruttore.

Ecco una costruzione alternativa senza Async:

function fn() {
  return Promise.resolve('hello');
}

fn().then(console.log);
// hello

In questo caso, il ritorno della promessa avviene "manualmente". Una funzione asincrona è sempre incapsulata in una nuova promessa.

Nel caso in cui il valore restituito sia un primitivo, la funzione async restituisce il valore racchiudendolo in una promessa. Se il valore restituito è un oggetto promessa, la sua risoluzione viene restituita in una nuova promessa.

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

Promise.resolve(p) === p;
// true
 

Ma cosa succede se all'interno di una funzione asincrona si verifica un errore?

async function foo() {
  throw Error('bar');
}

foo().catch(console.log);

Se non viene gestito, foo() restituirà una promessa con un rifiuto. In questa situazione, invece di Promise.resolve verrà restituito Promise.reject, contenente l'errore.

Le funzioni async restituiscono sempre una promessa, indipendentemente da ciò che viene restituito.

Le funzioni asincrone si mettono in pausa ad ogni await.

Await influisce sulle espressioni. Quindi, se l'espressione è una promessa, la funzione async si mette in pausa fino al momento dell'esecuzione della promessa. Se l'espressione non è una promessa, viene convertita in una promessa tramite Promise.resolve e poi completata.

// 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);

Ecco una descrizione di come funziona la funzione fn.

  • Dopo la sua chiamata, la prima riga viene convertita da const a = await 9; a const a = await Promise.resolve(9);.
  • Dopo l'uso di Await, l'esecuzione della funzione si interrompe finché a non ottiene il proprio valore (in questo caso 9).
  • delayAndGetRandom(1000) interrompe l'esecuzione della funzione fn, finché non si completa (dopo 1 secondo). Questo è praticamente come fermare la funzione fn per 1 secondo.
  • delayAndGetRandom(1000) restituisce un valore casuale tramite resolve, che viene poi assegnato alla variabile b.
  • E il caso della variabile c è analogo a quello della variabile a. Dopo di ciò, tutto si ferma per un secondo, ma ora delayAndGetRandom(1000) non restituisce nulla, poiché non è necessario.
  • Alla fine, i valori vengono calcolati secondo la formula a + b * c. Il risultato viene poi racchiuso in una promessa tramite Promise.resolve e restituito dalla funzione.

Queste pause possono ricordare i generatori in ES6, ma ci sono le proprie ragioni.

Risolvere il problema

Ora diamo un'occhiata alla soluzione del problema indicato sopra.

Nella funzione finishMyTask viene utilizzato Await per attendere i risultati di operazioni come queryDatabase, sendEmail, logTaskInFile e altre. Confrontando questa soluzione con quella che utilizzava le promesse, diventa evidente la somiglianza. Tuttavia, la versione con Async/Await semplifica notevolmente tutte le complessità sintattiche. In questo caso, non ci sono un gran numero di callback e catene come .then/.catch.

Ecco una soluzione per l'output dei numeri, qui ci sono due varianti.

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);
    });
  });
};

Ecco una soluzione che utilizza funzioni async.

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

Gestione degli errori

Gli errori non gestiti vengono racchiusi in una promessa rifiutata. Tuttavia, nelle funzioni async si può utilizzare la struttura try/catch per eseguire una gestione degli errori sincrona.

async function canRejectOrReturn() {
  // aspetta un secondo
  await new Promise(res => setTimeout(res, 1000));

  // Rifiuta con probabilità ~50%
  if (Math.random() > 0.5) {
    throw new Error('Spiacente, numero troppo grande.');
  }

  return 'numero perfetto';
}

canRejectOrReturn() è una funzione asincrona che può completarsi con successo ('numero perfetto') o fallire con un errore ('Spiacente, numero troppo grande').

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'errore catturato';
  }
}

Poiché nell'esempio sopra ci si aspetta che venga eseguita canRejectOrReturn, un suo fallimento comporterà l'esecuzione del blocco catch. Di conseguenza, la funzione foo terminerà o con undefined (quando nel blocco try non viene restituito nulla) o con errore catturato. Pertanto, questa funzione non avrà un fallimento poiché il try/catch gestirà la stessa funzione foo.

Ecco un altro esempio:

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

È importante notare che nell'esempio di foo viene restituita canRejectOrReturn. In questo caso, foo terminerà o con numero perfetto o restituirà l'errore Error ('Spiacente, numero troppo grande'). Il blocco catch non verrà mai eseguito.

Il problema è che foo restituisce la promessa passata da canRejectOrReturn. Pertanto, la soluzione della funzione foo diventa la soluzione per canRejectOrReturn. In questo caso, il codice consisterà di sole due righe:

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

Ecco cosa accade se si utilizzano insieme await e return:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'errore catturato';
  }
}

Nel codice sopra foo terminerà con successo sia con numero perfetto che con errore catturato. Non ci saranno rifiuti. Tuttavia, foo si concluderà con canRejectOrReturn, non con undefined. Assicuriamoci di questo, rimuovendo la riga return await canRejectOrReturn():

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

Errori comuni e insidie

In alcuni casi, l'uso di Async/Await può portare a errori.

Await dimenticato

Questo accade abbastanza frequentemente: ci si dimentica della parola chiave await prima di una promessa:

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

Nel codice, come si vede, non ci sono né await, né return. Pertanto, foo termina sempre con undefined senza ritardi di 1 secondo. Ma la promessa sarà eseguita. Se si verifica un errore o viene rigettata, in questo caso verrà sollevato UnhandledPromiseRejectionWarning.

Funzioni Async nelle callback

Le funzioni Async sono frequentemente utilizzate in .map o .filter come callback. Un esempio potrebbe essere la funzione fetchPublicReposCount(username), che restituisce il numero di repository pubblici su GitHub. Supponiamo che ci siano tre utenti i cui dati ci servono. Ecco il codice per questo compito:

const url = 'https://api.github.com/users';

// Funzione di utilità per recuperare i conteggi dei repo
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

Abbiamo bisogno degli account ArfatSalman, octocat, norvig. In questo caso eseguiamo:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];

const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

Vale la pena notare Await nella callback di .map. Qui counts è un array di promesse, mentre .map è una callback anonima per ogni utente specificato.

Uso eccessivo e sequenziale di await

Prendiamo come esempio questo codice:

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;
}

Qui nella variabile count viene memorizzato il numero di repo, che viene poi aggiunto all’array counts. Il problema del codice è che fino a quando non arrivano i dati del primo utente dal server, tutti gli utenti successivi saranno in attesa. Pertanto, in un dato momento viene elaborato solo un utente.

Se, ad esempio, la lavorazione di un utente richiede circa 300 ms, allora per tutti gli utenti il tempo totale sarà già di un secondo, e il tempo impiegato dipende linearmente dal numero di utenti. Ma poiché il recupero del numero di repo non dipende l'uno dall'altro, i processi possono essere parallelizzati. Per questo è necessaria la funzione .map e 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 riceve un array di promesse che restituisce una promessa. L'ultima si completa dopo il termine di tutte le promesse nell'array o al primo rifiuto. Può capitare che tutte non vengano avviate contemporaneamente; per garantire avvii simultanei, si può utilizzare p-map.

Conclusione

Le funzioni Async stanno diventando sempre più importanti per lo sviluppo. Per un utilizzo adattivo delle funzioni async, vale la pena considerare Gli Iterators Async. Un sviluppatore JavaScript dovrebbe avere una buona comprensione di questo.

Skillbox consiglia:

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster