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

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

Jake Archibald
Jake Archibald

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

[תופף מתחיל]

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

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

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

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

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

תמיכה בדפדפן

  • Chrome: 32.
  • קצה: 12.
  • Firefox: 29.
  • Safari: 8.

מקור

לספק דפדפנים שבהם אין אפשרות לבצע את ההטמעה באופן מלא, עד למפרט או להוסיף הבטחות לדפדפנים אחרים ול-Node.js, המילוי הפוליגוני (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
});

הפעולה הזו לא מזהה תמונות עם שגיאות לפני שהצלחנו להאזין להן them; לצערי ה-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 היה "מוכן" שהחזירה הבטחה, נוכל לעשות את זה:

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

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

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

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

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

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

הוכחה של Domanic Denicola – קריאת הטיוטה הראשונה של המאמר הזה וקיבלתי את הדירוג 'F' לטרמינולוגיה. הוא הכניס אותי לכלא, הוא אילץ אותי להעתיק מדינות וגורל 100 פעמים, וכתבתי מכתב מודאג להורים שלי. למרות שהיא הרבה מהמינוחים מבולבלים, אבל הנה העקרונות הבסיסיים:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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. לעצור את הציר

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

כמובן שלא תשתמשו ב-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 אבל אנחנו יכולים גם לפתור את הבעיה בהבטחה:

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.

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

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

אותו עיקרון חל על שגיאות בקריאות חוזרות (callback) של 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);
})

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

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

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

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

יצירת רצף

אנחנו רוצים להפוך את מערך 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())

זה עושה את אותו הדבר כמו הדוגמה הקודמת, אבל לא צריך 'רצף' מותאם אישית. הקריאה החוזרת של ההפחתה (callback) שלנו מופעלת לכל פריט במערך. 'רצף' הוא Promise.resolve() בפעם הראשונה, אבל בשאר הימים קורא ל'רצף' הוא כל מה שהחזרנו מהשיחה הקודמת. 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 התרחבה במידה רבה. החל מגרסה 55 של Chrome, פונקציות אסינכרוניות מאפשרות שימוש בקוד מבוסס-הבטחה כתוב כאילו הוא מסונכרן, אבל בלי לחסום את ה-thread הראשי. אפשר מידע נוסף על כך זמין במאמר על הפונקציות האסינכרוניות. יש תמיכה נרחבת בפונקציות Promises ובפונקציות אסינכרוניות בדפדפנים העיקריים. אפשר למצוא את הפרטים ב הבטחה ואסינכרוני פונקציה הפניה.

תודה רבה לאאן ואן קסטרן (Anne van Kesteren), דומני דניקולה (Tom Ashworth), רמי שארפ (Remy Sharp), עדי אוסמאני, ארתור אוונס ויוטקה היראנו, שביצעו הגהה וערכו תיקונים/המלצות.

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