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

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

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

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

[תיפוף מתחיל]

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

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

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

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

תמיכה בדפדפן ו-polyfill

תמיכה בדפדפן

  • 32
  • 12
  • 29
  • 8

מקור

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

על מה הטרחה?

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 וכו'. באירועים כאלה לא ממש חשוב לכם מה קרה לפני שצירפתם את ה-listener. אבל כשמדובר בהצלחה/כישלון אסינכרוני, כדאי שתעדיף משהו כזה:

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

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

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

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

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

בהבטחה הבסיסית ביותר, הבטחות קצת דומות לאירועים אחרים, למעט:

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

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

טרמינולוגיה של הבטחה

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

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

  • fulfill – הפעולה שקשורה להבטחה הצליחה
  • rejected – הפעולה שקשורה להבטחה נכשלה
  • בהמתנה – החשבון עדיין לא מומש או נדחה
  • setted – מתקיים או נדחה

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

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

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

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

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

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

כמו 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, כי הם יהיו זמינים בהקשרים של JS שלא בדפדפן, כמו Node.js (אם הם משתמשים בהם בממשקי ה-API העיקריים שלהם, זו שאלה אחרת).

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

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

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

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

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

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

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

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

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

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

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

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

נכון, בואו נתכנת כמה דברים. נניח שאנחנו רוצים:

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

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

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

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

XMLHttpRequest מבטיח

ממשקי ה-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, החיים שלי יהיו שמחים יותר.

שרשור

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

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

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

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, אבל נוכל לפתור את הבעיה גם ב-Prosding:

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() עדיין מחזירה Progress, שמאחזרת כתובת URL ואז מנתחת את התגובה כ-JSON.

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

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

כשמחזירים משהו מקריאה חוזרת (callback) של 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 מאוחזר רק פעם אחת. יש הבטחות!

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

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

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() הבא באמצעות קריאה חוזרת (callback) של דחייה (או 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!");
})

התהליך שלמעלה דומה מאוד ל-JavaScript test/catch רגיל, שגיאות שמתרחשות ב- "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 constructor, כך ששגיאות מתגלות באופן אוטומטי והופכות לדחיות.

אותו עיקרון חל על שגיאות שהתקבלו ב-then() קריאות חוזרות (callback).

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

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

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

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

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

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

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

יצירת רצף

אנחנו רוצים להפוך את מערך 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, הוא פשוט יוחזר (הערה: זהו שינוי במפרט שחלק מההטמעות עדיין לא פועלות). אם מעבירים לו משהו כמו הבטחה (עם שיטה then()), זה יוצר Promise אמיתי שממלא או דוחה באותו אופן. אם מעבירים ערך אחר, למשל, Promise.resolve('Hello'), הוא יוצר הבטחה שמתממשת בערך הזה. אם קוראים לו ללא ערך, כפי שצוין למעלה, הוא ימלא את הערך "undefined".

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

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

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

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

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

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

מאז שכתבתי את המאמר הזה, היכולת להשתמש ב-Promises התרחבה מאוד. החל מגרסה 55 של Chrome, פונקציות אסינכרוניות מאפשרות לכתוב קוד מבוסס-הבטחה כאילו הוא סינכרוני, אבל בלי לחסום את ה-thread הראשי. אפשר לקרוא מידע נוסף על כך my async functions article. בדפדפנים המובילים יש תמיכה נרחבת גם בפונקציות Promises וגם בפונקציות אסינכרוניות. הפרטים מופיעים בחומר העזר בנושא Promise ופונקציה אסינכרונית של MDN.

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

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