Asynch JS – כוחו של $.Deferred

Jeremy Chone
Jeremy Chone

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

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

ממשקי API אסינכרוניים לדפדפן

למרבה המזל, הדפדפנים מספקים כמה ממשקי API אסינכרוניים, כמו ממשקי ה-API הנפוצים ביותר של XHR (XMLHttpRequest או AJAX), וכן IndexedDB, SQLite, Web Worker HTML5 וממשקי ה-API של HTML5 GeoLocation API. גם חלק מהפעולות שקשורות ל-DOM נחשפות באופן אסינכרוני, כמו אנימציה של CSS3 דרך אירועי transitionEnd.

הדפדפנים חושפים את התכנות האסינכרוני ללוגיקת האפליקציה באמצעות אירועים או קריאות חזרה (callbacks).
בממשקי API אסינכררוניים מבוססי-אירועים, המפתחים רושמים טיפול באירוע לאובייקט נתון (למשל רכיב HTML או אובייקטים אחרים של DOM) ואז קוראים לפעולה. הדפדפן יבצע את הפעולה בדרך כלל בשרשור אחר, ויפעיל את האירוע ב-thread הראשי אם יהיה בכך צורך.

לדוגמה, קוד שמשתמש ב-XHR API, API אסינכררוני מבוסס-אירועים, ייראה כך:

// Create the XHR object to do GET to /data resource  
var xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
alert("We got data: " + xhr.response);
}
},false)

// perform the work
xhr.send();

אירוע המעברEnd של CSS3 הוא דוגמה נוספת ל-API אסינכרוני מבוסס אירוע.

// get the html element with id 'flyingCar'  
var flyingCarElem = document.getElementById("flyingCar");

// register an event handler 
// ('transitionEnd' for FireFox, 'webkitTransitionEnd' for webkit) 
flyingCarElem.addEventListener("transitionEnd",function(){
// will be called when the transition has finished.
alert("The car arrived");
});

// add the CSS3 class that will trigger the animation
// Note: some browers delegate some transitions to the GPU , but 
//       developer does not and should not have to care about it.
flyingCarElemen.classList.add('makeItFly') 

ממשקי API אחרים בדפדפן, כמו SQLite ו-HTML5 Geolocation, מבוססים על קריאה חוזרת (callback). כלומר, המפתח מעביר פונקציה כארגומנט שתיקרא חזרה על ידי ההטמעה הבסיסית עם הרזולוציה המתאימה.

לדוגמה, ב-HTML5 Geolocation, הקוד נראה כך:

// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){  
        alert('Lat: ' + position.coords.latitude + ' ' +  
                'Lon: ' + position.coords.longitude);  
});  

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

הכנת אפליקציות לפעולה אסינכרונית

בנוסף לממשקי ה-API האסינכרוניים המובנים בדפדפן, אפליקציות עם ארכיטקטורה טובה צריכות לחשוף גם את ממשקי ה-API ברמה הנמוכה באופן אסינכרוני, במיוחד כשהן מבצעות כל סוג של קלט/פלט או עיבוד מתמטי כבד. לדוגמה, ממשקי API לקבלת נתונים צריכים להיות אסינכרונים, ולא צריכים להיראות כך:

// WRONG: this will make the UI freeze when getting the data  
var data = getData();
alert("We got data: " + data);

בעיצוב ה-API הזה, getData()‎ צריך להיות חסום, וכתוצאה מכך ממשק המשתמש יהיה קפוא עד שאחזור הנתונים יושלם. אם הנתונים הם מקומיים בהקשר של JavaScript, יכול להיות שזה לא יהיה בעיה. עם זאת, אם צריך לאחזר את הנתונים מהרשת או אפילו באופן מקומי ב-SQLite או במאגר אינדקס, יכול להיות לכך השפעה דרמטית על חוויית המשתמש.

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

לדוגמה, ממשק ה-API הפשוט getData() יהפוך למשהו כזה:

getData(function(data){
alert("We got data: " + data);
});

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

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

