หนึ่งในแง่มุมที่สําคัญที่สุดในการสร้างแอปพลิเคชัน HTML5 ที่ราบรื่นและตอบสนองคือ การซิงค์ระหว่างส่วนต่างๆ ทั้งหมดของแอปพลิเคชัน เช่น การดึงข้อมูล การประมวลผล ภาพเคลื่อนไหว และองค์ประกอบอินเทอร์เฟซผู้ใช้
ความแตกต่างหลักกับเดสก์ท็อปหรือสภาพแวดล้อมแบบเนทีฟคือเบราว์เซอร์ไม่ได้ให้สิทธิ์เข้าถึงรูปแบบการแยกชุดข้อความ และจัดเตรียมเธรดเดียวสําหรับทุกอย่างที่เข้าถึงอินเทอร์เฟซผู้ใช้ (เช่น DOM) ซึ่งหมายความว่าตรรกะทั้งหมดของแอปพลิเคชันในการเข้าถึงและแก้ไของค์ประกอบอินเทอร์เฟซผู้ใช้จะอยู่ในเธรดเดียวกันเสมอ ดังนั้นจึงมีความสําคัญอย่างยิ่งที่จะต้องทําให้หน่วยงานทั้งหมดของแอปพลิเคชันมีขนาดเล็กและมีประสิทธิภาพมากที่สุดเท่าที่จะเป็นไปได้ รวมถึงใช้ประโยชน์จากความสามารถแบบแอซิงโครนัสทั้งหมดที่เบราว์เซอร์มีให้มากที่สุด
Browser Asynchronous 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 ทำงานแบบคอลแบ็ก ซึ่งหมายความว่านักพัฒนาซอฟต์แวร์จะส่งฟังก์ชันเป็นอาร์กิวเมนต์ที่การนำไปใช้งานพื้นฐานจะเรียกกลับพร้อมการแก้ปัญหาที่เกี่ยวข้อง
ตัวอย่างเช่น สําหรับตําแหน่งทางภูมิศาสตร์ 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 มิลลิวินาที) ประเภทใดก็ตามควรแสดงแบบไม่พร้อมกันตั้งแต่เริ่มต้น แม้ว่าการใช้งานครั้งแรกจะเป็นแบบพร้อมกันก็ตาม
การจัดการการทำงานผิดพลาด
ข้อเสียอย่างหนึ่งของโปรแกรมแบบไม่พร้อมกันคือวิธี try/catch แบบดั้งเดิมในการจัดการกับข้อผิดพลาดใช้ไม่ได้แล้ว เนื่องจากข้อผิดพลาดมักเกิดขึ้นในอีกเธรดหนึ่ง ดังนั้น ผู้รับสายจึงต้องมีวิธีแจ้งให้ผู้เรียกทราบเมื่อเกิดข้อผิดพลาดระหว่างการประมวลผล
ใน 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 ให้เรียกใช้ 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 ก่อน จากนั้นจึงเรียกใช้เมธอด .done เพื่อลงทะเบียนฟังก์ชันที่ต้องการให้เรียกกลับเมื่อข้อมูลได้รับการแก้ไข นอกจากนี้ เรายังเรียกใช้เมธอด .fail เพื่อจัดการกับกรณีที่ดำเนินการไม่สำเร็จในท้ายที่สุดได้ด้วย โปรดทราบว่าเราสามารถเรียกใช้ .done หรือ .fail เท่าที่ต้องการได้ เนื่องจากการใช้งาน Promise พื้นฐาน (โค้ด jQuery) จะจัดการการลงทะเบียนและการเรียกกลับ
รูปแบบนี้ช่วยให้คุณใช้โค้ดการซิงค์ขั้นสูงขึ้นได้ค่อนข้างง่าย และ jQuery มีโค้ดที่ใช้กันมากที่สุดอยู่แล้ว เช่น $.when
ตัวอย่างเช่น โค้ดเรียกกลับ 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() ทั้งหมด
กรณีการใช้งาน
ตัวอย่าง Use Case ที่มีประโยชน์ของ Deferred มีดังนี้
การเข้าถึงข้อมูล: การแสดง API การเข้าถึงข้อมูลเป็น $.Deferred มักเป็นการออกแบบที่เหมาะสม เรื่องนี้เห็นได้ชัดสำหรับข้อมูลระยะไกล เนื่องจากการเรียกระยะไกลแบบซิงค์จะทำลายประสบการณ์ของผู้ใช้โดยสิ้นเชิง แต่ก็เป็นเช่นนั้นสำหรับข้อมูลในเครื่องด้วย เนื่องจาก API ระดับล่าง (เช่น SQLite และ IndexedDB) เป็นแบบไม่พร้อมกัน $.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) เป็นเครื่องมือที่ยอดเยี่ยมในการทําให้การเขียนโปรแกรมแบบไม่พร้อมกันมีความน่าเชื่อถือและมีประสิทธิภาพ