Asynch JS - พลังของ $.Deferred

เจเรมี โชน
เจเรมี โชน

สิ่งสำคัญที่สุดอย่างหนึ่งในการสร้างแอปพลิเคชัน HTML5 ที่ราบรื่นและตอบสนองได้ดีคือการซิงค์ระหว่างส่วนต่างๆ ทั้งหมดของแอปพลิเคชัน เช่น การดึงข้อมูล การประมวลผล ภาพเคลื่อนไหว และองค์ประกอบอินเทอร์เฟซผู้ใช้

ความแตกต่างที่สำคัญของสภาพแวดล้อมเดสก์ท็อปหรือสภาพแวดล้อมแบบเนทีฟคือเบราว์เซอร์จะไม่ให้สิทธิ์เข้าถึงโมเดลเทรดและให้เทรดเดียวสำหรับทุกสิ่งที่เข้าถึงอินเทอร์เฟซผู้ใช้ (เช่น DOM) ซึ่งหมายความว่าตรรกะของแอปพลิเคชันทั้งหมดที่เข้าถึงและแก้ไของค์ประกอบของอินเทอร์เฟซผู้ใช้จะอยู่ในเทรดเดียวกันเสมอ ดังนั้นความสำคัญของการทำให้หน่วยงานของแอปพลิเคชันทั้งหมดมีขนาดเล็กและมีประสิทธิภาพที่สุดเท่าที่จะเป็นไปได้ รวมถึงใช้ประโยชน์จากความสามารถที่ไม่พร้อมกันที่เบราว์เซอร์มีให้มากที่สุด

API แบบอะซิงโครนัสของเบราว์เซอร์

โชคดีที่เบราว์เซอร์มี API แบบไม่พร้อมกันจำนวนหนึ่ง เช่น API XHR (XMLHttpRequest หรือ "AJAX") ที่ใช้กันโดยทั่วไป รวมทั้ง พนักงานเว็บ 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();

เหตุการณ์ CSS3transitionEnd เป็นอีกตัวอย่างหนึ่งของ 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 มิลลิวินาที) ควรแสดงแบบไม่พร้อมกันตั้งแต่เริ่มต้น แม้ว่าการใช้งานครั้งแรกจะเป็นแบบพร้อมกันก็ตาม

การจัดการความล้มเหลว

การจับโปรแกรมแบบอะซิงโครนัสอย่างหนึ่งคือวิธีลอง/จับผิดวิธีแบบดั้งเดิมที่ใช้ไม่ได้ผลอีกต่อไป เนื่องจากข้อผิดพลาดมักจะเกิดขึ้นในชุดข้อความอื่น ดังนั้น ผู้โทรจะต้องมีวิธีแบบแผนในการแจ้งให้ผู้โทรทราบเมื่อเกิดข้อผิดพลาดระหว่างการประมวลผล

ใน 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 ตัวทำงานเสร็จก่อนที่จะทำ 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 ได้มากเท่าที่ต้องการ เนื่องจากการใช้ Promise ที่เกี่ยวข้อง (โค้ด jQuery) จะจัดการการลงทะเบียนและ Callback

ด้วยรูปแบบนี้ การใช้โค้ดการซิงค์ขั้นสูงขึ้นจึงค่อนข้างง่าย และ 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) เพื่อให้ผู้เรียกใช้สามารถลงทะเบียนฟังก์ชันที่ดำเนินการสำเร็จและล้มเหลวได้ จากนั้น เมื่อการเรียก XHR กลับมา จะมีการแปลงค่าล่าช้า (3.1) หรือปฏิเสธ (3.2) การทำ Deferred.resolve จะทริกเกอร์ฟังก์ชัน "เสร็จสิ้น" (...) ทั้งหมด และฟังก์ชันสัญญาอื่นๆ (เช่น จากนั้นและไปป์) และการเรียกใช้ Deferred.reject จะเรียกใช้ฟังก์ชันผิดพลาด() ทั้งหมด

กรณีการใช้งาน

กรณีการใช้งานที่ดีซึ่งการเลื่อนเวลาได้จะเป็นประโยชน์อย่างมากมีดังนี้

การเข้าถึงข้อมูล: การแสดง API การเข้าถึงข้อมูลเป็น $.Deferred มักมีการออกแบบที่ถูกต้อง ข้อมูลนี้เห็นได้ชัดสำหรับข้อมูลระยะไกล เนื่องจากการเรียกใช้จากระยะไกลแบบพร้อมกันจะทำลายประสบการณ์ของผู้ใช้โดยสิ้นเชิง และยังเป็นจริงเกี่ยวกับข้อมูลในเครื่องเช่นเดียวกับ API ระดับล่าง (เช่น SQLite และ IndexedDB) เป็นแบบอะซิงโครนัสในตัวเอง $.when และ .pipe ของ Deferred API มีประโยชน์อย่างยิ่งในการซิงค์ข้อมูลและเชื่อมโยงคำค้นหาย่อยแบบไม่พร้อมกัน

