Ας δούμε το Async/Await σε JavaScript χρησιμοποιώντας παραδείγματα

Ο συγγραφέας του άρθρου εξετάζει παραδείγματα Async/Await σε JavaScript. Συνολικά, το Async/Await είναι ένας βολικός τρόπος για να γράψετε ασύγχρονο κώδικα. Προτού εμφανιστεί αυτό το χαρακτηριστικό, αυτός ο κώδικας γράφτηκε χρησιμοποιώντας επανάκληση και υποσχέσεις. Ο συγγραφέας του αρχικού άρθρου αποκαλύπτει τα πλεονεκτήματα του Async/Await αναλύοντας διάφορα παραδείγματα.

Υπενθύμιση: για όλους τους αναγνώστες του "Habr" - έκπτωση 10 ρούβλια κατά την εγγραφή σε οποιοδήποτε μάθημα Skillbox χρησιμοποιώντας τον κωδικό προσφοράς "Habr".

Το 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. Εάν η μετατροπή ήταν επιτυχής, αποστέλλεται μια επιστολή στο email του χρήστη. Στη συνέχεια, πληροφορίες σχετικά με το συμβάν εισάγονται στο αρχείο transformations.log, υποδεικνύοντας την ημερομηνία.

Αξίζει να προσέξετε την επικάλυψη των ανακλήσεων και τον μεγάλο αριθμό }) στο τελευταίο μέρος του κώδικα. Ονομάζεται Callback Hell ή Pyramid of Doom.

Τα μειονεκτήματα αυτής της μεθόδου είναι προφανή:

  • Αυτός ο κώδικας είναι δύσκολο να διαβαστεί.
  • Είναι επίσης δύσκολος ο χειρισμός σφαλμάτων, γεγονός που συχνά οδηγεί σε κακή ποιότητα κώδικα.

Για να λυθεί αυτό το πρόβλημα, προστέθηκαν υποσχέσεις στο JavaScript. Σας επιτρέπουν να αντικαταστήσετε τη βαθιά ένθεση των ανακλήσεων με τη λέξη .then.

Η θετική πτυχή των υποσχέσεων είναι ότι κάνουν τον κώδικα πολύ πιο ευανάγνωστο, από πάνω προς τα κάτω και όχι από αριστερά προς τα δεξιά. Ωστόσο, οι υποσχέσεις έχουν και τα προβλήματά τους:

  • Πρέπει να προσθέσετε πολλά .τότε.
  • Αντί για try/catch, το .catch χρησιμοποιείται για τον χειρισμό όλων των σφαλμάτων.
  • Η εργασία με πολλές υποσχέσεις σε έναν βρόχο δεν είναι πάντα βολική· σε ορισμένες περιπτώσεις, περιπλέκουν τον κώδικα.

Εδώ υπάρχει ένα πρόβλημα που θα δείξει το νόημα του τελευταίου σημείου.

Ας υποθέσουμε ότι έχουμε έναν βρόχο for που εκτυπώνει μια ακολουθία αριθμών από το 0 έως το 10 σε τυχαία διαστήματα (0–n δευτερόλεπτα). Χρησιμοποιώντας υποσχέσεις, πρέπει να αλλάξετε αυτόν τον βρόχο έτσι ώστε οι αριθμοί να εκτυπώνονται με τη σειρά από το 0 έως το 10. Έτσι, εάν χρειάζονται 6 δευτερόλεπτα για να εκτυπώσετε ένα μηδέν και 2 δευτερόλεπτα για να εκτυπώσετε ένα, πρέπει πρώτα να εκτυπωθεί το μηδέν και μετά θα ξεκινήσει η αντίστροφη μέτρηση για την εκτύπωση του ενός.

Και φυσικά, δεν χρησιμοποιούμε Async/Await ή .sort για να λύσουμε αυτό το πρόβλημα. Ένα παράδειγμα λύσης βρίσκεται στο τέλος.

Ασύγχρονες λειτουργίες

