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

ג'רמי צ'ון
ג'רמי צ'ון

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

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

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

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

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

אירוע המעבר מ-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, הקוד נראה כך:

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

טיפול בכשלים

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

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

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

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

יחד עם $.Deded

אחת המגבלות של הגישה לקריאה החוזרת (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 Core שנקראת $.Deferred, שמספקת פתרון פשוט וחזק לתכנות אסינכרוני.

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

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

באמצעות הדפוס הזה קל יחסית להטמיע קוד מתקדם יותר לסנכרון, ו-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) כדי שהמתקשר יוכל לרשום את הפונקציות כשהוא מוגדר ונכשל. לאחר מכן, כשחוזרת קריאת ה-XHR, היא מבטלת את הדחייה (3.1) או דוחה אותה (3.2). ביצוע הפעולה deferred.resolve יגרום להפעלה של כל פונקציות Make(...) ופונקציות אבטחה אחרות (למשל, אז ו-pipe) וקריאה ל-deferred.rejects תפעיל את כל פונקציות ה-fail() .

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

הנה כמה תרחישים טובים לדוגמה שבהם עדיף להשתמש בדחייה:

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

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

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

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

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

סיכום

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