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

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

Jake Archibald
Jake Archibald

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

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

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

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

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

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

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

מומלץ להשתמש בגישה של תרגום מקודש (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.

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

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

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

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