ภาพเคลื่อนไหวของ UI: การสร้างภาพเคลื่อนไหวอย่างน้อย 1 อย่างด้วยเหตุการณ์ TrueViewEnd อาจน่าเบื่อหน่าย โดยเฉพาะอย่างยิ่งเมื่อภาพเคลื่อนไหวเป็นทั้งภาพเคลื่อนไหว CSS3 และ JavaScript ปนกัน (ซึ่งมักจะเกิดขึ้นอยู่แล้ว) การรวมฟังก์ชันภาพเคลื่อนไหวแบบ Deferred จะช่วยลดความซับซ้อนของโค้ดและปรับปรุงความยืดหยุ่นได้อย่างมาก แม้แต่ฟังก์ชัน Wrapper ทั่วไปอย่าง cssAnimation(className) ที่จะแสดงผลออบเจ็กต์ Promise ที่ได้รับการแก้ไขในtransitionEnd ก็อาจมีประโยชน์อย่างยิ่ง

การแสดงผลคอมโพเนนต์ UI: เข้าใจยากขึ้นเล็กน้อย แต่เฟรมเวิร์กคอมโพเนนต์ HTML ขั้นสูงควรใช้ Deferred ด้วย หากไม่ใส่รายละเอียดมากเกินไป (โดยจะต้องเป็นเรื่องของโพสต์อื่น) เมื่อแอปพลิเคชันต้องแสดงส่วนต่างๆ ของอินเทอร์เฟซผู้ใช้ การรวมวงจรของคอมโพเนนต์เหล่านั้นไว้ใน Deferred จะทำให้ควบคุมเวลาได้มากขึ้น

API แบบไม่พร้อมกันของเบราว์เซอร์: เพื่อจุดประสงค์ในการปรับให้เป็นรูปแบบมาตรฐาน วิธีที่ดีที่สุดคือการรวมการเรียก API ของเบราว์เซอร์เป็นแบบ Deferred ซึ่งจะใช้โค้ดบรรทัดละ 4-5 บรรทัด แต่จะทำให้โค้ดของแอปพลิเคชันง่ายขึ้นมาก ตามที่แสดงในโค้ดจำลองของ getData/getLocation ด้านบน วิธีนี้จะช่วยให้โค้ดของแอปพลิเคชันมีโมเดลแบบไม่พร้อมกันใน API ทุกประเภท (เบราว์เซอร์ ข้อมูลเฉพาะแอปพลิเคชัน และแบบผสม)

การแคช: นี่เป็นประโยชน์อย่างหนึ่งแต่อาจมีประโยชน์มากในบางกรณี เนื่องจาก Promise API (เช่น สามารถเรียกใช้ .done(...) และ .fail(...)) ก่อนหรือหลังการเรียกใช้แบบไม่พร้อมกัน และใช้ออบเจ็กต์ที่เลื่อนเวลาเป็นแฮนเดิลการแคชสำหรับการเรียกแบบไม่พร้อมกันได้ ตัวอย่างเช่น CacheManager สามารถติดตามการล่าช้าของคำขอที่ระบุและส่งคืนคำมั่นสัญญาที่มีการเลื่อนเวลาที่ตรงกันในกรณีที่ข้อมูลยังคงเป็นโมฆะ ข้อดีคือ ผู้โทรไม่จำเป็นต้องทราบว่าการโทรติดแล้วหรือกำลังได้รับการแก้ไข ระบบจะเรียกใช้ฟังก์ชันเรียกกลับในลักษณะเดียวกัน

บทสรุป

แม้ว่าแนวคิดของ $.Deferred จะเรียบง่าย แต่อาจต้องใช้เวลาสักพักจึงจะเข้าใจแนวคิดนี้ อย่างไรก็ตาม ด้วยลักษณะของสภาพแวดล้อมของเบราว์เซอร์ การเชี่ยวชาญการเขียนโปรแกรมแบบอะซิงโครนัสใน JavaScript เป็นสิ่งจำเป็นสำหรับนักพัฒนาแอปพลิเคชัน HTML5 ที่จริงจัง และรูปแบบ Promise (และการติดตั้งใช้งาน jQuery) คือเครื่องมือที่ยอดเยี่ยม เพื่อทำให้การเขียนโปรแกรมแบบไม่พร้อมกันมีความน่าเชื่อถือและมีประสิทธิภาพ