สิ่งสำคัญที่สุดอย่างหนึ่งในการสร้างแอปพลิเคชัน HTML5 ที่ราบรื่นและตอบสนองตามอุปกรณ์คือการซิงค์ระหว่างส่วนต่างๆ ทั้งหมดของแอปพลิเคชัน เช่น การดึงข้อมูล การประมวลผล ภาพเคลื่อนไหว และองค์ประกอบอินเทอร์เฟซผู้ใช้
ความแตกต่างหลักกับเดสก์ท็อปหรือสภาพแวดล้อมแบบเนทีฟคือเบราว์เซอร์จะไม่ให้สิทธิ์เข้าถึงรูปแบบการแยกชุดข้อความ และจัดเตรียมเธรดเดียวสําหรับทุกอย่างที่เข้าถึงอินเทอร์เฟซผู้ใช้ (เช่น DOM) ซึ่งหมายความว่าตรรกะทั้งหมดของแอปพลิเคชันในการเข้าถึงและแก้ไของค์ประกอบอินเทอร์เฟซผู้ใช้จะอยู่ในเธรดเดียวกันเสมอ ดังนั้นจึงมีความสําคัญในการรักษาหน่วยงานทั้งหมดของแอปพลิเคชันให้เล็กและมีประสิทธิภาพมากที่สุดเท่าที่จะเป็นไปได้ รวมถึงใช้ประโยชน์จากความสามารถแบบแอซิงโครนัสทั้งหมดที่เบราว์เซอร์มีให้มากที่สุด
API แบบอะซิงโครนัสของเบราว์เซอร์
แต่โชคดีที่เบราว์เซอร์มี API แบบไม่พร้อมกันหลายรายการ เช่น XHR (XMLHttpRequest หรือ "AJAX") API ที่ใช้งานกันโดยทั่วไป รวมถึง IndexedDB, SQLite, เวิร์กเกอร์บนเว็บ HTML5 และ HTML5 GeoLocation API เป็นต้น แม้แต่การทำงานที่เกี่ยวข้องกับ DOM บางอย่างจะแสดงแบบไม่พร้อมกัน เช่น ภาพเคลื่อนไหว CSS3 ดังกล่าวผ่านเหตุการณ์ transitionEnd
วิธีที่เบราว์เซอร์แสดงการเขียนโปรแกรมแบบแอซิงโครนัสต่อตรรกะแอปพลิเคชันคือผ่านเหตุการณ์หรือคอลแบ็ก
ใน 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 โค้ดจะมีลักษณะดังนี้
// 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 ของแอปพลิเคชันทั้งหมดที่อาจต้องใช้เวลาสักครู่ในการประมวลผล ให้เป็นแบบอะซิงโครนัสตั้งแต่ต้น เนื่องจากการปรับโค้ดแอปพลิเคชันแบบซิงโครนัสใหม่ให้เป็นแบบอะซิงโครนัสอาจเป็นงานที่ท้าทาย
ตัวอย่างเช่น getData() API แบบง่ายจะกลายเป็นดังนี้
getData(function(data){
alert("We got data: " + data);
});
ข้อดีของแนวทางนี้คือ บังคับให้โค้ด UI ของแอปพลิเคชันมุ่งเน้นแบบแอซิงโครนัสตั้งแต่ต้น และช่วยให้ API ที่เกี่ยวข้องตัดสินใจได้ว่าจำเป็นต้องเป็นแบบแอซิงโครนัสหรือไม่ในขั้นตอนถัดไป
โปรดทราบว่า API ของแอปพลิเคชันบางรายการไม่จำเป็นต้องเป็นแบบแอซิงโครนัส หลักคร่าวๆ คือ API ที่ทำ I/O หรือประมวลผลอย่างหนักทุกประเภท (ทุกอย่างที่ใช้เวลานานกว่า 15 มิลลิวินาที) ควรแสดงแบบไม่พร้อมกันตั้งแต่เริ่มต้น แม้ว่าการใช้งานครั้งแรกจะเป็นแบบพร้อมกันก็ตาม
การจัดการความล้มเหลว
ลักษณะหนึ่งของการจัดโปรแกรมแบบไม่พร้อมกันคือวิธีการลอง/ตรวจจับข้อผิดพลาดแบบดั้งเดิมใช้ไม่ได้ผลแล้ว เนื่องจากเกิดข้อผิดพลาดขึ้นในชุดข้อความอื่น ผู้รับแจ้งจึงต้องมีวิธีที่มีโครงสร้างในการแจ้งเตือนผู้โทรเมื่อเกิดข้อผิดพลาดขึ้นระหว่างการประมวลผล
ใน API แบบไม่สอดคล้องตามเหตุการณ์ การดำเนินการนี้มักจะทำโดยโค้ดแอปพลิเคชันซึ่งค้นหาเหตุการณ์หรือออบเจ็กต์เมื่อได้รับเหตุการณ์ สําหรับ API แบบแอซิงโครนัสที่อิงตามการเรียกกลับ แนวทางปฏิบัติแนะนําคือให้มีอาร์กิวเมนต์ที่ 2 ซึ่งใช้ฟังก์ชันที่จะเรียกในกรณีที่ดำเนินการไม่สำเร็จพร้อมข้อมูลข้อผิดพลาดที่เหมาะสมเป็นอาร์กิวเมนต์
การเรียก getData จะมีลักษณะดังนี้
// getData(successFunc,failFunc);
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});
ประกอบกับ $.Deferred
ข้อจํากัดอย่างหนึ่งของวิธีการเรียกกลับข้างต้นคืออาจทําให้เขียนตรรกะการซิงค์ขั้นสูงในระดับปานกลางได้ยาก
ตัวอย่างเช่น หากต้องรอให้ API แบบไม่พร้อมกัน 2 รายการทํางานเสร็จก่อนจึงจะทํารายการที่ 3 ได้ ความซับซ้อนของโค้ดอาจเพิ่มขึ้นอย่างรวดเร็ว
// 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 เรียกใช้สิ่งนี้เมื่อ "dataFunc
ดังนั้น ตัวอย่างการเรียกใช้ 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 ได้มากเท่าที่ต้องการเนื่องจากการใช้ Promise ที่สำคัญ (โค้ด 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) เพื่อให้ผู้เรียกใช้สามารถลงทะเบียนฟังก์ชัน done และ fail ได้ จากนั้น เมื่อการเรียก XHR แสดงผล ระบบจะแก้ไขการเลื่อน (3.1) หรือปฏิเสธ (3.2) การดำเนินการ deferred.resolve จะทริกเกอร์ฟังก์ชัน done(…) ทั้งหมดและฟังก์ชัน Promise อื่นๆ (เช่น then และ pipe) และการเรียกใช้ deferred.reject จะเรียกใช้ฟังก์ชัน fail() ทั้งหมด
กรณีการใช้งาน
ต่อไปนี้คือกรณีการใช้งานที่ดีที่การเลื่อนเวลาออกไปจะมีประโยชน์อย่างมาก
การเข้าถึงข้อมูล: การแสดง API การเข้าถึงข้อมูลเป็น $.Deferred มักเป็นการออกแบบที่เหมาะสม เรื่องนี้เห็นได้ชัดสำหรับข้อมูลระยะไกล เนื่องจากการเรียกระยะไกลแบบซิงค์จะทำลายประสบการณ์ของผู้ใช้โดยสิ้นเชิง แต่ก็เป็นเช่นนั้นสำหรับข้อมูลในเครื่องด้วย เนื่องจาก API ระดับล่าง (เช่น SQLite และ IndexedDB) เป็นแบบไม่พร้อมกัน Deferred API $.when และ .pipe ของ Deferred API มีประสิทธิภาพอย่างมากในการซิงค์และเชื่อมโยงการค้นหาย่อยแบบไม่พร้อมกัน
ภาพเคลื่อนไหว UI: การจัดระเบียบภาพเคลื่อนไหวอย่างน้อย 1 รายการด้วยเหตุการณ์ transitionEnd อาจเป็นเรื่องน่าเบื่อมาก โดยเฉพาะเมื่อภาพเคลื่อนไหวเป็นภาพเคลื่อนไหว CSS3 และ JavaScript ผสมกัน (ซึ่งมักจะเป็นเช่นนั้น) การรวมฟังก์ชันภาพเคลื่อนไหวเป็น Deferred ช่วยลดความซับซ้อนของโค้ดและเพิ่มความยืดหยุ่นได้อย่างมาก แม้แต่ฟังก์ชัน Wrapper แบบทั่วไปอย่าง cssAnimation(className) ที่จะแสดงผลออบเจ็กต์ Promise ที่แก้ไขได้ใน transitionEnd ก็มีประโยชน์อย่างมาก
การแสดงคอมโพเนนต์ UI: ตัวเลือกนี้มีความซับซ้อนกว่าเล็กน้อย แต่เฟรมเวิร์กคอมโพเนนต์ HTML ขั้นสูงควรใช้การเลื่อนเวลาไว้ด้วย โดยไม่ต้องลงรายละเอียดมากนัก (เราจะพูดถึงเรื่องนี้ในโพสต์อื่น) เมื่อแอปพลิเคชันต้องแสดงส่วนต่างๆ ของอินเทอร์เฟซผู้ใช้ การมีวงจรชีวิตของคอมโพเนนต์เหล่านั้นที่รวมอยู่ใน Deferred จะช่วยให้ควบคุมเวลาได้มากขึ้น
API แบบไม่สอดคล้องของเบราว์เซอร์: บ่อยครั้งที่การรวมการเรียก API ของเบราว์เซอร์เป็น Deferred เป็นสิ่งที่ควรทำเพื่อวัตถุประสงค์ในการทำให้เป็นแบบมาตรฐาน ซึ่งจะใช้โค้ดเพียง 4-5 บรรทัดเท่านั้น แต่จะทำให้โค้ดแอปพลิเคชันง่ายขึ้นอย่างมาก ดังที่แสดงในโค้ดจำลอง getData/getLocation ด้านบน การดำเนินการนี้ช่วยให้โค้ดแอปพลิเคชันมีรูปแบบแบบไม่สอดคล้องกัน 1 รูปแบบใน API ทุกประเภท (เบราว์เซอร์ แอปพลิเคชันเฉพาะ และคอมโพเนนต์)
การแคช: ประโยชน์นี้ถือเป็นผลพลอยได้ แต่ก็มีประโยชน์มากในบางกรณี เนื่องจาก Promise API (เช่น .done(…) และ .fail(…)) เรียกใช้ได้ก่อนหรือหลังจากที่ทำการเรียกแบบไม่สอดคล้องกัน ออบเจ็กต์ Deferred สามารถใช้เป็นแฮนเดิลการแคชสําหรับการเรียกแบบไม่สอดคล้องกันได้ ตัวอย่างเช่น CacheManager อาจติดตาม Deferred สำหรับคำขอที่ระบุ และแสดง Promise ของ " Deferred ที่ตรงกัน" หากยังไม่ทำให้เป็นโมฆะ ข้อดีคือผู้โทรไม่จำเป็นต้องทราบว่าการโทรได้รับการแก้ไขแล้วหรืออยู่ระหว่างการแก้ไข ฟังก์ชันการเรียกกลับจะได้รับการเรียกใช้ด้วยวิธีเดียวกันทุกประการ
บทสรุป
แม้ว่าแนวคิด $.Deferred จะเข้าใจง่าย แต่อาจต้องใช้เวลาสักพักจึงจะเข้าใจวิธีใช้อย่างถ่องแท้ อย่างไรก็ตาม เนื่องด้วยลักษณะของสภาพแวดล้อมเบราว์เซอร์ การเรียนรู้การเขียนโปรแกรมแบบไม่พร้อมกันใน JavaScript จึงเป็นสิ่งจําเป็นสําหรับนักพัฒนาแอปพลิเคชัน HTML5 ที่จริงจัง และรูปแบบ Promise (และการใช้งาน jQuery) เป็นเครื่องมือที่ยอดเยี่ยมในการทําให้การเขียนโปรแกรมแบบไม่พร้อมกันมีความน่าเชื่อถือและมีประสิทธิภาพ