Prima di procedere alla lettura di questo articolo è fortemente consigliato consultare un'introduzione alle Promise, in quanto async/await è strettamente costruito su di esse. Inoltre è bene notare che gli esempi sottostanti sono stati tutti scritti utilizzando Node.js come ambiente di sviluppo, nonostante async/await possa essere utilizzato in altri ambienti basati su JavaScript. Per una guida su come installare Node.js è bene consultare questo articolo.

Async/Await è una caratteristica che JavaScript ha introdotto come standard a partire dalla versione ECMA-262 (8a edizione) risalente a Giugno 2017. Essa permette una gestione dei task asincroni molto efficiente e con una sintassi facilmente leggibile, rendendo il codice simile alla programmazione sincrona con la quale diversi programmatori si sentono a proprio agio. Ed è esattamente qui che risiede il vero potere di async/await, ovvero fornire un framework di gestione dei task asincroni che all'apparenza sembri sincrono, anche se in realtà è esattamente l'opposto.

Una funzione può essere definita con la keyword async, che la marca come funzione asincrona. Al loro interno queste funzioni possono invocare altre funzioni che restituiscono una Promise senza dover gestire la risposta con i metodi then catch ma utilizzando la keyword await per attendere il completamento del task asincrono e procedere dunque al normale flusso di esecuzione.

Vediamo subito un esempio pratico:

function wait(milliseconds) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('All right!');
    }, milliseconds);
  });
}

async function myAsyncFunction() {
  console.log('Begin');
  var result = await wait(2000);
  console.log('Result is: ' + result);
}

asyncCall();

In questo caso la funzione wait restituisce una Promise, la quale viene "risolta" dopo aver atteso un tot di secondi tramite la classica funzione setTimeout. All'interno della funzione myAsyncFunction viene invocata la funzione wait anteponendo la keyword awaitIn questo caso il flusso di esecuzione della funzione rimane bloccato fintanto che la Promise non viene risolta o rifiutata, esattamente come nella normale programmazione sincrona.

Vediamo un esempio più complesso. Supponiamo di voler ottenere il sorgente di una pagina web e di volerne estrapolare il suo page-title e scriverlo su file:

const Request = require('request');
const Cheerio = require('cheerio');
const fs = require('fs');

const getHtml = (url) => {
  return new Promise((resolve, reject) => {
    Request(url, (err, res, body) => {
      if (err) {
        reject(err); return;
      }
      resolve(body);
    });
  });
};

const getPageTitle = (html) => {
  const $ = Cheerio.load(html);
  return $('title').text();
};

const writeFile = (filePath, content) => {
  return new Promise((resolve, reject) => {
    fs.writeFile(filePath, content, (err) => {
      if (err) return reject(err);
      return resolve();
    });
  });
};

const main = async () => {
  try {
    let html = await getHtml('https://www.touchmultimedia.com');
    let pageTitle = getPageTitle(html); // sync
    await writeFile('my-file-name.txt', pageTitle);
    console.log('Successfully wrote file');
  } catch (e) {
    console.log('An error occurred')
    console.log(e);
  }
}

main();

In questo esempio abbiamo 4 funzioni:

  • getHtml, writeFile restituiscono una Promise, risolvendola dopo aver compiuto dei task asincroni
  • getPageTitle è una funzione sincrona
  • main restituisce una AsyncFunction

La funzione main contiene la keyword async, il che molto semplicemente indica che la funzione ritorna sempre una Promise. Se la funzione restituisce un valore di tipo non Promise allora JavaScript automaticamente incapsulerà quel valore in una risoluzione o rifiuto di Promise. Per meglio comprendere questa logica vediamo l'esempio seguente:

const main = async () => {
  try {
    let html = await getHtml('https://www.touchmultimedia.com');
    let pageTitle = getPageTitle(html); // sync
    await writeFile('my-file-name.txt', pageTitle);
    return 'Successfully wrote file';
  } catch (e) {
    console.log('An error occurred')
    return e;
  }
}

main().then(result => console.log(result))
  .catch(err => console.log(err));

Come si può notare la chiamata al metodo main restituisce una Promise, che potrà essere risolta o rifiutata.

All'interno della funzione main tutte le chiamate a funzione con await sono raggruppate in un try/catch. Questo è stato implementato per intercettare eventuali errori dovuti per esempio al sito non raggiungibile o al fallimento di scrittura su disco. Questo costrutto è sicuramente familiare ai programmatori che provengono da una scuola di programmazione sincrona.

Una cosa molto importante da ricordare è che await non può essere utilizzato nelle normali funzioni, ma solamente all'interno di funzioni dichiarate async.

Perchè utilizzarlo

Una volta che si ha appreso il meccanismo di funzionamento di async/await sorge spontanea la domanda sul perchè un programmatore dovrebbe utilizzarlo in luogo di una gestione a callback o a Promise. Vediamo attraverso degli esempi quali sono i reali vantaggi:

Gestione più efficiente degli errori

Tramite async/await è possibile gestire gli errori che provengono sia dal codice sincrono che da quello asincrono, tramite il classico costrutto try/catch. Con una gestione a callback o Promise dovremmo infatti gestire gli errori sia del codice sincrono che di quello asincrono, con il risultato di avere probabilmente del codice duplicato. Supponiamo per esempio di voler chiamare un'url che restituisce un JSON e di volerlo interpretare ed assegnare ad una variabile; in questo caso dobbiamo gestire sia gli errori di chiamata http che di interpretazione del JSON. Tramite le Promise avremmo una cosa del genere:

const Request = require('request');

const getGithubUser = (username) => {
  return new Promise((resolve, reject) => {
    Request.get({
      url: `https://api.github.com/users/${username}`,
      headers: {
        'User-Agent': 'request'
      }
    }, (err, res, body) => {
      if (err) return reject(err);
      return resolve(body);
    });
  });
};

