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

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

Jake Archibald
Jake Archibald

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

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

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

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

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

Browser Support

  • Chrome: 55.
  • Edge: 15.
  • Firefox: 52.
  • Safari: 10.1.

Source

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

נניח שאתם רוצים לאחזר כתובת 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() מחזירה הבטחה שrejects עם 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!';
}

השלמת הפעולה שלמעלה נמשכת 1000 אלפיות שנייה, לעומת:

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());
  }
}
הקוד נראה הרבה יותר מסודר, אבל האחזור השני לא מתחיל עד שהאחזור הראשון נקרא במלואו, וכן הלאה. הבדיקה הזו איטית בהרבה מהדוגמה של ההבטחות שבה האחזור מתבצע במקביל. למרבה המזל, יש דרך ביניים אידיאלית.
מומלץ – יפה ומקביל
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

מומלץ להשתמש בגישה של תרגום מקודש (transpiling), כי אפשר פשוט להשבית אותה ברגע שדפדפני היעד תומכים בפונקציות אסינכררוניות. עם זאת, אם באמת אתם לא רוצים להשתמש בתרגום מקודש, תוכלו להשתמש ב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. חוץ מזה, הוא פועל באופן זהה.

פתרון עקיף: regenerator

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

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

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

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

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