טיפול בכשלים

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

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

הקריאה שלנו ל-getData תיראה כך:

// getData(successFunc,failFunc);  
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});

איך משלבים את זה עם $.Deferred

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

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

// first do the get data.   
getData(function(data){
// then get the location
getLocation(function(location){
alert("we got data: " + data + " and location: " + location);
},function(ex){
alert("getLocation failed: "  + ex);
});
},function(ex){
alert("getData failed: " + ex);
});

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

למרבה המזל, יש תבנית ישנה יחסית שנקראת Promises (שדומה ל-Future ב-Java) והטמעה חזקה ומודרנית בליבה של jQuery שנקראת $.Deferred, שמספקת פתרון פשוט ויעיל לתכנות אסינכרוני.

כדי להסביר את זה בפשטות, דפוס ה-Promises מגדיר שה-API האסינכרוני מחזיר אובייקט Promise, שהוא סוג של "Promise שהתוצאה תיפתר עם הנתונים המתאימים". כדי לקבל את הפתרון, מבצע הקריאה מקבל את אובייקט ה-Promise וקורא ל-done(successFunc(data)), שמורה לאובייקט ה-Promise לקרוא ל-successFunc הזה כשה-"data" יפתר.

כך, הדוגמה לקריאה ל-getData שלמעלה נראית כך:

// get the promise object for this API  
var dataPromise = getData();

// register a function to get called when the data is resolved
dataPromise.done(function(data){
alert("We got data: " + data);
});

// register the failure function
dataPromise.fail(function(ex){
alert("oops, some problem occured: " + ex);
});

// Note: we can have as many dataPromise.done(...) as we want. 
dataPromise.done(function(data){
alert("We asked it twice, we get it twice: " + data);
});

כאן קודם מקבלים את האובייקט dataPromise ואז קוראים ל-method‏ .done כדי לרשום פונקציה שרוצים שתתקבל חזרה כשהנתונים יתקבלו. אפשר גם לקרוא ל-method .fail כדי לטפל בכשל הסופי. שימו לב שאנחנו יכולים לבצע כמה קריאות .done או .fail שאנחנו צריכים, כי ההטמעה הבסיסית של Promise (קוד jQuery) לטפל ברישום ובקריאות החוזרות.

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

לדוגמה, פונקציית ה-callback בתצוגת עץ של getData/getLocation שלמעלה תהפוך למשהו כזה:

// assuming both getData and getLocation return their respective Promise
var combinedPromise = $.when(getData(), getLocation())

// function will be called when both getData and getLocation resolve
combinePromise.done(function(data,location){
alert("We got data: " + dataResult + " and location: " + location);
});  

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

function getData(){
// 1) create the jQuery Deferred object that will be used
var deferred = $.Deferred();

// ---- AJAX Call ---- //
XMLHttpRequest xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
    // 3.1) RESOLVE the DEFERRED (this will trigger all the done()...)
    deferred.resolve(xhr.response);
}else{
    // 3.2) REJECT the DEFERRED (this will trigger all the fail()...)
    deferred.reject("HTTP error: " + xhr.status);
}
},false) 

// perform the work
xhr.send();
// Note: could and should have used jQuery.ajax. 
// Note: jQuery.ajax return Promise, but it is always a good idea to wrap it
//       with application semantic in another Deferred/Promise  
// ---- /AJAX Call ---- //

// 2) return the promise of this deferred
return deferred.promise();
}

לכן, כשמתבצעת קריאה ל-getData(), קודם נוצר אובייקט jQuery.Deferred חדש (1) ואז מוחזר ה-Promise שלו (2) כדי שהמבצע יכול לרשום את הפונקציות done ו-fail. לאחר מכן, כשחוזרת הקריאה ל-XHR, היא פותרת את הבעיה (3.1) שנדחתה או דוחה אותה (3.2). קריאה ל-deferred.resolve תפעיל את כל הפונקציות של done(…) ופונקציות אחרות של promise (למשל, then ו-pipe), וקריאה ל-deferred.reject תפעיל את כל הפונקציות של fail().

