הבטחות ל-JavaScript: מבוא

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

Jake Archibald
Jake Archibald

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

[Drumroll begins]

הבטחות הגיעו ל-JavaScript!

[זיקוקים מתפוצצים, נייר נוצץ יורד מלמעלה, הקהל משתגע]

בשלב הזה, החשבון שלכם ייכלל באחת מהקטגוריות הבאות:

  • אנשים מריעים מסביב אליכם, אבל אתם לא בטוחים למה כל המהומה. אולי אתם אפילו לא בטוחים מהי "הבטחה". אתם יכולים להרים את הכתפיים, אבל משקל הנייר הנוצץ נופל עליכם. אם כן, אל דאגה, לקח לי המון זמן להבין למה כדאי לי לטפל בדברים האלה. מומלץ להתחיל מההתחלה.
  • מכים באוויר! הגיע הזמן, נכון? השתמשתם ב-Promise בעבר, אבל אתם לא אוהבים את העובדה שלכל ההטמעות יש ממשק API שונה במקצת. מהו ה-API של גרסת JavaScript הרשמית? מומלץ להתחיל בטרמינולוגיה.
  • כבר ידעתם על כך, וצוחקים על אלה שמתרגשים כאילו מדובר בחדשות. עכשיו אפשר ליהנות מהתחושה של עליונות, ואז לעבור ישירות אל הפניית ה-API.

תמיכה בדפדפנים ו-polyfill

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

  • Chrome: ‏ 32.
  • Edge: ‏ 12.
  • Firefox: 29.
  • Safari: 8.

מקור

כדי להביא דפדפנים חסרים הטמעה מלאה של הבטחות לתאימות למפרט, או להוסיף הבטחות לדפדפנים אחרים ול-Node.js, אפשר להיעזר בpolyfill (2k gzipped).

מה כל ההמולה?

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

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

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

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

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

לצערנו, בדוגמה שלמעלה, יכול להיות שהאירועים התרחשו לפני שהתחלנו להאזין להם, לכן אנחנו צריכים לפתור את הבעיה באמצעות המאפיין 'complete' של התמונות:

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

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

אירועים הם לא תמיד הדרך הטובה ביותר

אירועים הם פתרון מצוין לדברים שיכולים לקרות כמה פעמים באותו אובייקט – keyup, ‏ touchstart וכו'. באירועים האלה לא באמת מעניין מה קרה לפני שתיצמדו את הבורר. אבל כשמדובר בהצלחה/כישלון אסינכרוני, רצוי להשתמש במשהו כזה:

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

זה מה שמבטיחים עושים, אבל עם שמות טובים יותר. אם לרכיבי תמונות HTML הייתה שיטה 'ready' שמחזירה הבטחה, היינו יכולים לעשות את זה:

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

ברמה הבסיסית ביותר, הבטחות הן קצת כמו פונקציות event listener, חוץ מהעובדות הבאות:

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

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

מונחים של Promise

Domenic Denicola בדק את הטיוטה הראשונה של המאמר הזה והעניק לי ציון 'כישלון' בתחום המונחים. הוא העניש אותי, הכריח אותי להעתיק את States and Fates 100 פעמים וכתב מכתב דאגה להורים שלי. למרות זאת, עדיין יש לי ערבוב של חלק מהמונחים, אבל ריכזתי כאן את היסודות:

הבטחה יכולה להיות:

  • fulfilled – הפעולה שקשורה להבטחה בוצעה בהצלחה
  • rejected – הפעולה שקשורה להבטחה נכשלה
  • pending – הבקשה עדיין לא בוצעה או נדחתה
  • settled – הבקשה בוצעה או נדחתה

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

הבטחות מגיעות ל-JavaScript!

הבטחות קיימות כבר זמן מה בצורת ספריות, כמו:

להבטחות הקודמות ולהבטחות ב-JavaScript יש התנהגות סטנדרטית משותפת שנקראת Promises/A+. אם אתם משתמשים ב-jQuery, יש להם משהו דומה שנקרא Deferreds. עם זאת, פונקציות Deferred לא תואמות ל-Promise/A+, ולכן הן שונות במעט ופחות שימושיות. ל-jQuery יש גם סוג Promise, אבל זהו רק קבוצת משנה של Deferred ויש לו את אותן בעיות.

