אחד מהיבטים החשובים ביותר בבניית אפליקציות HTML5 חלקות ותגובניות הוא הסנכרון בין כל החלקים השונים של האפליקציה, כמו אחזור נתונים, עיבוד, אנימציות ורכיבי ממשק המשתמש.
ההבדל העיקרי בין סביבת דפדפן לסביבה מקומית הוא שבדפדפנים אין גישה למודל השרשור, והם מספקים שרשור יחיד לכל מה שמקבל גישה לממשק המשתמש (כלומר ה-DOM). המשמעות היא שכל הלוגיקה של האפליקציה שמבצעת גישה אל רכיבי ממשק המשתמש ומשנים אותם תמיד נמצאת באותו חוט, ולכן חשוב לשמור על כל יחידות העבודה של האפליקציה קטנות ויעילות ככל האפשר ולנצל את כל היכולות האסינכרוניות שהדפדפן מציע ככל האפשר.
ממשקי API אסינכרוניים לדפדפן
למרבה המזל, הדפדפנים מספקים מספר ממשקי API אסינכררוניים, כמו ממשקי ה-API הנפוצים של XHR (XMLHttpRequest או 'AJAX'), וגם IndexedDB, SQLite, Web workers של HTML5 וממשקי ה-API של GeoLocation ב-HTML5, כדי לציין כמה מהם. גם חלק מהפעולות שקשורות ל-DOM נחשפות באופן אסינכרוני, כמו אנימציה של CSS3 דרך אירועי transitionEnd.
הדפדפנים חושפים את התכנות האסינכרוני ללוגיקת האפליקציה באמצעות אירועים או פונקציות חזרה (callbacks).
בממשקי API אסינכררוניים מבוססי-אירועים, המפתחים רושמים טיפול באירוע לאובייקט נתון (למשל, אלמנט HTML או אובייקטים אחרים של DOM) ואז קוראים לפעולה. הדפדפן יבצע את הפעולה בדרך כלל בשרשור אחר, ויפעיל את האירוע בשרשור הראשי במקרים הרלוונטיים.
לדוגמה, קוד שמשתמש ב-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();
האירוע transitionEnd ב-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 אלפיות השנייה) צריך להיות חשוף באופן אסינכרוני מההתחלה, גם אם ההטמעה הראשונה היא סינכרונית.
טיפול בכשלים
אחד מהחסרונות של תכנות אסינכרוני הוא ששיטת try/catch המסורתית לטיפול בכשלים כבר לא עובדת, כי בדרך כלל השגיאות מתרחשות בשרשור אחר. לכן, לגורם שאליו מתבצעת הקריאה צריכה להיות דרך מובנית להודיע למבצע הקריאה כשמשהו משתבש במהלך העיבוד.
ב-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 כדי לרשום פונקציה שרוצים שתתקבל חזרה כשהנתונים יתקבלו. אפשר גם להפעיל את השיטה .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 לגישה לנתונים כ-$.Deferred. זה ברור לגבי נתונים מרוחקים, כי קריאות מרוחקות סינכרוניות יהרסו לחלוטין את חוויית המשתמש, אבל זה נכון גם לגבי נתונים מקומיים, כי לרוב ממשקי ה-API ברמה נמוכה יותר (למשל, SQLite ו-IndexedDB) הן אסינכרוניות בעצמן. הפונקציות $.when ו-.pipe של Deferred API הן פונקציות חזקות מאוד לסנכרון ולשרשור של שאילתות משנה אסינכרוניות.
אנימציות של ממשק משתמש: תזמור של אנימציה אחת או יותר עם אירועי transitionEnd יכול להיות משימה מייגעת למדי, במיוחד כשהאנימציות הן שילוב של אנימציית CSS3 ו-JavaScript (כפי שקורה לרוב). כשעוטפים את פונקציות האנימציה כ-Deferred, אפשר להפחית באופן משמעותי את המורכבות של הקוד ולשפר את הגמישות. גם פונקציית מעטפת גנרית פשוטה כמו cssAnimation(className) שתחזיר את אובייקט ה-Promise שמטופל ב-transitionEnd יכולה לעזור מאוד.
תצוגת רכיבי ממשק משתמש: זו דרך מתקדמת יותר, אבל גם בפלטפורמות מתקדמות של רכיבי HTML צריך להשתמש ב-Deferred. בלי להיכנס יותר מדי לפרטים (זה יהיה נושא של פוסט אחר), כשאפליקציה צריכה להציג חלקים שונים בממשק המשתמש, היכולת להכיל את מחזור החיים של הרכיבים האלה ב-Deferred מאפשרת שליטה רבה יותר על התזמון.
כל API אסינכררוני בדפדפן: למטרות נורמליזציה, לרוב מומלץ לעטוף את הקריאות ל-API בדפדפן כ-Deferred. כל אחד מהם מורכב מ-4 עד 5 שורות קוד, אבל הוא פשוט הרבה יותר מקוד האפליקציה. כפי שמוצג בקוד הפסאודו של getData/getLocation שלמעלה, כך אפשר ליצור מודל אסינכרוני אחד בקוד האפליקציה לכל סוגי ה-API (דפדפנים, ספציפיים לאפליקציות ומורכב).
אחסון במטמון: זוהי תועלת משנית, אבל היא יכולה להיות שימושית מאוד במקרים מסוימים. כי ממשקי ה-Promise API (למשל, .done(…) ו-.fail(…)) לפני או אחרי ביצוע הקריאה האסינכרונית, אפשר להשתמש באובייקט Deferred כמזהה לשמירה במטמון של קריאה אסינכררונית. לדוגמה, אפשר להשתמש ב-CacheManager כדי לעקוב אחרי משימות שהושהו לבקשות מסוימות, ולהחזיר את ה-Promise של המשימה המושהית התואמת אם היא לא בוטלה. היתרון הוא שהמבצע של הקריאה החוזרת לא צריך לדעת אם הבעיה כבר נפתרה או שהיא בתהליך פתרון, כי פונקציית הקריאה החוזרת שלו תופעל באותו אופן.
סיכום
המושג של $.Deferred פשוט, אבל יכול להיות שייקח זמן להבין אותו לעומק. עם זאת, לאור אופי סביבת הדפדפן, כל מפתח אפליקציות HTML5 רציני חייב לשלוט בתכנות אסינכררונית ב-JavaScript, ודפוס Promise (וההטמעה של jQuery) הם כלים מצוינים שבעזרתם אפשר ליצור תכנות אסינכררונית אמינה וחזקה.