פונקציות אסינכרוניות: מתן הבטחות ידידותיות

פונקציות אסינכררוניות מאפשרות לכתוב קוד מבוסס-הבטחה כאילו הוא סינכרוני.

Jake Archibald
Jake Archibald

פונקציות אסינכררוניות מופעלות כברירת מחדל ב-Chrome, ב-Edge, ב-Firefox וב-Safari, והן פשוט נהדרות. הם מאפשרים לכתוב קוד מבוסס-הבטחה כאילו הוא סינכרוני, אבל בלי לחסום את ה-thread הראשי. הם הופכים את הקוד האסינכרוני פחות "חכם" לקריא יותר.

הפונקציות האסינכרוניות פועלות כך:

async function myFirstAsyncFunction() {
  try {
    const fulfilledValue = await promise;
  } catch (rejectedValue) {
    // …
  }
}

אם משתמשים במילות המפתח async לפני הגדרת פונקציה, אפשר להשתמש ב-await בתוך הפונקציה. כשמבצעים await של הבטחה, הפונקציה מושהית בדרך שלא חוסמת עד שההבטחה נשמרת. אם ההתחייבות מתקיימת, הערך מוחזר. אם ההבטחה נדחתה, הערך שנדחתה יידחה.

תמיכה בדפדפנים

תמיכה בדפדפנים

  • Chrome: 55.
  • קצה: 15.
  • Firefox: 52.
  • Safari: 10.1.

מקור

דוגמה לרישום ביומן של אחזור

נניח שאתם רוצים לאחזר כתובת URL ולרשום את התשובה כטקסט. כך זה נראה בעזרת הבטחות:

function logFetch(url) {
  return fetch(url)
    .then((response) => response.text())
    .then((text) => {
      console.log(text);
    })
    .catch((err) => {
      console.error('fetch failed', err);
    });
}

והנה אותו הדבר כשמשתמשים בפונקציות אסינכרוניות:

async function logFetch(url) {
  try {
    const response = await fetch(url);
    console.log(await response.text());
  } catch (err) {
    console.log('fetch failed', err);
  }
}

מספר השורות זהה, אבל כל הקריאות החוזרות הוסרו. כך קל יותר לקרוא את הקוד, במיוחד למי שלא מכיר היטב את ה-promises.

ערכים שמוחזרים בפעולות אסינכרוניות

פונקציות אסינכררוניות תמיד מחזירות הבטחה, גם אם משתמשים ב-await וגם אם לא. ההבטחה הזו מתקבלת עם הערך שהפונקציה האסינכרונית מחזירה, או נדחית עם הערך שהפונקציה האסינכרונית גורמת להשלכה שלו. לכן, עם:

