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

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

ג'ייק ארצ'יבלד
ג'ייק ארצ'יבלד

הפונקציות האסינכרוניות מופעלות כברירת מחדל ב-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);
  }
}

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

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

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

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