הבטחות מפשטות חישובים אסינכרוניים וחישובים שנדחו. הבטחה מייצגת פעולה שעדיין לא הושלמה.
מפתחים, התכוננו לרגע מכונן בהיסטוריה של פיתוח אתרים.
[תופים]
הבטחות הגיעו ל-JavaScript!
[זיקוקים מתפוצצים, נייר נוצץ יורד מלמעלה, הקהל משתולל]
בשלב הזה, אתם משתייכים לאחת מהקטגוריות הבאות:
- אנשים מריעים סביבכם, אבל אתם לא בטוחים על מה כל המהומה. אולי אתם אפילו לא בטוחים מה זה "הבטחה". אתם מושכים בכתפיים, אבל משקל הנייר הנוצץ מכביד על הכתפיים שלכם. אם כן, אל תדאגו, לי לקח הרבה זמן להבין למה זה חשוב. כדאי להתחיל מההתחלה.
- אתה מניף אגרוף באוויר! הגיע הזמן, נכון? השתמשתם ב-Promise בעבר, אבל מפריע לכם שבכל ההטמעות יש API קצת שונה. מהו ה-API של הגרסה הרשמית של JavaScript? כדאי להתחיל עם הטרמינולוגיה.
- כבר ידעתם על זה, ואתם לועגים לאנשים שמתלהבים כאילו מדובר בחדשות מרעישות. קחו רגע כדי להתענג על ההצלחה, ואז עברו ישירות למאמרי העזרה של ה-API.
תמיכה בדפדפן ו-polyfill
כדי להתאים דפדפנים שאין בהם הטמעה מלאה של הבטחות למפרט, או כדי להוסיף הבטחות לדפדפנים אחרים ול-Node.js, כדאי לעיין בpolyfill (2k gzipped).
על מה כל המהומה?
JavaScript הוא שרשור יחיד, כלומר שני קטעי סקריפט לא יכולים לפעול בו-זמנית, אלא אחד אחרי השני. בדפדפנים, JavaScript חולק שרשור עם הרבה דברים אחרים שמשתנים מדפדפן לדפדפן. אבל בדרך כלל JavaScript נמצא באותו תור כמו רינדור, עדכון סגנונות וטיפול בפעולות של המשתמש (כמו הדגשת טקסט ואינטראקציה עם פקדים של טפסים). פעילות באחד מהם מעכבת את האחרים.
כבני אדם, אתם יכולים לעשות כמה דברים במקביל. אתם יכולים להקליד עם כמה אצבעות, אתם יכולים לנהוג ולנהל שיחה בו-זמנית. פונקציית החסימה היחידה שאנחנו צריכים להתמודד איתה היא התעטשות, שבה כל הפעילות הנוכחית חייבת להיות מושעית למשך ההתעטשות. זה די מעצבן, במיוחד כשנוהגים ומנסים לנהל שיחה. אתם לא רוצים לכתוב קוד שמתעטש.
כנראה שהשתמשתם באירועים ובקריאות חוזרות (callbacks) כדי לעקוף את זה. אלה האירועים:
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
});
This doesn't catch images that errored before we got a chance to listen for them; unfortunately the DOM doesn't give us a way to do that. בנוסף, כאן נטענת תמונה אחת. העניינים מסתבכים עוד יותר אם אנחנו רוצים לדעת מתי קבוצה של תמונות נטענה.
אירועים הם לא תמיד הדרך הכי טובה
אירועים מתאימים לדברים שיכולים לקרות כמה פעמים באותו אובייקט – 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 יכול להצליח או להיכשל רק פעם אחת. היא לא יכולה להצליח או להיכשל פעמיים, וגם לא לעבור מהצלחה לכישלון או להיפך.
- אם אובייקט promise הצליח או נכשל, ואתם מוסיפים מאוחר יותר קריאה חוזרת (callback) להצלחה או לכישלון, הקריאה החוזרת הנכונה תתבצע, למרות שהאירוע התרחש קודם לכן.
זה שימושי מאוד להצלחה או לכישלון אסינכרוניים, כי פחות חשוב לכם לדעת מתי בדיוק משהו הפך לזמין, ויותר חשוב לכם להגיב לתוצאה.
הסברים על המונחים של Promise
Domenic Denicola הגיה את הטיוטה הראשונה של המאמר הזה ונתן לי ציון "נכשל" על הטרמינולוגיה. הוא השאיר אותי בריתוק, הכריח אותי להעתיק את מדינות וגורלות 100 פעמים וכתב מכתב מודאג להורים שלי. למרות זאת, אני עדיין מתבלבלת במונחים, אבל הנה העקרונות הבסיסיים:
הבטחה יכולה להיות:
- fulfilled – הפעולה שקשורה ל-Promise הצליחה
- rejected – הפעולה שקשורה ל-promise נכשלה
- pending – עדיין לא מולאה או נדחתה
- settled – הבקשה מולאה או נדחתה
במפרט משתמשים גם במונח thenable כדי לתאר אובייקט שדומה ל-promise, בכך שיש לו שיטת then. המונח הזה מזכיר לי את מנהל נבחרת הכדורגל לשעבר של אנגליה, טרי ונבלס, אז אשתמש בו כמה שפחות.
הבטחות מגיעות ל-JavaScript!
הבטחות קיימות כבר זמן מה בצורה של ספריות, כמו:
ההבטחות שלמעלה ושל JavaScript חולקות התנהגות משותפת ומתוקננת שנקראת Promises/A+. אם אתם משתמשים ב-jQuery, יש להם משהו דומה שנקרא Deferreds. עם זאת, אובייקטים מסוג Deferred לא תואמים ל-Promise/A+, ולכן הם שונים במקצת ופחות שימושיים, אז שימו לב. ל-jQuery יש גם סוג Promise, אבל זו רק קבוצת משנה של Deferred ויש לה את אותן בעיות.
למרות שההטמעות של Promise פועלות לפי התנהגות סטנדרטית, ממשקי ה-API הכוללים שלהן שונים. ה-API של JavaScript promises דומה ל-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 של ה-promise מקבל ארגומנט אחד, קריאה חוזרת (callback) עם שני פרמטרים, resolve ו-reject. מבצעים פעולה כלשהי בתוך הקריאה החוזרת (callback), אולי באופן אסינכרוני, ואז מפעילים את 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) למקרה של הצלחה, וקריאה חוזרת למקרה של כישלון. שניהם אופציונליים, כך שאפשר להוסיף קריאה חוזרת (callback) רק למקרה של הצלחה או כישלון.
ההבטחות של JavaScript התחילו ב-DOM בתור "Futures", שונו ל-"Promises", ולבסוף הועברו ל-JavaScript. העובדה שהם נמצאים ב-JavaScript ולא ב-DOM היא נהדרת, כי הם יהיו זמינים בהקשרים של JS שאינם בדפדפן, כמו Node.js (השאלה אם הם משתמשים בהם בממשקי ה-API העיקריים שלהם היא שאלה אחרת).
למרות שהן תכונה של JavaScript, ה-DOM לא חושש להשתמש בהן. למעשה, כל ממשקי ה-API החדשים של DOM עם שיטות אסינכרוניות להצלחה או לכישלון ישתמשו בהבטחות. זה כבר קורה עם ניהול מכסות, אירועי טעינת גופנים, ServiceWorker, Web MIDI, Streams ועוד.
תאימות עם ספריות אחרות
ה-API של JavaScript promises יתייחס לכל דבר עם שיטת then() כ-promise-like (או thenable ב-promise-speak sigh), כך שאם אתם משתמשים בספרייה שמחזירה Q promise, זה בסדר, היא תפעל היטב עם ה-JavaScript promises החדשים.
למרות שכמו שציינתי, ה-Deferreds של jQuery קצת… לא מועילים. למרבה המזל, אפשר להמיר אותם ל-Promise רגיל, ומומלץ לעשות את זה בהקדם האפשרי:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
כאן, הפונקציה $.ajax של jQuery מחזירה Deferred. מכיוון שיש לו שיטת then(), Promise.resolve() יכול להפוך אותו ל-JavaScript promise. עם זאת, לפעמים אובייקטים מסוג deferred מעבירים כמה ארגומנטים לפונקציות ה-callback שלהם, לדוגמה:
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
לעומת זאת, הבטחות ב-JS מתעלמות מכל הערכים מלבד הראשון:
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
לשמחתנו, בדרך כלל זה מה שאתם רוצים, או לפחות נותן לכם גישה למה שאתם רוצים. בנוסף, חשוב לדעת ש-jQuery לא פועל לפי המוסכמה של העברת אובייקטים של שגיאות לדחיות.
עכשיו קל יותר לכתוב קוד אסינכרוני מורכב
נכון, בואו נכתוב קוד. נניח שאנחנו רוצים:
- הפעלת ספינר כדי לציין טעינה
- אחזור של קובץ JSON של סיפור, שכולל את הכותרת וכתובות ה-URL של כל פרק
- הוספת כותרת לדף
- אחזור של כל פרק
- הוספת הסטורי לדף
- עצירת הספינר
… אבל גם להודיע למשתמש אם משהו השתבש בדרך. נרצה גם לעצור את הספינר בשלב הזה, אחרת הוא ימשיך להסתובב, יקבל סחרחורת ויתנגש בחלק אחר של ממשק המשתמש.
כמובן שלא הייתם משתמשים ב-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, החיים שלי יהיו מאושרים יותר.
שרשור
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.
הוספת פעולות אסינכרוניות לתור
אפשר גם לשרשר then כדי להריץ פעולות אסינכרוניות ברצף.
כשמחזירים משהו מ-callback של then(), זה קצת כמו קסם.
אם מחזירים ערך, הפונקציה then() הבאה נקראת עם הערך הזה. עם זאת, אם מחזירים משהו שדומה ל-promise, הפונקציה then() הבאה ממתינה לו, ונקראת רק כשה-promise הזה מסתיים (מצליח או נכשל). לדוגמה:
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 promise, כך ש-story.json מאוחזר רק פעם אחת. איזה כיף, הבטחות!
טיפול בשגיאות
כמו שראינו קודם, then() מקבל שני ארגומנטים, אחד להצלחה ואחד לכישלון (או fulfill ו-reject, במונחים של promises):
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);
})
ההבדל הוא קטן, אבל שימושי מאוד. דחיות של Promise מדלגות קדימה ל-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!");
})
התהליך שלמעלה דומה מאוד ל-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);
})
לכן כדאי לבצע את כל העבודה שקשורה ל-promise בתוך פונקציית ה-callback של ה-promise 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);
})
טיפול בשגיאות בפועל
בסיפור ובפרקים שלנו, אנחנו יכולים להשתמש ב-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() רק למטרות רישום ביומן, בלי להתאושש מהשגיאה. כדי לעשות את זה, פשוט זורקים מחדש את השגיאה. אפשר לעשות את זה ב-method 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, הוא פשוט יחזיר אותו (הערה: זהו שינוי במפרט שחלק מההטמעות עדיין לא פועלות לפיו). אם מעבירים אליה משהו דמוי-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) של reduce נקראת לכל פריט במערך.
"sequence" הוא Promise.resolve() בפעם הראשונה, אבל בשאר הקריאות, "sequence" הוא מה שהחזרנו מהקריאה הקודמת. array.reduce
הוא שימושי מאוד לצמצום מערך לערך יחיד, שבמקרה הזה הוא promise.
בואו נחבר את כל החלקים:
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 מקבל מערך של הבטחות ויוצר הבטחה שמתממשת כשכולן מסתיימות בהצלחה. מקבלים מערך של תוצאות (כל מה שה-promises מילאו) באותו סדר כמו ה-promises שהעברתם.
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 הראשי. אפשר לקרוא מידע נוסף על כך במאמר שלי בנושא פונקציות אסינכרוניות. יש תמיכה נרחבת גם ב-Promises וגם בפונקציות אסינכרוניות בדפדפנים המובילים. הפרטים מופיעים במאמרי העזרה של MDN בנושא Promise ופונקציית async.
תודה רבה ל-Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans ו-Yutaka Hirano שהגיהו את המאמר הזה והציעו תיקונים והמלצות.
תודה גם ל-Mathias Bynens על עדכון חלקים שונים במאמר.