Asynch JS – Sức mạnh của $.Deferred

Jeremy Chone
Jeremy Chone

Một trong những khía cạnh quan trọng nhất khi xây dựng ứng dụng HTML5 mượt mà và thích ứng là việc đồng bộ hoá giữa tất cả các phần khác nhau của ứng dụng, chẳng hạn như tìm nạp, xử lý dữ liệu, ảnh động và các thành phần giao diện người dùng.

Điểm khác biệt chính với máy tính hoặc môi trường gốc là trình duyệt không cấp quyền truy cập vào mô hình tạo luồng và cung cấp một luồng duy nhất cho mọi hoạt động truy cập vào giao diện người dùng (tức là DOM). Điều này có nghĩa là tất cả logic ứng dụng truy cập và sửa đổi các phần tử giao diện người dùng luôn nằm trong cùng một luồng, do đó, tầm quan trọng của việc giữ cho tất cả đơn vị công việc của ứng dụng nhỏ và hiệu quả nhất có thể, đồng thời tận dụng mọi tính năng không đồng bộ mà trình duyệt cung cấp nhiều nhất có thể.

API không đồng bộ của trình duyệt

May mắn thay, Trình duyệt cung cấp một số API không đồng bộ như API XHR (XMLHttpRequest hoặc "AJAX") thường dùng, cũng như IndexedDB, SQLite, trình chạy web HTML5 và API Vị trí địa lý HTML5. Ngay cả một số thao tác liên quan đến DOM cũng được hiển thị không đồng bộ, chẳng hạn như ảnh động CSS3 thông qua các sự kiện transitionEnd.

Cách trình duyệt hiển thị hoạt động lập trình không đồng bộ cho logic ứng dụng là thông qua các sự kiện hoặc lệnh gọi lại.
Trong các API không đồng bộ dựa trên sự kiện, nhà phát triển đăng ký một trình xử lý sự kiện cho một đối tượng nhất định (ví dụ: Phần tử HTML hoặc các đối tượng DOM khác) rồi gọi hành động. Trình duyệt sẽ thực hiện thao tác này thường trong một luồng khác và kích hoạt sự kiện trong luồng chính khi thích hợp.

Ví dụ: mã sử dụng API XHR, một API không đồng bộ dựa trên sự kiện, sẽ có dạng như sau:

// 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();

Sự kiện transitionEnd CSS3 là một ví dụ khác về API không đồng bộ dựa trên sự kiện.

// 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') 

Các API trình duyệt khác, chẳng hạn như SQLite và API vị trí địa lý HTML5, dựa trên lệnh gọi lại, nghĩa là nhà phát triển truyền một hàm dưới dạng đối số sẽ được lệnh triển khai cơ bản gọi lại bằng độ phân giải tương ứng.

Ví dụ: đối với Vị trí địa lý HTML5, mã sẽ trông giống như sau:

// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){  
        alert('Lat: ' + position.coords.latitude + ' ' +  
                'Lon: ' + position.coords.longitude);  
});  

Trong trường hợp này, chúng ta chỉ cần gọi một phương thức và truyền một hàm sẽ được gọi lại bằng kết quả đã yêu cầu. Điều này cho phép trình duyệt triển khai chức năng này một cách đồng bộ hoặc không đồng bộ và cung cấp một API duy nhất cho nhà phát triển bất kể chi tiết triển khai.

Chuẩn bị ứng dụng để hoạt động không đồng bộ

Ngoài các API không đồng bộ tích hợp sẵn của trình duyệt, các ứng dụng được thiết kế tốt cũng phải hiển thị các API cấp thấp theo cách không đồng bộ, đặc biệt là khi thực hiện bất kỳ loại I/O hoặc xử lý điện toán nặng nào. Ví dụ: API để lấy dữ liệu phải không đồng bộ và KHÔNG được như sau:

// WRONG: this will make the UI freeze when getting the data  
var data = getData();
alert("We got data: " + data);

Thiết kế API này yêu cầu chặn getData(). Thao tác này sẽ đóng băng giao diện người dùng cho đến khi tìm nạp dữ liệu. Nếu dữ liệu ở cục bộ trong ngữ cảnh JavaScript thì đây có thể không phải là vấn đề. Tuy nhiên, nếu dữ liệu cần được tìm nạp từ mạng hoặc thậm chí là cục bộ trong SQLite hoặc kho chỉ mục, thì điều này có thể ảnh hưởng đáng kể đến trải nghiệm người dùng.

Thiết kế phù hợp là chủ động tạo tất cả API ứng dụng có thể mất chút thời gian để xử lý, không đồng bộ ngay từ đầu vì việc cải tiến mã ứng dụng đồng bộ thành không đồng bộ có thể là một nhiệm vụ khó khăn.

