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 về việc xây dựng ứng dụng HTML5 mượt mà và phản hồi nhanh là tính năng đồng bộ hoá giữa tất cả các phần của ứng dụng, chẳng hạn như tìm nạp dữ liệu, 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à các trình duyệt không cấp quyền truy cập vào mô hình phân luồng và cung cấp một luồng duy nhất cho mọi thứ truy cập vào giao diện người dùng (cụ thể là DOM). Điều này có nghĩa là tất cả logic của ứ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ả các đơn vị công việc của ứng dụng nhỏ và hiệu quả nhất có thể, cũng như tận dụng mọi khả 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ộ, chẳng hạn như API XHR (XMLHttpRequest hoặc "AJAX") thường dùng, cũng như IndexedDB, SQLite, Trình chạy web HTML5 và API HTML5 GeoLocation, v.v. Ngay cả một số thao tác liên quan đến DOM cũng hiển thị không đồng bộ, như ảnh động CSS3 thông qua các sự kiện transitionEnd.

Cách các trình duyệt hiển thị chương trình không đồng bộ cho logic ứng dụng thông qua 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ý 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), sau đó gọi hành động. Trình duyệt sẽ thực hiện thao tác 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 (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 của 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à vị trí địa lý HTML5, được dựa trên lệnh gọi lại, nghĩa là nhà phát triển sẽ truyền một hàm làm đối số để được gọi lại bằng phương thức triển khai cơ sở với độ phân giải tương ứng.

Ví dụ: đối với Vị trí địa lý HTML5, mã sẽ có dạ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ỉ gọi một phương thức và truyền một hàm sẽ nhận được lệnh gọi lại với kết quả được 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ể thông tin triển khai là gì.

Làm cho ứng dụng ở trạng thái 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ó cấu trúc hợp lý cũng nên hiển thị các API cấp thấp theo kiểu không đồng bộ, đặc biệt là khi chúng thực hiện bất kỳ thao tác I/O hoặc xử lý nặng tính toán nào. Ví dụ: các API để nhận dữ liệu phải không đồng bộ và KHÔNG nên có dạng 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 getData() phải chặn. Thao tác này sẽ đóng băng giao diện người dùng cho đến khi dữ liệu được tìm nạp. Nếu dữ liệu được lưu trữ 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 lưu trữ 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 làm cho tất cả API ứng dụng có thể mất một chút thời gian để xử lý, không đồng bộ ngay từ đầu vì việc trang bị thêm mã ứng dụng đồng bộ thành không đồng bộ có thể là một tác vụ khó khăn.

Ví dụ: API getData() đơn giản sẽ có dạng như sau:

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

Điểm hay của phương pháp này là phương pháp này buộc mã giao diện người dùng của ứng dụng không được làm trung tâm ngay từ đầu và cho phép các API cơ bản quyết định xem 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 không nên đồng bộ. Quy 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 (bất kỳ hoạt động nào có thể mất hơn 15 mili giây) đều phải hiển thị không đồng bộ ngay từ đầu ngay cả khi lần triển khai đầu tiên là đồng bộ.

Xử lý lỗi

Một điểm đáng lưu ý của việc lập trình không đồng bộ là cách thử/nắm bắt truyền thống để xử lý các lỗi không thực sự 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 gọi cần có một cách có cấu trúc để thông báo cho phương thức gọi khi xảy ra sự cố trong quá trình xử lý.

Trong API không đồng bộ dựa trên sự kiện, điều này thường được thực hiện bằng mã xử lý ứ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, cách tốt nhất là có đố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 $.Deferred

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

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

// 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 việc thậm chí có thể 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 riêng.

May mắn thay, có một mẫu tương đối cũ tên là Promises (tương tự như Future trong Java) và cách triển khai mạnh mẽ và hiện đại trong lõi jQuery có tên là $.Deferred, 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, mẫu Promises xác định rằng API không đồng bộ sẽ trả về một đối tượng Promise thuộc loại "Hứa hẹn rằng kết quả sẽ được phân giải bằng dữ liệu tương ứng". Để có được độ phân giải, phương thức gọi sẽ nhận được đối tượng Promise và gọi một thực hiện(Func(data)). Thao tác này sẽ yêu cầu đối tượng Promise gọi đối tượng này thành côngFunc khi "dữ liệu" được giải quyết.

Vì vậy, ví dụ về lệnh gọi getData ở trên sẽ có dạng 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, sau đó 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 giải quyết. 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ó số lượng lệnh gọi .don hoặc .fail tuỳ 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ể tương đối dễ dàng triển khai mã đồng bộ hoá nâng cao hơn và jQuery đã cung cấp mã đồng bộ hoá 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 dạng 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à ưu điểm của jQuery.Deferred là giúp các nhà phát triển dễ dàng triển khai hàm không đồng bộ. Ví dụ: getData 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, phương thức 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 sẽ giải quyết bị trễ (3.1) hoặc từ chối (3.2). Việc thực hiện Delayed.resolve sẽ kích hoạt tất cả các hàm done(...) và các hàm hứa hẹn khác (ví dụ: then và pipe) và gọi Delayed.reject sẽ gọi tất cả các hàm failed().

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

Dưới đây là một số trường hợp sử dụng hay mà Deferred có thể rất hữu ích:

Quyền truy cập vào dữ liệu: Việc hiển thị các API truy cập dữ liệu dưới dạng $.Deferred thường là cách thiết kế phù hợp. Điều này là rất 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 là với các API cấp thấp hơn (ví dụ: SQLite và IndexedDB) không đồng bộ. $.when và .pipe của Deferred API cực kỳ mạnh mẽ để đồng bộ hoá và tạo chuỗi cho các truy vấn phụ không đồng bộ.

Ảnh động giao diện người dùng: Việc sắp xếp một hoặc nhiều ảnh động bằng các sự kiện transitionEnd có thể khá tẻ nhạt, đặc biệt là khi các ảnh động kết hợp giữ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 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 cho bạn.

UI Component Display (Màn hình thành phần giao diện người dùng): Tính năng này 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 quá nhiề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ị các phần khác nhau của giao diện người dùng, việc vòng đời của các thành phần được đóng gói trong Deferred sẽ giúp bạn kiểm soát thời gian hiệu quả hơn.

API không đồng bộ của trình duyệt bất kỳ: Để chuẩn hoá, bạn nên gói các lệnh gọi API trình duyệt thành Deferred. Việc này chỉ mất từ 4 đến 5 dòng mã cho mỗi mã, nhưng sẽ giúp đơn giản hoá đáng kể mọi mã xử lý ứng dụng. Như minh hoạ trong mã giả getData/getLocation ở trên, điều này cho phép mã xử lý ứ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 tin cụ thể của ứng dụng và tổ 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ì Promise API (ví dụ: .done(...) và .fail(...)) có thể được gọi 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 điều khiển lưu vào bộ nhớ đệm cho lệnh gọi không đồng bộ. Ví dụ: một CacheManager chỉ có thể theo dõi Deferred cho các yêu cầu nhất định và trả về Promise of Deferred nếu chưa bị vô hiệu hoá. Ưu điểm của phương thức gọi là phương thức gọi không cần phải biết lệnh gọi đã được giải quyết hay đang trong quá trình được giải quyết, hàm callback sẽ được gọi chính xác 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 có thể bạn cần chút thời gian để xử lý hiệu quả. Tuy nhiên, do bản chất của môi trường trình duyệt, việc thành thạo việc lập trình không đồng bộ trong JavaScript là điều cần thiết đố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 để lập trình không đồng bộ trở nên đáng tin cậy và mạnh mẽ.