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

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

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

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

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

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

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

כבר הצגתי לך את 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 מאוחזרות ונקראות במקביל, אבל כתובות ה-URL "החכמות" ביט reduce יוחלף בקידוד רגיל, משעמם וקריא ללולאה.

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

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

Babel יעשה את זה עבורכם, הנה דוגמה דרך Babel REPL

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

הפלט לא כל כך יפה, אז כדאי להיזהר "bloat".

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

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

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