הבטחות (promises) מפשטות חישובים מושהים ואסינכרוניים. הבטחה מייצגת פעולה שעדיין לא הושלמה.
מפתחים, תכינו את עצמכם לרגע מכונן בהיסטוריה של פיתוח האינטרנט.
[תופף מתחיל]
הבטחות הגיעו ל-JavaScript!
[זיקוקים מתפוצצים, גשם של נייר נוצץ מלמעלה, הקהל מתפוצץ]
בשלב הזה, החשבון שלכם ייכלל באחת מהקטגוריות הבאות:
- אנשים מריעים מסביב אליכם, אבל אתם לא בטוחים למה כל המהומה. אולי אפילו לא ברור לך מהי "הבטחה". צריך למשוך בכתפיים, אבל המשקל של הנייר הנוצץ מכבד את הכתפיים שלך. אם כן, אל תדאגו, לקח לי זמן רב להבין למה כדאי לי לעשות זאת. מומלץ להתחיל בהתחלה.
- מכים באוויר! הגיע הזמן, נכון? השתמשתם ב-Promise בעבר, אבל אתם לא אוהבים את העובדה שלכל ההטמעות יש ממשק API שונה במקצת. מהו ה-API של גרסת JavaScript הרשמית? מומלץ להתחיל בטרמינולוגיה.
- כבר ידעתם על זה, וצוחקים על אלה שמתרגשים כאילו זה חדשות בשבילם. נסו להקדיש כמה רגעים כדי לבחון את עליונות שלכם, ואז עברו ישירות להפניה ל-API.
תמיכה בדפדפנים ו-polyfill
כדי להביא דפדפנים חסרים הטמעה מלאה של הבטחות לתאימות למפרט, או להוסיף הבטחות לדפדפנים אחרים ול-Node.js, אפשר להיעזר בpolyfill (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
וכו'. באירועים האלה לא משנה מה קרה לפני שמצרפים את המאזין. אבל כשמדובר בהצלחה/כישלון אסינכרוני, רצוי להשתמש במשהו כזה:
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
});
ברמה הבסיסית ביותר, הבטחות דומות קצת למאזינים של אירועים, אבל:
- הבטחה יכולה להצליח או להיכשל רק פעם אחת. היא לא יכולה להצליח או להיכשל פעמיים, וגם לא לעבור מסטטוס הצלחה לסטטוס כישלון או להפך.
- אם האירוע של ה-promise הצליח או נכשל, ואתם מוסיפים בהמשך פונקציית קריאה חוזרת (callback) לאירועים של הצלחה או כישלון, פונקציית ה-callback הנכונה תופעל, גם אם האירוע התרחש מוקדם יותר.
האפשרות הזו שימושית במיוחד כשרוצים לדעת אם פעולה בוצעה בהצלחה או נכשלה, כי פחות מעניין אתכם המועד המדויק שבו משהו הפך לזמין, ויותר מעניין אתכם להגיב לתוצאה.
מונחים של Promise
Domenic Denicola בדק את הטיוטה הראשונה של המאמר הזה והעניק לי ציון 'כישלון' בתחום המונחים. הוא העניש אותי, הכריח אותי להעתיק את States and Fates 100 פעמים וכתב מכתב דאגה להורים שלי. למרות זאת, אני עדיין מובלבל במינוחים רבים, אבל הנה העקרונות הבסיסיים:
הבטחה יכולה להיות:
- fulfilled – הפעולה שקשורה להבטחה בוצעה בהצלחה
- rejected – הפעולה שקשורה להבטחה נכשלה
- pending – הבקשה עדיין לא בוצעה או נדחתה
- settled – הבקשה בוצעה או נדחתה
במפרט נעשה שימוש גם במונח thenable כדי לתאר אובייקט שדומה להבטחה, כלומר אובייקט שיש לו שיטה then
. המונח הזה מזכיר לי את Terry Venables של מנהל הפוטבול האנגלי, ולכן אשתמש בו כמה שפחות.
הבטחות מגיעות ל-JavaScript!
הבטחות קיימות כבר זמן מה בצורת ספריות, כמו:
להבטחות הקודמות ולהבטחות ב-JavaScript יש התנהגות סטנדרטית משותפת שנקראת Promises/A+. אם אתם משתמשים ב-jQuery, יש להם משהו דומה שנקרא Deferreds. עם זאת, פונקציות Deferred לא תואמות ל-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"));
}
});
ה-constructor של ההבטחה מקבל ארגומנט אחד, פונקציית קריאה חוזרת עם שני פרמטרים, resolve ו-reject. מבצעים פעולה כלשהי בתוך הקריאה החוזרת, אולי אסינכרנית, ואז קוראים ל-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 עם שיטות אסינכרניות של הצלחה/כישלון ישתמשו ב-promises. אנחנו כבר עושים זאת בניהול מכסות, באירועי טעינת גופנים, ב-ServiceWorker, ב-Web MIDI, ב-Streams ובתכונות נוספות.
תאימות לספריות אחרות
ממשק ה-API של הבטחות ב-JavaScript יתייחס לכל דבר עם שיטה then()
כאל התחייבות (או thenable
בשפת הבטחות אנחה), כך שאם אתם משתמשים בספרייה שמחזירה התחייבות מסוג Q, זה בסדר, היא תפעל בצורה חלקה עם הבטחות החדשות ב-JavaScript.
עם זאת, כפי שציינתי, פונקציות ה-Deferred של jQuery הן קצת… לא מועילות. למרבה המזל, אפשר להמיר אותם להבטחה סטנדרטיות, ולכן כדאי לעשות זאת בהקדם האפשרי:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
כאן, הפונקציה $.ajax
של jQuery מחזירה Deferred. מכיוון שיש לו את השיטה then()
, אפשר להשתמש ב-Promise.resolve()
כדי להפוך אותו להבטחה של JavaScript. עם זאת, לפעמים פונקציות מושהות מעבירות כמה ארגומנטים לפונקציות ה-callbacks שלהן, לדוגמה:
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
s יחד כדי לשנות ערכים או להריץ פעולות אסינכררוניות נוספות אחת אחרי השנייה.
טרנספורמציה של ערכים
אפשר לשנות ערכים פשוט על ידי החזרת הערך החדש:
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()
מקבלת שני ארגומנטים, אחד להצלחה ואחד לכישלון (או fulfill ו-reject, בשפת ההבטחות):
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!");
})
התהליך שלמעלה דומה מאוד ל-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);
})
לכן מומלץ לבצע את כל העבודה שקשורה להבטחה בתוך הפונקציה הלא חוזרת (callback) של ה-promise, כדי ששגיאות יתגלו באופן אוטומטי ויהפכו לדחייה.
אותו הדבר חל על שגיאות שמופיעות בקריאות חזרה (callbacks) של 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 או שהמשתמש אופליין), המערכת תדלג על כל קריאות החזרה (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()
רק למטרות רישום ביומן, בלי לשחזר את השגיאה. כדי לעשות זאת, פשוט זורקים מחדש את השגיאה. אפשר לעשות זאת בשיטה 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 Fiction. זו לא ספרות פנאי, אז בואו נתקן את זה.
יצירת רצף
אנחנו רוצים להפוך את מערך 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()
, אבל בשאר הקריאות הערך של 'sequence' הוא הערך שהוחזר מהקריאה הקודמת. array.reduce
מאוד שימושי אם רוצים להרחון מערך לערך יחיד, ובמקרה הזה הוא הבטחה.
נסכם את כל זה:
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Once the last chapter's promise is done…
return sequence.then(function() {
// …fetch the next chapter
return getJSON(chapterUrl);
}).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
הגענו למסקנה הזאת, גרסה אסינכרונית לחלוטין של גרסת הסנכרון. אבל אנחנו יכולים לעשות טוב יותר. בשלב הזה הדף שלנו מופיע כך:
הדפדפנים מצטיינים בהורדת כמה פריטים בו-זמנית, ולכן אנחנו מפסידים ביצועים כשאנחנו מורידים פרקים אחד אחרי השני. המטרה שלנו היא להוריד את כולם בו-זמנית, ואז לעבד אותם כשכולם יגיעו. למרבה המזל, יש ממשק API לשם כך:
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
Promise.all
מקבלת מערך של הבטחות ויוצרת הבטחה שתתבצע רק אחרי שכל ההבטחות יבוצעו. תקבלו מערך של תוצאות (כל מה שהובטח) באותו סדר כמו ההבטחות שהעברתם.
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Take an array of promises and wait on them all
return Promise.all(
// Map our array of chapter urls to
// an array of chapter json promises
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// Now we have the chapters jsons in order! Loop through…
chapters.forEach(function(chapter) {
// …and add to the page
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened so far
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
בהתאם לחיבור, הטעינה יכולה להיות מהירה יותר בשניות מאשר טעינה אחת אחרי השנייה, והקוד קצר יותר מהניסיון הראשון שלנו. אפשר להוריד את הפרקים בסדר כלשהו, אבל הם יופיעו במסך בסדר הנכון.
עם זאת, עדיין יש לנו אפשרות לשפר את הביצועים שנראים לעין. כשפרק אחד יגיע, נוסיף אותו לדף. כך המשתמשים יוכלו להתחיל לקרוא לפני ששאר הפרקים הגיעו. כשפרק שלישי יגיע, לא נוסיף אותו לדף כי יכול להיות שהמשתמש לא ירגיש שחסר פרק שני. כשפרק שני יגיע, נוכל להוסיף את הפרקים השני והשלישי וכו'.
כדי לעשות זאת, אנחנו מאחזרים את קובצי ה-JSON לכל הפרקים שלנו בו-זמנית ואז יוצרים רצף כדי להוסיף אותם למסמך:
getJSON('story.json')
.then(function(story) {
addHtmlToPage(story.heading);
// Map our array of chapter urls to
// an array of chapter json promises.
// This makes sure they all download in parallel.
return story.chapterUrls.map(getJSON)
.reduce(function(sequence, chapterPromise) {
// Use reduce to chain the promises together,
// adding content to the page for each chapter
return sequence
.then(function() {
// Wait for everything in the sequence so far,
// then wait for this chapter to arrive.
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
וזהו, הכי טוב משניהם! זמן האספקה של כל התוכן זהה, אבל המשתמש מקבל את קטע התוכן הראשון מוקדם יותר.
בדוגמה הפשוטה הזו, כל הפרקים מגיעים בערך באותו זמן, אבל היתרונות של הצגת פרק אחד בכל פעם יהיו גדולים יותר ככל שיהיו יותר פרקים גדולים יותר.
כדי לבצע את הפעולות שלמעלה באמצעות אירועים או קריאות חוזרות (callbacks) בסגנון Node.js, הקוד יוכפל, אבל חשוב יותר שלא קל לעקוב אחריו. עם זאת, זה לא הסוף של הסיפור של ההבטחות. כשמשלבים אותן עם תכונות אחרות של ES6, הן הופכות לפשוטות עוד יותר.
סבב בונוס: יכולות מורחבות
מאז שכתבתי את המאמר הזה במקור, היכולת להשתמש ב-Promises התרחבה. מאז Chrome 55, פונקציות אסינכרניות אפשרו לכתוב קוד שמבוסס על הבטחה (promise) כאילו הוא סינכרוני, אבל בלי לחסום את הליבה הראשית. מידע נוסף על כך זמין במאמר שלי על פונקציות אסינכררוניות. יש תמיכה רחבה ב-Promises ובפונקציות אסינכרניות בדפדפנים העיקריים. הפרטים מופיעים במאמרים של MDN בנושא Promise ופונקציה אסינכררונית.
תודה רבה ל-Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans ו-Yutaka Hirano על בדיקת הטקסט ועל התיקונים וההמלצות.
בנוסף, תודה Mathias Bynens על כך שעדכנת חלקים שונים במאמר.