השימוש ב-Promises מפשט את החישובים האסינכרוניים והמושהים. הבטחה מייצגת פעולה שעדיין לא הושלמה.
מפתחים, כדאי שתתכוננו לרגע מכריע בהיסטוריה של פיתוח אתרים.
[Drumroll begins]
הבטחות הגיעו ל-JavaScript!
[זיקוקים מתפוצצים, נייר מנצנץ נופל מלמעלה, הקהל משתול]
בשלב הזה, אתם משתייכים לאחת מהקטגוריות הבאות:
- אנשים מעודדים סביבך, אבל אתה לא בטוח על מה המהומה. אולי אתם אפילו לא בטוחים מה זה "הבטחה". אתם מרימים כתפיים, אבל משקל הנייר הנוצץ מכביד על הכתפיים. אם כן, אל תדאג, לקח לי הרבה זמן להבין למה כדאי לי להתעניין בדברים האלה. כדאי להתחיל מההתחלה.
- אתם מרימים אגרוף לאוויר! הגיע הזמן, נכון? השתמשת בעבר ב-Promise, אבל מפריע לך שלכל ההטמעות יש API שונה במקצת. מה ה-API של גרסת ה-JavaScript הרשמית? מומלץ להתחיל עם הטרמינולוגיה.
- כבר ידעתם על זה ואתם מזלזלים באלה שקופצים בהתלהבות כאילו זה חדש להם. אחרי שתסיימו להתפעל מהיכולות שלכם, תוכלו לעבור ישר אל הפניית ה-API.
תמיכה בדפדפן ו-polyfill
כדי להוסיף הבטחות לדפדפנים אחרים ול-Node.js, או כדי להוסיף לדפדפנים שחסרה בהם הטמעה מלאה של הבטחות תאימות למפרט, אפשר לעיין בפוליפיל (2k gzipped).
על מה כל המהומה?
JavaScript היא שפה עם שרשור יחיד, כלומר שני קטעי סקריפט לא יכולים לפעול בו-זמנית, אלא רק אחד אחרי השני. בדפדפנים, JavaScript חולק שרשור עם הרבה דברים אחרים שמשתנים מדפדפן לדפדפן. אבל בדרך כלל JavaScript נמצא באותו תור כמו פעולות הרינדור, עדכון הסגנונות וטיפול בפעולות משתמש (כמו הדגשת טקסט ואינטראקציה עם אמצעי בקרה של טפסים). פעילות באחד מהדברים האלה מעכבת את האחרים.
כמו כל בני האדם, אתם יכולים לבצע כמה פעולות בו-זמנית. אתם יכולים להקליד עם כמה אצבעות, אתם יכולים לנהוג ולנהל שיחה בו-זמנית. הפונקציה היחידה שקשורה לחסימה היא עיטוש, שבו כל הפעילות הנוכחית מושעית למשך העיטוש. זה די מעצבן, במיוחד כשנוהגים ומנסים לנהל שיחה. אתם לא רוצים לכתוב קוד שגורם לעיטושים.
כנראה שהשתמשתם באירועים ובקודים להתקשרות חזרה כדי לעקוף את הבעיה הזו. אלה אירועים:
var img1 = document.querySelector('.img-1');
img1.addEventListener('load', function() {
// woo yey image loaded
});
img1.addEventListener('error', function() {
// argh everything's broken
});
זה לא גורם לעיטושים בכלל. אנחנו מקבלים את התמונה, מוסיפים כמה מאזינים, ואז JavaScript יכולה להפסיק את ההרצה עד שאחד מהמאזינים האלה נקרא.
לצערנו, בדוגמה שלמעלה, יכול להיות שהאירועים קרו לפני שהתחלנו להאזין להם, ולכן אנחנו צריכים למצוא פתרון לבעיה הזו באמצעות המאפיין complete של התמונות:
var img1 = document.querySelector('.img-1');
function loaded() {
// woo yey image loaded
}
if (img1.complete) {
loaded();
}
else {
img1.addEventListener('load', loaded);
}
img1.addEventListener('error', function() {
// argh everything's broken
});
התכונה הזו לא מזהה תמונות שהייתה בהן שגיאה לפני שהייתה לנו הזדמנות להאזין להן. לצערנו, ה-DOM לא מאפשר לנו לעשות את זה. בנוסף, התמונה הזו נטענת. הדברים מסתבכים עוד יותר אם רוצים לדעת מתי קבוצה של תמונות נטענה.
אירועים הם לא תמיד הדרך הכי טובה
אירועים מתאימים למקרים שיכולים לקרות כמה פעמים באותו אובייקט – keyup
, touchstart
וכו'. כשמשתמשים באירועים האלה, לא באמת חשוב מה קרה לפני שחיברתם את ה-listener. אבל כשמדובר בהצלחה או בכישלון אסינכרוניים, כדאי להשתמש במשהו כזה:
img1.callThisIfLoadedOrWhenLoaded(function() {
// loaded
}).orIfFailedCallThis(function() {
// failed
});
// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
// all loaded
}).orIfSomeFailedCallThis(function() {
// one or more failed
});
זה מה שפונקציות Promise עושות, אבל עם שמות טובים יותר. אם לרכיבי תמונות 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, אבל יש כמה הבדלים:
- הבטחה יכולה להצליח או להיכשל רק פעם אחת. היא לא יכולה להצליח או להיכשל פעמיים, ולא יכולה לעבור מהצלחה לכישלון או להיפך.
- אם הבטחה הצליחה או נכשלה ואחר כך מוסיפים קריאה חוזרת (callback) להצלחה או לכישלון, הקריאה החוזרת הנכונה תופעל, גם אם האירוע התרחש קודם.
התכונה הזו שימושית במיוחד להצלחה או לכישלון אסינכרוניים, כי פחות חשוב לדעת מתי בדיוק משהו הפך לזמין, ויותר חשוב להגיב לתוצאה.
הסברים על המונחים שקשורים ל-Promise
Domenic Denicola proof read the first draft of this article and graded me "F" for terminology. הוא הכניס אותי לריתוק, הכריח אותי להעתיק את States and Fates 100 פעמים, וכתב מכתב מודאג להורים שלי. למרות זאת, אני עדיין מתבלבל בין הרבה מהמונחים, אבל הנה כמה מהמושגים הבסיסיים:
הבטחה יכולה להיות:
- fulfilled – הפעולה שקשורה להבטחה בוצעה בהצלחה
- נדחתה – הפעולה שקשורה להבטחה נכשלה
- בהמתנה – עדיין לא בוצעה או נדחתה
- settled (הושלמה) – העסקה הושלמה או נדחתה
במפרט נעשה שימוש גם במונח thenable כדי לתאר אובייקט שדומה ל-Promise, כי יש לו שיטה then
. המונח הזה מזכיר לי את מאמן נבחרת אנגליה לשעבר טרי ונבלס, ולכן אשתמש בו כמה שפחות.
הבטחות מגיעות ל-JavaScript!
הבטחות קיימות כבר זמן מה בצורה של ספריות, כמו:
ההבטחות שלמעלה ושל JavaScript חולקות התנהגות משותפת וסטנדרטית שנקראת Promises/A+. אם אתם משתמשים ב-jQuery, יש להן משהו דומה שנקרא Deferreds. עם זאת, Deferreds לא תואמים ל-Promise/A+, ולכן הם שונים במקצת ופחות שימושיים. כדאי לשים לב לכך. ל-jQuery יש גם סוג Promise, אבל הוא רק קבוצת משנה של Deferred ויש לו את אותן הבעיות.
למרות שההטמעות של הבטחות פועלות לפי התנהגות סטנדרטית, ממשקי ה-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 ו-reject. מבצעים פעולה בתוך הקריאה החוזרת, אולי באופן אסינכרוני, ואז קוראים ל-resolve אם הכול פעל, אחרת קוראים ל-reject.
בדומה ל-throw
ב-JavaScript רגיל, מקובל, אבל לא חובה, לדחות עם אובייקט שגיאה. היתרון של אובייקטים מסוג Error הוא שהם כוללים מעקב אחר מחסנית (stack trace), ולכן הם עוזרים יותר בניפוי באגים.
כך משתמשים בהבטחה הזו:
promise.then(function(result) {
console.log(result); // "Stuff worked!"
}, function(err) {
console.log(err); // Error: "It broke"
});
הפונקציה then()
מקבלת שני ארגומנטים: קריאה חוזרת למקרה של הצלחה, וקריאה חוזרת למקרה של כשל. שניהם אופציונליים, כך שאפשר להוסיף קריאה חוזרת רק למקרה של הצלחה או כישלון.
ההבטחות של JavaScript התחילו ב-DOM כ-Futures, שונה השם ל-Promises, ולבסוף עברו ל-JavaScript. העובדה שהם נמצאים ב-JavaScript ולא ב-DOM היא מצוינת, כי הם יהיו זמינים בהקשרים של JS שאינם דפדפנים, כמו Node.js (השאלה אם הם משתמשים בהם בממשקי ה-API העיקריים שלהם היא כבר עניין אחר).
למרות שהם תכונה של JavaScript, ה-DOM לא חושש להשתמש בהם. למעשה, כל ממשקי ה-API החדשים של DOM עם שיטות אסינכרוניות להצלחה או לכישלון ישתמשו ב-promises. זה כבר קורה עם ניהול מכסות, אירועים של טעינת גופנים, ServiceWorker, Web MIDI, Streams ועוד.
תאימות לספריות אחרות
ממשק ה-API של הבטחות JavaScript יתייחס לכל דבר עם שיטת then()
כאל הבטחה (או thenable
בשפת ההבטחות sigh), כך שאם אתם משתמשים בספרייה שמחזירה הבטחת Q, זה בסדר, היא תפעל בצורה טובה עם ההבטחות החדשות של JavaScript.
אבל כמו שאמרתי, ה-Deferreds של jQuery קצת… לא מועילים. למזלכם, אפשר להמיר אותם להבטחות רגילות, וכדאי לעשות את זה בהקדם האפשרי:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
במקרה הזה, הפונקציה $.ajax
של jQuery מחזירה Deferred. מכיוון שיש לו שיטת then()
, Promise.resolve()
יכול להפוך אותו להבטחה של JavaScript. עם זאת,
לפעמים פונקציות Deferred מעבירות כמה ארגומנטים לפונקציות הקריאה החוזרת שלהן, למשל:
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()
הוא לא סוף הסיפור, אפשר לשרשר כמה פקודות 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()
נקראת עם הערך הזה. עם זאת, אם מחזירים משהו שדומה ל-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 לשליחת בקשות. לאחר מכן אנחנו שולחים בקשה לכתובת הראשונה בקבוצה. בשלב הזה, היתרונות של Promises מתחילים להיות ברורים יותר בהשוואה לדפוסי קריאה חוזרת פשוטים.
אפשר אפילו ליצור שיטה מקוצרת להוספת פרקים:
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
דחייה מתרחשת כשמבצעים דחייה מפורשת של הבטחה, אבל גם באופן מרומז אם מוצגת שגיאה בקריאת החזרה של בנאי:
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 בתוך פונקציית הקריאה החוזרת של בנאי ה-Promise, כדי שהשגיאות יתגלו אוטומטית ויהפכו לדחיות.
אותו עיקרון חל גם על שגיאות שמוחזרות בthen()
callbacks.
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 או שהמשתמש במצב אופליין), כל קריאות החזרה הבאות להצלחה ידלגו, כולל הקריאה ב-story.chapterUrls[0]
שמנסה לנתח את התגובה כ-JSON, וגם הקריאה להחזרה שמוסיפה את chapter1.html לדף.getJSON()
במקום זאת, הוא עובר אל פונקציית ה-callback של catch. כתוצאה מכך, אם אחת מהפעולות הקודמות נכשלה, הערך 'הצגת הפרק נכשלה' יתווסף לדף.
בדומה ל-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
הוא פשוט יחזיר אותו (הערה: זה שינוי במפרט שחלק מההטמעות עדיין לא פועלות לפיו). אם מעבירים לו משהו שנראה כמו הבטחה (יש לו method then()
), הוא יוצר Promise
אמיתי שממלא או דוחה את ההבטחה באותו אופן. אם מעבירים ערך אחר, למשל, Promise.resolve('Hello')
, היא יוצרת הבטחה שמתממשת עם הערך הזה. אם קוראים לה בלי ערך, כמו בדוגמה שלמעלה, היא מחזירה את הערך undefined.
יש גם את Promise.reject(val)
, שיוצר הבטחה שנדחית עם הערך שנותנים לה (או עם undefined).
אפשר לסדר את הקוד שלמעלה באמצעות array.reduce
:
// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Add these actions to the end of the sequence
return sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve())
הקוד הזה עושה את אותו הדבר כמו בדוגמה הקודמת, אבל לא צריך את המשתנה הנפרד sequence. פונקציית ה-callback של reduce נקראת לכל פריט במערך.
'sequence' הוא Promise.resolve()
בפעם הראשונה, אבל בשאר הקריאות 'sequence' הוא הערך שהוחזר מהקריאה הקודמת. array.reduce
מאוד שימושי לצמצום מערך לערך יחיד, שהוא במקרה הזה הבטחה.
בואו נסכם את הכול:
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Once the last chapter's promise is done…
return sequence.then(function() {
// …fetch the next chapter
return getJSON(chapterUrl);
}).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
והנה, קיבלנו גרסה אסינכרונית מלאה של הגרסה הסינכרונית. אבל אנחנו יכולים להשתפר. בשלב הזה, הדף שלנו מוריד כך:
הדפדפנים טובים למדי בהורדה של כמה דברים בו-זמנית, ולכן אנחנו מאבדים ביצועים בהורדה של פרקים אחד אחרי השני. אנחנו רוצים להוריד את כל הקבצים בבת אחת, ואז לעבד אותם אחרי שכולם יגיעו. למזלנו, יש API בשביל זה:
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
Promise.all
מקבל מערך של הבטחות ויוצר הבטחה שמתממשת כשכולן מסתיימות בהצלחה. מקבלים מערך של תוצאות (כל מה שהובטח) באותו סדר של ההבטחות שהועברו.
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Take an array of promises and wait on them all
return Promise.all(
// Map our array of chapter urls to
// an array of chapter json promises
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// Now we have the chapters jsons in order! Loop through…
chapters.forEach(function(chapter) {
// …and add to the page
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened so far
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
בהתאם לחיבור, הטעינה יכולה להיות מהירה יותר בשניות בהשוואה לטעינה של כל תמונה בנפרד, ויש פחות קוד מאשר בניסיון הראשון. הפרקים יכולים להוריד בכל סדר, אבל הם מופיעים על המסך בסדר הנכון.
עם זאת, עדיין אפשר לשפר את הביצועים הנתפסים. כשפרק ראשון יגיע, צריך להוסיף אותו לדף. כך המשתמש יכול להתחיל לקרוא לפני ששאר הפרקים מגיעים. כשפרק שלוש יגיע, לא נוסיף אותו לדף כי יכול להיות שהמשתמש לא יבין שפרק שתיים חסר. כשמגיעים לפרק השני אפשר להוסיף את פרק 2 ופרק 3 וכן הלאה.
כדי לעשות את זה, אנחנו מאחזרים 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';
})
והנה, קיבלנו את הטוב משני העולמות! משך הזמן שנדרש להעברת כל התוכן זהה, אבל המשתמש מקבל את החלק הראשון של התוכן מוקדם יותר.
בדוגמה הפשוטה הזו, כל הפרקים מגיעים בערך באותו הזמן, אבל היתרון של הצגת פרק אחד בכל פעם יהיה משמעותי יותר אם יהיו יותר פרקים גדולים יותר.
אם משתמשים בקריאות חוזרות (callback) או באירועים בסגנון Node.js, הקוד ארוך פי שניים, וחשוב מכך, קשה יותר לעקוב אחריו. אבל זה לא הסוף של הסיפור לגבי הבטחות. בשילוב עם תכונות אחרות של ES6, השימוש בהן הופך לקל עוד יותר.
סבב בונוס: יכולות מורחבות
מאז שכתבתי את המאמר הזה, היכולת להשתמש ב-Promises התרחבה מאוד. מאז Chrome 55, פונקציות אסינכרוניות מאפשרות לכתוב קוד מבוסס-promise כאילו הוא סינכרוני, אבל בלי לחסום את השרשור הראשי. מידע נוסף על כך מופיע במאמר שלי על פונקציות אסינכרוניות. יש תמיכה נרחבת ב-Promises ובפונקציות אסינכרוניות בדפדפנים המובילים. פרטים נוספים זמינים במאמרים בנושא Promise ו-async function ב-MDN.
תודה רבה לאן ואן קסטרן, דומניק דניקולה, טום אשוורת', רמי שארפ, אדי אוסמני, ארתור אוונס ויוטאקה היראנו שערכו הגהה של המאמר הזה והציעו תיקונים והמלצות.
תודה גם ל-Mathias Bynens על עדכון חלקים שונים במאמר.