למרות שההטמעות של ההבטחות פועלות לפי התנהגות סטנדרטית, ממשקי ה-API הכוללים שלהן שונים. ה-promises של JavaScript דומים ב-API ל-RSVP.js. כך יוצרים הבטחה:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

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

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

כך משתמשים בהבטחה הזו:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

then() מקבלת שני ארגומנטים: קריאה חוזרת (callback) למקרה של הצלחה וקריאה חוזרת למקרה של כישלון. שני הקריאות החוזרות הן אופציונליות, כך שאפשר להוסיף קריאה חוזרת רק במקרה של הצלחה או כשל.

ההתחייבויות ב-JavaScript התחילו ב-DOM בתור 'Futures', שינו את השם ל-'Promises' ובסופו של דבר עברו ל-JavaScript. עדיף להשתמש בהם ב-JavaScript ולא ב-DOM, כי הם יהיו זמינים בהקשרים של JavaScript שאינם דפדפנים, כמו Node.js (השאלה אם הם ישתמשו בהם בממשקי ה-API המרכזיים שלהם היא שאלה אחרת).

למרות שהן תכונה של JavaScript, DOM לא חושש להשתמש בהן. למעשה, כל ממשקי ה-API החדשים של DOM עם שיטות אסינכרניות של הצלחה/כישלון ישתמשו ב-promises. אנחנו כבר עושים זאת בניהול מכסות, באירועי טעינת גופנים, ב-ServiceWorker, ב-Web MIDI, ב-Streams ובתכונות נוספות.

תאימות לספריות אחרות

ממשק ה-API של הבטחות ב-JavaScript יתייחס לכל דבר עם שיטה then() כאל התחייבות (או thenable בשפת הבטחות אנחה), כך שאם אתם משתמשים בספרייה שמחזירה התחייבות מסוג Q, זה בסדר, היא תפעל בצורה חלקה עם הבטחות החדשות ב-JavaScript.

עם זאת, כפי שציינתי, פונקציות ה-Deferred של jQuery הן קצת… לא מועילות. למרבה המזל, אפשר להמיר אותם להבטחות רגילות, ורצוי לעשות זאת בהקדם האפשרי:

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

כאן, הפונקציה $.ajax של jQuery מחזירה Deferred. מכיוון שיש לו את השיטה then(), אפשר להשתמש ב-Promise.resolve() כדי להפוך אותו להבטחה של JavaScript. עם זאת, לפעמים פונקציות מושהות מעבירות כמה ארגומנטים לפונקציות ה-callbacks שלהן, לדוגמה:

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

לעומת זאת, המערכת תתעלם מכל ההבטחות ב-JS מלבד הראשונה:

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

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

קוד אסינכרוני מורכב קל יותר לשימוש

בסדר, נתחיל לכתוב קוד. נניח שאנחנו רוצים:

  1. הצגת סמל טעינה כדי לציין שהטעינה מתבצעת
  2. אחזור נתוני JSON של סיפור, שמספקים לנו את הכותרת וכתובות ה-URL של כל פרק
  3. הוספת כותרת לדף
  4. אחזור של כל פרק
  5. הוספת הכתבה לדף
  6. איך מפסיקים את סיבוב הגלגל

… אבל גם להודיע למשתמש אם משהו השתבש בדרך. כדאי גם להפסיק את ה-spinner בשלב הזה, אחרת הוא ימשיך להסתובב, יסתחרר ויתרסק בממשק משתמש אחר.

כמובן שלא משתמשים ב-JavaScript כדי להציג כתבה, הצגה כ-HTML מהירה יותר, אבל התבנית הזו נפוצה למדי כשעובדים עם ממשקי API: אחזור של כמה נתונים, ואז ביצוע פעולה כלשהי בסיום.

נתחיל באחזור נתונים מהרשת:

שימוש ב-XMLHttpRequest כ-Promise

ממשקי API ישנים יעודכנו כך שישתמשו בהבטחות, אם ניתן לעשות זאת באופן תואם לאחור. XMLHttpRequest היא מועמדת מצוינת, אבל בינתיים נכתוב פונקציה פשוטה ליצירת בקשת GET:

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

עכשיו נשתמש בו:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

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

קישור (chaining)

then() הוא לא הסוף של הסיפור. אפשר לשרשר thens יחד כדי לשנות ערכים או להריץ פעולות אסינכררוניות נוספות אחת אחרי השנייה.