תרחישים לדוגמה

הנה כמה תרחישים לדוגמה שבהם השימוש ב-Deferred יכול להיות שימושי מאוד:

גישה לנתונים: חשיפת ממשקי API של גישה לנתונים כ-$.Dede היא בדרך כלל התכנון הנכון. זה ברור לגבי נתונים מרוחקים, כי קריאות מרוחקות סינכרוניות יהרסו לחלוטין את חוויית המשתמש, אבל זה נכון גם לגבי נתונים מקומיים, כי לרוב ממשקי ה-API ברמה נמוכה יותר (למשל, SQLite ו-IndexedDB) הן אסינכרוניות בעצמן. הפונקציות $.when ו-‎.pipe של Deferred API הן פונקציות חזקות מאוד לסנכרון ולשרשור של שאילתות משנה אסינכררוניות.

אנימציות של ממשק משתמש: תזמור של אנימציה אחת או יותר עם אירועי transitionEnd יכול להיות משימה מייגעת, במיוחד כשהאנימציות הן שילוב של אנימציית CSS3 ו-JavaScript (כפי שקורה לרוב). עיבוד פונקציות האנימציה בהתאם לעיכוב יכול להפחית באופן משמעותי את מורכבות הקוד ולשפר את הגמישות. גם פונקציית מעטפת גנרית פשוטה כמו cssAnimation(className) שתחזיר את אובייקט ה-Promise שמתקבל ב-transitionEnd יכולה לעזור מאוד.

תצוגה של רכיבי ממשק משתמש: זאת קצת יותר מתקדמת, אבל גם מסגרות של רכיבי HTML מתקדמות צריכות להשתמש גם ב-frameworks שנדחו. בלי להיכנס יותר מדי לפרטים (זה יהיה נושא של פוסט אחר), כשאפליקציה צריכה להציג חלקים שונים בממשק המשתמש, עטיפת מחזור החיים של הרכיבים האלה ב-Deferred מאפשרת שליטה רבה יותר על התזמון.

כל API אסינכררוני בדפדפן: למטרות נורמליזציה, לרוב מומלץ לעטוף את הקריאות ל-API בדפדפן כ-Deferred. נדרשות 4 עד 5 שורות קוד כל אחת, אבל כל קוד של אפליקציה יהיה פשוט יותר. כפי שמוצג בקוד הפסאודו-פסאודו של getData/getLocation, הקוד של האפליקציות יכול לקבל מודל אסינכרוני אחד בכל סוגי ה-API (דפדפנים, מידע ספציפי על אפליקציות והרכב).

שמירה במטמון: זה סוג של יתרון צדדי, אבל הוא יכול להיות שימושי מאוד במקרים מסוימים. כי Promise APIs (למשל, ניתן לקרוא ל- .done(...) ול- .fail(...) ) לפני או אחרי ביצוע הקריאה האסינכרונית, אפשר להשתמש באובייקט שנדחה ככינוי שמירה במטמון לשיחה אסינכרונית. לדוגמה, אפשר להשתמש ב-CacheManager כדי לעקוב אחרי משימות שהושהו לבקשות מסוימות, ולהחזיר את ה-Promise של המשימה המושהית התואמת אם היא לא בוטלה. היתרון הוא שהמבצע של הקריאה החוזרת לא צריך לדעת אם הבעיה כבר נפתרה או שהיא בתהליך פתרון, כי הפונקציה שלו תופעל באותו אופן.

סיכום

המושג של $.Deferred פשוט, אבל יכול להיות שייקח זמן להבין אותו לעומק. עם זאת, לאור אופי סביבת הדפדפן, כל מפתח אפליקציות HTML5 רציני חייב לשלוט בתכנות אסינכררונית ב-JavaScript, ודפוס Promise (וההטמעה של jQuery) הם כלים מצוינים שבעזרתם אפשר ליצור תכנות אסינכררונית אמינה וחזקה.