const main = () => {
  return getGithubUser('defra91').then((data) => {
    try {
      let json = JSON.parse(data); 
    } catch (err) {
      console.log('Error while parsing JSON', err);
    }
  }).catch((err) => {
    console.log('An error occurred', err);
  });
};

main();

Come si può notare la gestione non è delle migliori e il codice sta già diventando piuttosto "sporco" già con poche righe di codice. Tramite async/await la gestione diventa più lineare e leggibile:

const main = async () => {
  try {
    let user = await getGithubUser('defra91');
    let userJson = JSON.parse(user); // JSON.parse is sync and can throw an exception
    return userJson;
  } catch (err) {
    console.log('An error occurred', err);
  }
};

main().then((resp) => {
  console.log(resp); // should print json
});

Gestione dei condizionali

Immaginiamo di dover ad un certo punto, sulla base di qualche condizione, dover prendere diverse strade nel flusso di esecuzione del programma. Tramite le Promise i condizionali sono piuttosto difficili da gestire e possono indurre a scrivere un'ingente quantità di codice, difficilmente gestibile e testabile. Vediamo subito un esempio:

const main = () => {
  return getGithubUser('defra91').then((data) => {
    let dataJson = JSON.parse(data); // for this example ignore try/catch for JSON.parse
    if (dataJson.followers > 100) {
      return dataJson;
    }
    return getGithubUser('agar3s').then((data) => {
      return JSON.parse(data);
    });
  });
};

main().then((resp) => {
  console.log(resp); // should print json
});

L'esempio è molto banale ma già ci si rende conto di come il metodo main si stia indentando sempre più, andando a complicarsi e rendendosi sempre meno leggibile. Tramite async/await le cose però decisamente cambiano:

const main = async () => {
  try {
    let user = JSON.parse(await getGithubUser('defra91'));
    if (user.followers > 100) return user;
    else return JSON.parse(await getGithubUser('agar3s'));
  } catch (err) {
    console.log('An error occurred', err);
  }
};

main().then((data) => {
  console.log(data);
});

Gestione di una catena di chiamate

Qualora dovessimo aver bisogno di fare una catena di chiamate asincrone l'utilizzo delle Promise ci pone nelle condizioni di dover scrivere una quantità notevole di then, oppure di utilizzare Promise.all. Questo approccio alla lunga può creare scarsa leggibilità e di conseguenza scarsa gestione:

const main = () => {
  let usernames = ['defra91', 'agar3s', 'alejonext', 'angusshire'];
  let promises = usernames.map(item => getGithubUser(item));
  return Promise.all(promises).then((data) => {
    data.forEach(item => console.log(item));
    return data.map(item => JSON.parse(item));
  })
};

main().then((data) => {
  data.forEach(item => console.log(item));
}).catch((err) => {
  console.log('An error occurred', err);
});

Come si può notare in questo caso ho dovuto costruire un Array di Promise e poi invocare il metodo Promise.all per risolverle tutte. In questo caso non ho scritto molto codice ma tramite async/await si può comunque ottenere un miglioramento:

const main = async () => {
  let usernames = ['defra91', 'agar3s', 'alejonext', 'angusshire'];
  try {
    let data = [];
    for (let i = 0; i < usernames.length; i++) {
      data.push(await JSON.parse(getGithubUser(usernames[i])));
    }
    return data;
  } catch (err) {
    return err;
  }
};

main().then((data) => {
  data.forEach(item => console.log(item));
}).catch((err) => {
  console.log('An error occurred', err);
});

Come si può notare da questo esempio il codice ha una sintassi molto simile a quella che ci si aspetterebbe da una funzione sincrona, infatti è stato possibile utilizzare un ciclo for per iterare su un array. Questo rende sicuramente il codice più leggibile e gestibile.

Conclusioni

Ricapitolando, la keyword async anteposta ad una dichiarazione di funzione ha i seguenti effetti:

  • Fa sì che la funzione restituisca sempre una Promise, anche implicitamente
  • Permette l'utilizzo di await al suo interno

La keyword await prima di una Promise fa sì che JavaScript attenda che la Promise venga risolta o rifiutata:

  • Se la Promise viene rifiutata allora viene lanciata un'eccezione con l'errore
  • Se la Promise viene risolta allora viene restituito il suo risultato, che può essere assegnato ad una variabile

Questa nuova caratteristica costituisce un ottimo framework per gestire i task asincroni in modo efficiente e con una maggiore leggibilità del codice. Numerosi progetti in NodeJs, tra cui il popolare framework Hapi.js, hanno iniziato ad utilizzarla, per cui è molto importante esserne a conoscenza e saperla gestire autonomamente, in modo da ampliare le proprie skill di programmazione JavaScript.

È importante sottolineare che l'utilizzo di callback o di Promise non rappresenta una cattiva gestione del codice asincrono. Al contrario, se gestiti con attenzione e con i giusti accorgimenti, non hanno nulla da invidiare all'utilizzo di async/await. Questo nuovo costrutto tuttavia rende la scrittura del codice decisamente più semplice per molti programatori, ma in fin dei conti è una scelta molto personale, che varia in base alle abitudini e ai gusti di ciascuno. Personalmente ritengo sia opportuno valutare caso per caso e adottare il costrutto che meglio si presta allo scenario corrente. La gestione dei task asincroni, al di là del costrutto che si utilizza, rimane comunque molto complesso e richiede buone conoscenze da parte del programmatore, per cui è buona prassi conoscere a fondo tutte le possibili soluzioni che possano rendere il codice più leggibile e manutenibile. Async/await in questo caso si pone come una novità molto interessante da valutare con attenzione.