Η προσθήκη λειτουργιών async στο ES2017 (ES8) απλοποίησε το έργο της εργασίας με υποσχέσεις. Σημειώνω ότι οι ασύγχρονες λειτουργίες λειτουργούν «πάνω» από τις υποσχέσεις. Αυτές οι συναρτήσεις δεν αντιπροσωπεύουν ποιοτικά διαφορετικές έννοιες. Οι Async συναρτήσεις προορίζονται ως εναλλακτική λύση στον κώδικα που χρησιμοποιεί υποσχέσεις.

Το Async/Await καθιστά δυνατή την οργάνωση της εργασίας με ασύγχρονο κώδικα σε σύγχρονο στυλ.

Έτσι, η γνώση υποσχέσεων διευκολύνει την κατανόηση των αρχών του Async/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');
  }
}

Σημείωση! Αξίζει να θυμόμαστε ότι οι κατασκευαστές κλάσεων και οι λήπτες/ρυθμιστές δεν μπορούν να είναι ασύγχρονοι.

Σημασιολογία και κανόνες εκτέλεσης

Οι Async συναρτήσεις είναι βασικά παρόμοιες με τις τυπικές συναρτήσεις JS, αλλά υπάρχουν εξαιρέσεις.

Έτσι, οι ασύγχρονες συναρτήσεις επιστρέφουν πάντα υποσχέσεις:

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.reject που περιέχει ένα σφάλμα θα επιστραφεί αντί για το Promise.resolve.

Οι Async συναρτήσεις εξάγουν πάντα μια υπόσχεση, ανεξάρτητα από το τι επιστρέφεται.

Οι ασύγχρονες λειτουργίες παύουν σε κάθε αναμονή.