טרנספורמציה של ערכים

אפשר לשנות ערכים פשוט על ידי החזרת הערך החדש:

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

כדוגמה מעשית, נמשיך מהדוגמה הקודמת:

get('story.json').then(function(response) {
  console.log("Success!", response);
})

התגובה היא JSON, אבל אנחנו מקבלים אותה כרגע כטקסט פשוט. אפשר לשנות את פונקציית get כך שתשתמש ב-JSON‏ responseType, אבל אפשר גם לפתור את הבעיה בארץ ההבטחות:

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

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

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

למעשה, אפשר ליצור פונקציה getJSON() בקלות רבה:

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON() עדיין מחזירה הבטחה, שמאחזרת כתובת URL ואז מפענחת את התגובה כ-JSON.

הוספת פעולות אסינכרוניות לתור

אפשר גם לשרשר thens כדי להריץ פעולות אסינכרניות ברצף.

כשמחזירים משהו מקריאה חוזרת של then(), זה קצת קסם. אם מחזירים ערך, then() הבא יקרא עם הערך הזה. עם זאת, אם מחזירים משהו שדומה להבטחה, הקריאה הבאה ל-then() ממתינה לו, והיא נקראת רק כשהבטחה הזו מתקבלת (מצליחה או נכשלת). לדוגמה:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

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

אפשר גם ליצור שיטה של קיצור דרך כדי לקבל פרקים:

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

אנחנו לא מורידים את story.json עד שמפעילים את getChapter, אבל בפעם הבאה שמפעילים את getChapter אנחנו משתמשים שוב בהבטחה של הסיפור, כך ש-story.json אוחזר רק פעם אחת. Yay Promises!

טיפול בשגיאות

כפי שראינו קודם, הפונקציה then() מקבלת שני ארגומנטים, אחד להצלחה ואחד לכישלון (או fulfill ו-reject, בשפת ההבטחות):

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

אפשר גם להשתמש ב-catch():

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

אין שום דבר מיוחד ב-catch(), זה רק גליק ל-then(undefined, func), אבל קל יותר לקרוא אותו. חשוב לזכור ששתי דוגמאות הקוד שלמעלה פועלות באופן שונה. הדוגמה השנייה זהה לקוד הבא:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

ההבדל הוא עדין, אבל מאוד שימושי. דחייה של הבטחה תדלג אל then() הבא עם קריאה חוזרת (או catch(), כי זה שווה ערך). כשהערך הוא then(func1, func2), תתבצע קריאה ל-func1 או ל-func2, אבל אף פעם לא לשתיהן. עם זאת, אם תשתמשו ב-then(func1).catch(func2), שתי הפונקציות יישלחו אם func1 תדחה, כי הן שלבים נפרדים בשרשרת. צריך לצלם את הפרטים הבאים:

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

התהליך שלמעלה דומה מאוד ל-try/catch רגיל ב-JavaScript, ושגיאות שמתרחשות בתוך 'try' עוברות מיד לבלוק catch(). הנה תרשים תהליך של השלבים שלמעלה (כי אני אוהב תרשימי תהליך):

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

חריגות והבטחות ב-JavaScript

דחיות מתרחשות כשהבטחה נדחית באופן מפורש, אבל גם באופן משתמע אם נזרקת שגיאה ב-callback של ה-constructor:

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

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

אותו הדבר חל על שגיאות שמופיעות בקריאות חזרה (callbacks) של then().

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

טיפול בשגיאות בפועל

באמצעות הסיפור והפרקים שלנו, אנחנו יכולים להשתמש ב-catch כדי להציג שגיאה למשתמש:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

אם האחזור של story.chapterUrls[0] נכשל (למשל, http 500 או שהמשתמש אופליין), המערכת תדלג על כל קריאות החזרה (callbacks) הבאות עם הצלחה, כולל זו שב-getJSON() שמנסה לנתח את התגובה כ-JSON, ותדלג גם על קריאת החזרה שמוסיפה את chapter1.html לדף. במקום זאת, היא עוברת ל-catch callback. כתוצאה מכך, אם אחת מהפעולות הקודמות נכשלה, המערכת תוסיף לדף את ההודעה 'לא ניתן להציג פרק'.