Ví dụ: API getData() đơn giản sẽ trở thành như sau:

getData(function(data){
alert("We got data: " + data);
});

Điều hay của phương pháp này là buộc mã giao diện người dùng của ứng dụng phải tập trung vào tính không đồng bộ ngay từ đầu và cho phép các API cơ bản quyết định xem liệu chúng có cần không đồng bộ hay không ở giai đoạn sau.

Xin lưu ý rằng không phải tất cả API ứng dụng đều cần hoặc nên không đồng bộ. Nguyên tắc chung là mọi API thực hiện bất kỳ loại I/O hoặc xử lý nặng nào (mọi thứ có thể mất hơn 15 mili giây) đều phải được hiển thị không đồng bộ ngay từ đầu, ngay cả khi quá trình triển khai đầu tiên là đồng bộ.

Xử lý lỗi

Một điểm cần lưu ý khi lập trình không đồng bộ là cách try/catch truyền thống để xử lý lỗi không còn hoạt động nữa, vì lỗi thường xảy ra trong một luồng khác. Do đó, phương thức được gọi cần có một cách thức có cấu trúc để thông báo cho phương thức gọi khi có sự cố trong quá trình xử lý.

Trong API không đồng bộ dựa trên sự kiện, việc này thường được thực hiện bằng cách mã ứng dụng truy vấn sự kiện hoặc đối tượng khi nhận được sự kiện. Đối với các API không đồng bộ dựa trên lệnh gọi lại, phương pháp hay nhất là có một đối số thứ hai lấy một hàm sẽ được gọi trong trường hợp không thành công với thông tin lỗi thích hợp làm đối số.

Lệnh gọi getData của chúng ta sẽ có dạng như sau:

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

Kết hợp với $.Deferred

Một hạn chế của phương pháp gọi lại ở trên là việc viết thậm chí logic đồng bộ hoá nâng cao ở mức vừa phải có thể trở nên thực sự cồng kềnh.

Ví dụ: nếu bạn cần đợi 2 API không đồng bộ hoàn tất trước khi thực hiện API thứ ba, thì độ phức tạp của mã có thể tăng nhanh.

// 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);
});

Mọi thứ thậm chí có thể trở nên phức tạp hơn khi ứng dụng cần thực hiện cùng một lệnh gọi từ nhiều phần của ứng dụng, vì mọi lệnh gọi sẽ phải thực hiện các lệnh gọi nhiều bước này hoặc ứng dụng sẽ phải triển khai cơ chế lưu vào bộ nhớ đệm của riêng ứng dụng.

May mắn thay, có một mẫu tương đối cũ, được gọi là Promises (tương tự như Future trong Java) và một cách triển khai mạnh mẽ và hiện đại trong lõi jQuery có tên là $.Deferred. Mẫu này cung cấp một giải pháp đơn giản và mạnh mẽ cho việc lập trình không đồng bộ.

Nói một cách đơn giản hơn, mẫu Promises xác định rằng API không đồng bộ sẽ trả về đối tượng Promise, tức là đối tượng Promise, thuộc loại “Promise” (Hứa hẹn rằng kết quả sẽ được phân giải bằng dữ liệu tương ứng). Để nhận được độ phân giải, phương thức gọi sẽ nhận đối tượng Promise và gọi một done(SuccessFunc(data)). Đối tượng này sẽ báo cho đối tượng Promise gọi thành côngFunc khi “dữ liệu” được phân giải.

Vì vậy, ví dụ về lệnh gọi getData ở trên sẽ trở thành như sau:

// 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);
});

Ở đây, trước tiên, chúng ta lấy đối tượng dataPromise rồi gọi phương thức .done để đăng ký một hàm mà chúng ta muốn được gọi lại khi dữ liệu được phân giải. Chúng ta cũng có thể gọi phương thức .fail để xử lý lỗi cuối cùng. Xin lưu ý rằng chúng ta có thể có nhiều lệnh gọi .done hoặc .fail theo nhu cầu vì việc triển khai Promise cơ bản (mã jQuery) sẽ xử lý việc đăng ký và gọi lại.

Với mẫu này, bạn có thể dễ dàng triển khai mã đồng bộ hoá nâng cao hơn và jQuery đã cung cấp mã phổ biến nhất như $.when.

Ví dụ: lệnh gọi lại getData/getLocation lồng nhau ở trên sẽ trở thành như sau:

// 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);
});  

Và điều tuyệt vời là jQuery.Deferred giúp nhà phát triển dễ dàng triển khai hàm không đồng bộ. Ví dụ: getData có thể có dạng như sau:

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();
}