Η αναμονή επηρεάζει τις εκφράσεις. Έτσι, εάν η έκφραση είναι υπόσχεση, η συνάρτηση 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 = αναμονή Promise.resolve(9);.
  • Μετά τη χρήση του Await, η εκτέλεση της συνάρτησης αναστέλλεται έως ότου το a πάρει την τιμή του (στην τρέχουσα κατάσταση είναι 9).
  • Το delayAndGetRandom(1000) διακόπτει την εκτέλεση της συνάρτησης fn μέχρι να ολοκληρωθεί (μετά από 1 δευτερόλεπτο). Αυτό σταματά αποτελεσματικά τη λειτουργία fn για 1 δευτερόλεπτο.
  • Το delayAndGetRandom(1000) μέσω ανάλυσης επιστρέφει μια τυχαία τιμή, η οποία στη συνέχεια εκχωρείται στη μεταβλητή b.
  • Λοιπόν, η περίπτωση με τη μεταβλητή c είναι παρόμοια με την περίπτωση της μεταβλητής a. Μετά από αυτό, όλα σταματούν για ένα δευτερόλεπτο, αλλά τώρα το 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() {
  // 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() είναι μια ασύγχρονη συνάρτηση που είτε πετυχαίνει ("τέλειος αριθμός") είτε αποτυγχάνει με ένα σφάλμα ("Συγγνώμη, ο αριθμός είναι πολύ μεγάλος").

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

Εφόσον το παραπάνω παράδειγμα αναμένει την εκτέλεση του canRejectOrReturn, η δική του αποτυχία θα έχει ως αποτέλεσμα την εκτέλεση του μπλοκ catch. Ως αποτέλεσμα, η συνάρτηση foo θα τελειώσει είτε με απροσδιόριστο (όταν δεν επιστρέφεται τίποτα στο μπλοκ δοκιμής) είτε με εντοπισμένο σφάλμα. Ως αποτέλεσμα, αυτή η λειτουργία δεν θα αποτύχει επειδή το try/catch θα χειριστεί τη λειτουργία foo από μόνη της.

Εδώ είναι ένα άλλο παράδειγμα:

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

Αξίζει να δοθεί προσοχή στο γεγονός ότι στο παράδειγμα, το canRejectOrReturn επιστρέφεται από το foo. Το Foo σε αυτήν την περίπτωση είτε τερματίζει με έναν τέλειο αριθμό είτε επιστρέφει ένα Σφάλμα («Συγγνώμη, ο αριθμός είναι πολύ μεγάλος»). Το μπλοκ catch δεν θα εκτελεστεί ποτέ.

Το πρόβλημα είναι ότι το foo επιστρέφει την υπόσχεση που δόθηκε από το canRejectOrReturn. Έτσι, η λύση στο foo γίνεται η λύση για το canRejectOrReturn. Σε αυτήν την περίπτωση, ο κώδικας θα αποτελείται από δύο μόνο γραμμές:

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

Δείτε τι συμβαίνει αν χρησιμοποιήσετε την αναμονή και επιστρέψετε μαζί:

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

Στον παραπάνω κώδικα, το foo θα βγει επιτυχώς με έναν τέλειο αριθμό και ένα σφάλμα. Εδώ δεν θα υπάρξουν αρνήσεις. Αλλά το foo θα επιστρέψει με το canRejectOrReturn, όχι με το undefined. Ας βεβαιωθούμε για αυτό αφαιρώντας τη γραμμή αναμονής επιστροφής canRejectOrReturn():

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

Συνήθη λάθη και παγίδες

Σε ορισμένες περιπτώσεις, η χρήση Async/Await μπορεί να οδηγήσει σε σφάλματα.

Ξεχασμένη αναμονή

Αυτό συμβαίνει αρκετά συχνά - η λέξη-κλειδί αναμονής ξεχνιέται πριν από την υπόσχεση:

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

Όπως μπορείτε να δείτε, δεν υπάρχει αναμονή ή επιστροφή στον κωδικό. Επομένως το foo βγαίνει πάντα με undefined χωρίς καθυστέρηση 1 δευτερολέπτου. Αλλά η υπόσχεση θα εκπληρωθεί. Εάν εμφανίσει σφάλμα ή απόρριψη, τότε θα κληθεί UnhandledPromiseRejectionWarning.

Ασύγχρονες λειτουργίες στις Επιστροφές κλήσης

Οι Async συναρτήσεις χρησιμοποιούνται αρκετά συχνά στο .map ή στο .filter ως επανάκληση. Ένα παράδειγμα είναι η συνάρτηση fetchPublicReposCount(όνομα χρήστη), η οποία επιστρέφει τον αριθμό των ανοιχτών αποθετηρίων στο 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 στο .map callback. Εδώ το counts είναι μια σειρά από υποσχέσεις και το .map είναι μια ανώνυμη επιστροφή κλήσης για κάθε καθορισμένο χρήστη.

Υπερβολικά συνεπής χρήση της αναμονής

Ας πάρουμε αυτόν τον κώδικα ως παράδειγμα:

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

Εδώ ο αριθμός repo τοποθετείται στη μεταβλητή count και, στη συνέχεια, αυτός ο αριθμός προστίθεται στον πίνακα counts. Το πρόβλημα με τον κώδικα είναι ότι μέχρι να φτάσουν τα δεδομένα του πρώτου χρήστη από τον διακομιστή, όλοι οι επόμενοι χρήστες θα βρίσκονται σε κατάσταση αναμονής. Έτσι, μόνο ένας χρήστης υποβάλλεται σε επεξεργασία κάθε φορά.

Εάν, για παράδειγμα, χρειάζονται περίπου 300 ms για την επεξεργασία ενός χρήστη, τότε για όλους τους χρήστες είναι ήδη ένας δεύτερος· ο χρόνος που δαπανάται εξαρτάται γραμμικά από τον αριθμό των χρηστών. Αλλά επειδή η απόκτηση του αριθμού των repo δεν εξαρτάται η μία από την άλλη, οι διαδικασίες μπορούν να παραλληλιστούν. Αυτό απαιτεί εργασία με .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 προτείνει:

Πηγή: www.habr.com

Προσθέστε ένα σχόλιο