// wait ms milliseconds
function wait(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function hello() {
  await wait(500);
  return 'world';
}

...קריאה לפונקציה hello() מחזירה הבטחה שממלאת את "world".

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

…קריאה ל-foo() מחזירה הבטחה שנדחית עם Error('bar').

דוגמה: העברה בזמן אמת של תגובה

היתרונות של פונקציות אסינכרניות גדלים בדוגמאות מורכבות יותר. נניח שרוצים להעביר תשובה בסטרימינג תוך רישום ביומן של קטעי הקוד, ולהחזיר את הגודל הסופי.

הנה זה עם הבטחות:

function getResponseSize(url) {
  return fetch(url).then((response) => {
    const reader = response.body.getReader();
    let total = 0;

    return reader.read().then(function processResult(result) {
      if (result.done) return total;

      const value = result.value;
      total += value.length;
      console.log('Received chunk', value);

      return reader.read().then(processResult);
    });
  });
}

תראו לי את ג'ייק, 'בעל ההבטחות', ארצ'יבלד. רואים איך אני קורא ל-processResult() בתוך עצמו כדי להגדיר לולאה אסינכרונית? כתיבת שגרמה לי להרגיש חכמה מאוד. אבל כמו רוב הקודים 'החכמים', צריך להביט בו במשך זמן רב כדי להבין מה הוא עושה, כמו אחת מהתמונות של 'עין הקסם' משנות ה-90.

ננסה שוב עם פונקציות אסינכרוניות:

async function getResponseSize(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  let result = await reader.read();
  let total = 0;

  while (!result.done) {
    const value = result.value;
    total += value.length;
    console.log('Received chunk', value);
    // get the next result
    result = await reader.read();
  }

  return total;
}

כל ה'חכמה' נעלמה. הלולאה האסינכרונית שגרמה לי להרגיש כל כך מרוצים הוחלפה בלולאת while מהימנה ומשעממת. הרבה יותר טוב. בעתיד, תוכלו להשתמש במחזורים אסינכרונים, שיאפשרו להחליף את הלולאה while בלולאה מסוג for-of, וכך להפוך את הקוד ליותר מסודר.

תחבירים אחרים של פונקציות אסינכררוניות

כבר הראיתי לכם את async function() {}, אבל אפשר להשתמש במילות המפתח async עם תחביר של פונקציות אחרות:

פונקציות חץ

// map some URLs to json-promises
const jsonPromises = urls.map(async (url) => {
  const response = await fetch(url);
  return response.json();
});

שיטות של אובייקטים

const storage = {
  async getAvatar(name) {
    const cache = await caches.open('avatars');
    return cache.match(`/avatars/${name}.jpg`);
  }
};

storage.getAvatar('jaffathecake').then(…);

שיטות של כיתות

class Storage {
  constructor() {
    this.cachePromise = caches.open('avatars');
  }

  async getAvatar(name) {
    const cache = await this.cachePromise;
    return cache.match(`/avatars/${name}.jpg`);
  }
}

const storage = new Storage();
storage.getAvatar('jaffathecake').then(…);

זהירות! הימנעו מרצף אירועים ארוך מדי

גם אם אתם כותבים קוד שנראה סינכרוני, חשוב לא לפספס את ההזדמנות לבצע דברים במקביל.

async function series() {
  await wait(500); // Wait 500ms…
  await wait(500); // …then wait another 500ms.
  return 'done!';
}

השלמת התהליך שלמעלה נמשכת 1,000 אלפיות השנייה, ואילו:

async function parallel() {
  const wait1 = wait(500); // Start a 500ms timer asynchronously…
  const wait2 = wait(500); // …meaning this timer happens in parallel.
  await Promise.all([wait1, wait2]); // Wait for both timers in parallel.
  return 'done!';
}

השלמת הפעולה שלמעלה נמשכת 500 אלפיות שנייה, כי שתי ההמתנות מתרחשות בו-זמנית. נבחן דוגמה מעשית.

דוגמה: הפלט של אחזור ברצף

נניח שאתם רוצים לאחזר סדרה של כתובות URL ולרשום אותן בהקדם האפשרי, בסדר הנכון.

נשימה עמוקה – כך זה נראה עם הבטחות:

function markHandled(promise) {
  promise.catch(() => {});
  return promise;
}

function logInOrder(urls) {
  // fetch all the URLs
  const textPromises = urls.map((url) => {
    return markHandled(fetch(url).then((response) => response.text()));
  });

  // log them in order
  return textPromises.reduce((chain, textPromise) => {
    return chain.then(() => textPromise).then((text) => console.log(text));
  }, Promise.resolve());
}

כן, זה נכון, אני משתמש ב-reduce כדי לשרשר רצף של הבטחות. אני כל כך חכם. אבל זו קצת קוד חכם מדי, עדיף להימנע ממנו.

עם זאת, כשממירים את הקוד שלמעלה לפונקציה אסינכררונית, קל ליפול לסדר כרונולוגי מוגזם:

לא מומלץ – רצף ארוך מדי
async function logInOrder(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    console.log(await response.text());
  }
}
הקוד נראה הרבה יותר מסודר, אבל האחזור השני לא מתחיל עד שהאחזור הראשון נקרא במלואו, וכן הלאה. הבדיקה הזו איטית בהרבה מהדוגמה ל-promises שבה האחזור מתבצע במקביל. למרבה המזל, יש דרך ביניים אידיאלית.
מומלץ – יפה ומקביל
function markHandled(...promises) {
  Promise.allSettled(promises);
}

async function logInOrder(urls) {
  // fetch all the URLs in parallel
  const textPromises = urls.map(async (url) => {
    const response = await fetch(url);
    return response.text();
  });

  markHandled(...textPromises);

  // log them in sequence
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}
בדוגמה הזו, כתובות ה-URL מאוחרות ומוקרות במקביל, אבל החלק reduce "החכם" מוחלף בלולאת for רגילה, משעממת וקריאה.

פתרון עקיף לתמיכה בדפדפנים: גנרטורים

אם אתם מטרגטים דפדפנים שתומכים בגנרטורים (כולל הגרסה האחרונה של כל דפדפן ראשי), אתם יכולים להשתמש ב-polyfill לפונקציות אסינכררוניות.

Babel יעשה זאת בשבילכם, כאן יש דוגמה באמצעות Babel REPL

אני ממליץ על גישת ההעברה, כי אפשר להשבית אותה ברגע שדפדפני היעד יתמכו בפונקציות אסינכרוניות, אבל אם אתם באמת לא רוצים להשתמש בטרנספילר, תוכלו להשתמש ב-polyfill של Babel ולהשתמש בו בעצמכם. במקום:

async function slowEcho(val) {
  await wait(1000);
  return val;
}

…צריך לכלול את ה-polyfill ולכתוב:

const slowEcho = createAsyncFunction(function* (val) {
  yield wait(1000);
  return val;
});

שימו לב שצריך להעביר מחולל (function*) ל-createAsyncFunction, ולהשתמש ב-yield במקום ב-await. חוץ מזה, זה עובד באותו אופן.

פתרון: יצירה מחדש

אם אתם מטרגטים דפדפנים ישנים, Babel יכול גם להמיר גנרטורים, מה שמאפשר לכם להשתמש בפונקציות אסינכרוניות עד ל-IE8. כדי לעשות זאת, צריך את הגדרת ברירת המחדל es2017 של Babel וגם את הגדרת ברירת המחדל es2015.

הפלט לא יפה, לכן חשוב להיזהר מקוד מיותר.

אסינכרוניזציה של הכול!

אחרי שפונקציות אסינכררוניות יהיו זמינות בכל הדפדפנים, כדאי להשתמש בהן בכל פונקציה שמחזירה הבטחה (promise). הם לא רק עוזרים לשמור על קוד מסודר, אלא גם מוודאים שהפונקציה תמיד תחזיר הבטחה.

התלהבתי מאוד מהפונקציות האסינכרוניות בשנת 2014, ועכשיו נהדר לראות שהן מגיעות לדפדפנים. יש!