בדומה ל-try/catch ב-JavaScript, השגיאה מתגלה והקוד הבא ממשיך, כך שהכוכבים תמיד מוסתרים, וזה מה שאנחנו רוצים. הקוד שלמעלה הופך לגרסה אסינכרונית לא חוסמת של:

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

יכול להיות שתרצו להשתמש ב-catch() רק למטרות רישום ביומן, בלי לשחזר מהשגיאה. כדי לעשות זאת, פשוט זורקים מחדש את השגיאה. אפשר לעשות זאת בשיטה getJSON():

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

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

ביצוע פעולות במקביל וסדר פעולות: איך נהנים מהיתרונות של שניהם

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

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

זה עובד! אבל הוא מסנכרן את הדפדפן ונועלים אותו בזמן ההורדות. כדי שהפעולה תתבצע באופן אסינכרוני, אנחנו משתמשים ב-then() כדי שהדברים יתרחשו בזה אחר זה.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

אבל איך אפשר לעבור בחזרה על כתובות ה-URL של הפרקים ולאחזר אותן לפי הסדר? לא ניתן לעשות את הפעולות הבאות:

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

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

יצירת רצף

אנחנו רוצים להפוך את מערך chapterUrls לרצף של הבטחות. אנחנו יכולים לעשות זאת באמצעות then():

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

זו הפעם הראשונה שראינו את Promise.resolve(), שמייצר הבטחה שמתקבלת בהתאם לערך שתספקו לו. אם מעבירים לו מופע של Promise, הוא פשוט מחזיר אותו (הערה: זהו שינוי במפרט שחלק מההטמעות עדיין לא פועלות לפיו). אם מעבירים לו משהו שדומה להבטחה (יש לו method‏ then()), הוא יוצר Promise אמיתי שמספק/דוחה באותו אופן. אם מעבירים ערך אחר, למשל Promise.resolve('Hello'), הוא יוצר הבטחה שמתמלאת בערך הזה. אם קוראים לפונקציה ללא ערך, כמו למעלה, היא ממלאת את הערך 'undefined'.

יש גם את Promise.reject(val), שמייצר הבטחה שנדחית עם הערך שתספקו לה (או עם undefined).

אפשר לנקות את הקוד שלמעלה באמצעות array.reduce:

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

הפונקציה הזו פועלת כמו הדוגמה הקודמת, אבל לא צריך את המשתנה 'sequence' נפרד. פונקציית ה-callback של reduce נקראת לכל פריט במערך. הפעם הראשונה שהערך של 'sequence' הוא Promise.resolve(), אבל בשאר הקריאות הערך של 'sequence' הוא הערך שהוחזר מהקריאה הקודמת. array.reduce שימושי מאוד כדי לצמצם מערך לערך יחיד, שבמקרה הזה הוא הבטחה.

נסכם את כל מה שאמרנו:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

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

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

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all מקבלת מערך של הבטחות ויוצרת הבטחה שתתבצע רק אחרי שכל ההבטחות יבוצעו. תקבלו מערך של תוצאות (כל מה שההבטחות מולאו לגביו) באותו הסדר שבו העברתם את ההבטחות.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

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

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

כדי לעשות זאת, אנחנו מאחזרים את ה-JSON של כל הפרקים בו-זמנית, ואז יוצרים רצף כדי להוסיף אותם למסמך:

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

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

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

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

סבב בונוס: יכולות מורחבות

מאז שכתבתי את המאמר הזה, היכולת להשתמש ב-Promises התרחבה מאוד. מאז Chrome 55, פונקציות אסינכרוניות אפשרו לכתוב קוד שמבוסס על הבטחה (promise) כאילו הוא סינכרוני, אבל בלי לחסום את הליבה הראשית. מידע נוסף על כך זמין במאמר שלי על פונקציות אסינכררוניות. יש תמיכה רחבה ב-Promises ובפונקציות אסינכררוניות בדפדפנים העיקריים. הפרטים מופיעים במאמרים בנושא Promise ופונקציה אסינכררונית ב-MDN.

תודה רבה ל-Anne van Kesteren,‏ Domenic Denicola,‏ Tom Ashworth,‏ Remy Sharp,‏ Addy Osmani,‏ Arthur Evans ו-Yutaka Hirano על בדיקת הטקסט ועל התיקונים וההמלצות.

תודה גם ל-Mathias Bynens על עדכון חלקים שונים במאמר.