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

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

Jake Archibald
Jake Archibald

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

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

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

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

תמיכת דפדפן

תמיכה בדפדפן

  • 55
  • 15
  • 52
  • 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);
  }
}

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

ערכי החזרה אסינכרוניים

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

פתרון לעקוף את התמיכה בדפדפן: מחוללים

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

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

אסנכרן את כל הדברים!

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

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