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

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

Jake Archibald
Jake Archibald

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

[תופף מתחיל]

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

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

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

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) הנכון, למרות שהאירוע התרחש קודם לכן.

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

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

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

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

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

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

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

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

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

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

כמו ב-throw ב-JavaScript פשוט ישן, מקובל, אבל לא נדרש, לדחות באמצעות אובייקט Error. היתרון של אובייקטים מסוג 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 עם שיטות אסינכרוניות להצלחה או לכישלון ישתמשו בהבטחות. זה כבר קורה עם ניהול מכסות, אירועים של טעינת גופנים ServiceWorker, Web MIDI, Streams ועוד.

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

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

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

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

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

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

יצירת רצף

אנחנו רוצים להפוך את מערך 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), שיוצר הבטחה שנדחתה באמצעות הערך שנותנים לו (או לא מוגדר).

אנחנו יכולים לסדר את הקוד שלמעלה באמצעות 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() בפעם הראשונה, אבל בשאר הקריאות, "רצף" הוא מה שחזרנו מהקריאה הקודמת. 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 הראשי. אפשר לקרוא מידע נוסף על כך בmy async functions article. בדפדפנים העיקריים יש תמיכה רחבה בפונקציות Promises ובפונקציות האסינכרוניות. אפשר למצוא את הפרטים בחומר העזר של Promise ושל הפונקציה האסינכרונית ב-MDN.

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

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