Vì vậy, khi getData() được gọi, trước tiên, hàm này sẽ tạo một đối tượng jQuery.Deferred mới (1) rồi trả về Promise (2) để phương thức gọi có thể đăng ký các hàm đã hoàn tất và không thành công. Sau đó, khi lệnh gọi XHR trả về, lệnh gọi này sẽ phân giải lệnh gọi bị trì hoãn (3.1) hoặc từ chối lệnh gọi đó (3.2). Việc thực hiện deferred.resolve sẽ kích hoạt tất cả các hàm done(…) và các hàm promise khác (ví dụ: then và pipe) và việc gọi deferred.reject sẽ gọi tất cả các hàm fail().

Trường hợp sử dụng

Sau đây là một số trường hợp sử dụng hiệu quả mà tính năng Trì hoãn có thể rất hữu ích:

Truy cập dữ liệu: Hiện các API truy cập dữ liệu vì $.Deferred thường là cách phù hợp. Điều này là rõ ràng đối với dữ liệu từ xa, vì các lệnh gọi từ xa đồng bộ sẽ làm hỏng hoàn toàn trải nghiệm người dùng, nhưng cũng đúng với dữ liệu cục bộ thường gặp ở các API cấp thấp hơn (ví dụ: SQLite và IndexedDB) tự đồng bộ. $.when và .pipe của API trì hoãn cực kỳ mạnh mẽ để đồng bộ hoá và tạo chuỗi truy vấn phụ không đồng bộ.

Ảnh động trên giao diện người dùng: Việc điều phối một hoặc nhiều ảnh động bằng sự kiện transitionEnd có thể khá tẻ nhạt, đặc biệt là khi ảnh động là sự kết hợp của ảnh động CSS3 và JavaScript (như thường lệ). Việc gói các hàm ảnh động dưới dạng Deferred có thể làm giảm đáng kể độ phức tạp của mã và cải thiện tính linh hoạt. Ngay cả một hàm trình bao bọc chung đơn giản như cssAnimation(className) sẽ trả về đối tượng Promise được giải quyết trên TransitionEnd cũng có thể giúp ích rất nhiều.

Hiển thị thành phần giao diện người dùng: Đây là giao diện nâng cao hơn một chút, nhưng các khung Thành phần HTML nâng cao cũng nên sử dụng Deferred. Không đi sâu vào chi tiết (đây sẽ là chủ đề của một bài đăng khác), khi một ứng dụng cần hiển thị nhiều phần của giao diện người dùng, việc có vòng đời của các thành phần đó được đóng gói trong Deferred cho phép kiểm soát thời gian tốt hơn.

Mọi API không đồng bộ của trình duyệt: Đối với mục đích chuẩn hoá, bạn nên gói các lệnh gọi API của trình duyệt dưới dạng Trì hoãn. Mỗi lớp này sẽ có 4 đến 5 dòng mã, nhưng sẽ đơn giản hoá đáng kể mọi mã xử lý ứng dụng. Như đã trình bày trong mã giả getData/getLocation ở trên, điều này cho phép mã ứng dụng có một mô hình không đồng bộ trên tất cả các loại API (trình duyệt, thông số cụ thể về ứng dụng và phức hợp).

Lưu vào bộ nhớ đệm: Đây là một lợi ích phụ nhưng có thể rất hữu ích trong một số trường hợp. Vì các API Lời hứa (ví dụ: Bạn có thể gọi .done(…) và .fail(…) trước hoặc sau khi thực hiện lệnh gọi không đồng bộ, đối tượng Deferred có thể được dùng làm tay cầm lưu vào bộ nhớ đệm cho lệnh gọi không đồng bộ. Ví dụ: CacheManager chỉ có thể theo dõi Deferred cho các yêu cầu nhất định và trả về Promise của Deferred phù hợp nếu Promise đó chưa bị vô hiệu. Điểm thú vị là phương thức gọi không cần biết liệu lệnh gọi đã được giải quyết hay đang trong quá trình được phân giải, thì hàm callback sẽ được gọi theo cách tương tự.

Kết luận

Mặc dù khái niệm $.Deferred rất đơn giản, nhưng bạn có thể mất thời gian để nắm bắt được khái niệm này. Tuy nhiên, do bản chất của môi trường trình duyệt, việc lập trình không đồng bộ trong JavaScript là điều bắt buộc đối với bất kỳ nhà phát triển ứng dụng HTML5 nghiêm túc nào và mẫu Promise (và cách triển khai jQuery) là những công cụ to lớn giúp việc lập trình không đồng bộ trở nên đáng tin cậy và mạnh mẽ.