Trong bài viết JavaScript số 11, chúng ta đã có một phần thảo luận ngắn về các hàm có chứa các thao tác xử lý được thực thi không đồng bộ asynchronous
, và giải pháp sử dụng hàm gọi lại callback
để tiếp nhận kết quả và xử lý công việc liên quan sau khi một hàm asynchronous
được thực thi xong. Trong bài viết này, chúng ta sẽ cùng thảo luận chi tiết hơn về những cách thức làm việc với các thao tác xử lý được thực thi không đồng bộ.
Làm lại ví dụ về hàm asynchronous
Ở đây chúng ta sẽ giả lập một hàm requestData
gửi yêu cầu truy vấn thêm dữ liệu tới máy chủ web được thực thi không đồng bộ và có độ trễ để nhận được kết quả phản hồi từ máy chủ web là khoảng một vài giây sau khi hàm được gọi.
Kết quả trả về từ máy chủ có thể là dữ liệu data
ở dạng chuỗi nếu máy chủ web xử lý thành công yêu cầu truy vấn thêm dữ liệu. Hoặc, một tín hiệu thông báo lỗi từ máy chủ web được phiên dịch thành một object Error
.
Lúc này chúng ta cần thực hiện một công việc tiếp theo là cập nhật giao diện người dùng và được thực hiện bởi một hàm updateView
. Do hàm requestData
được thực hiện trên một tiến trình riêng, chúng ta sẽ không thể gán giá trị trả về của requestData
vào một biến. Rồi sau đó truyền vào một lời gọi hàm updateView
được viết song song như trong code ví dụ dưới đây.
const requestData = function() {
var error, data;
const mockRequest = function() {
// --- nhận được dữ liệu
data = 'Dữ liệu trả về từ máy chủ.'
};
setTimeout(mockRequest, 2.7 * 1000);
// --- trả về kết quả ở vị trí hàm được gọi
return [ error, data ];
}; // requestData
const updateView = function(error, data) {
if (error instanceof Error)
console.error(error);
else
console.log(data);
}; // updateView
var [ error, data ] = requestData();
updateView(error, data); // 'undefined'
Lý do để dẫn tới kết quả hoạt động như trên thì chúng ta đã biết rồi. Ngay sau khi phát động lời gọi hàm requestData()
, trình thực thi code không chờ đợi thao tác gửi yêu cầu đến máy chủ được thực hiện xong, mà sẽ chuyển ngay tới lời gọi hàm tiếp theo updateView()
. Và bởi vì lúc này máy chủ web vẫn chưa gửi phản hồi lại nên biến data
không có chứa dữ liệu undefined
.
Sử dụng hàm gọi lại callback
Để hàm updateView
có thể hoạt động nối tiếp với hàm requestData
thì chúng ta có thể viết lại hàm requestData
nhận vào một hàm gọi lại callback
để tiếp nhận dữ liệu trả về từ máy chủ và thực hiện công việc tiếp theo. Sau đó truyền hàm updateView
vào vị trí callback
để được thực thi trên cùng tiến trình riêng của hàm requestData
.
const requestData = function(callback) {
const mockRequest = function() {
// --- nhận được dữ liệu
var data = 'Dữ liệu trả về từ máy chủ.';
// --- gọi hàm xử lý tiếp theo
callback(null, data);
};
setTimeout(mockRequest, 2.7 * 1000);
}; // requestData
const updateView = function(error, data) {
if (error instanceof Error)
console.error(error);
else
console.log(data);
}; // updateView
requestData(updateView);
// ...trễ 2.7s
// 'Dữ liệu trả về từ máy chủ.'
Bây giờ thì mọi thứ đã hoạt động như chúng ta dự kiến. Tuy nhiên chúng ta lại có một câu hỏi khác xuất hiện lúc này - Sẽ thế nào nếu như bên trong hàm updateView
cũng có một thao tác xử lý khác được thực thi bất đồng bộ và chúng ta cũng cần nối tiếp thêm một hành động khác sau kết quả hoạt động của updateView
?
Bởi vì lúc này hàm updateView
cũng sẽ tạo ra một tiến trình thực thi riêng khác nữa; Nếu như chúng ta muốn thực hiện thêm một hành động khác nối tiếp kết quả hoạt động của updateView
thì chúng ta sẽ lại phải định nghĩa lại hàm updateView
ở dạng thức tiếp nhận một hàm callback
khác.
Và cứ như thế đối với trường hợp chúng ta có khoảng dăm cái thao tác xử lý bất đồng bộ cần chuyển tiếp kết quả hoạt động thì chúng ta sẽ có một mô hình các hàm callback
xếp chồng trông giống như một tác phẩm nghệ thuật. Và việc theo dõi logic hoạt động của code cũng không khác lắm với chương trình đuổi hình bắt chữ.
Sử dụng Promise
Để logic vận hành của trường hợp có nhiều thao tác bất đồng bộ nối tiếp được thể hiện trên bề mặt code gọn gàng, ngay ngắn, và dễ theo dõi hơn. JavaScript có cung cấp một công cụ mới để chúng ta sử dụng trong tình huống này, đó là các object Promise
- Tài liệu về class Promise
của MDN
new Promise(requestData)
.then(updateView)
.then(doNextTask)
.then(doAnotherTask)
.catch(handleError)
.finally(cleanUpResource);
Sau khi thực hiện lời gọi hàm requestData
và nhận được dữ liệu phản hồi, phương thức .then
của Promise
sẽ truyền dữ liệu cho hàm thực hiện thao tác xử lý tiếp theo là updateView
; Kết quả hoạt động của updateView
lại tiếp tục được truyền cho hàm thực hiện công việc kế tiếp doNextTask
; Rồi sau đó kết quả hoạt động của doNextTask
lại được chuyển tiếp cho hàm thực hiện công việc nối tiếp sau đó doAnotherTask
.
Ở bất kỳ giai đoạn nào của chuỗi thao tác bất đồng bộ liên tiếp, nếu có ngoại lệ phát sinh thì tiến trình xử lý sẽ chuyển tới hàm xử lý handleError
ở phương thức .catch
. Sau cùng thì dù có ngoại lệ phát sinh hay không thì hàm dọn dẹp tài nguyên cleanUpResources
ở phương thức .finally
cũng sẽ được thực hiện.
Trong phương cách xử lý này, JavaScript đã định nghĩa một dạng thức chung cho các hàm truyền vào để khởi tạo Promise
và có thể được nối tiếp bởi .then
như sau:
const requestData = function(resolve, reject) {
// ---
resolve('Dữ liệu trả về từ máy chủ.');
}; // requestData
Trong đó resolve
là một hàm thực hiện chuyển tiếp dữ liệu data
tới các khối .then
tiếp theo khi requestData
hoàn thành công việc; Và reject
là một hàm chuyển tiếp ngoại lệ error
tới khối .catch
khi requestData
không hoàn thành được công việc và thông báo ngoại lệ.
Các hàm thực hiện thao tác xử lý nối tiếp trong .then
sau đó sẽ có dạng thức chung là tiếp nhận dữ liệu từ resolve()
của Promise
đứng liền kề phía trên để xử lý công việc. Và tiếp tục tạo ra Promise
mới để resolve()
chuyển cho tác vụ .then
kế tiếp.
const requestData = function(resolve, reject) {
const mockRequest = function() {
var data = 'Dữ liệu trả về từ máy chủ.';
resolve(data);
};
setTimeout(mockRequest, 2.7 * 1000);
}; // requestData
const updateView = function(data) {
return new Promise(function(resolve, reject) {
const mockUpdate = function() {
console.log(data);
var viewData = 'Dữ liệu kết quả hoạt động của View.';
resolve(viewData);
};
setTimeout(mockUpdate, 1.8 * 1000);
}); // Promise
}; // updateView
const doNextTask = function(viewData) {
return new Promise(function(resolve, reject) {
const mockDo = function() {
console.log(viewData);
var nextData = 'Dữ liệu kết quả hoạt động của Next.';
resolve(nextData);
};
setTimeout(mockDo, 1.8 * 1000);
}); // Promise
}; // doNextTask
const doAnotherTask = function(nextData) {
return new Promise(function(resolve, reject) {
const mockDo = function() {
console.log(nextData);
resolve();
};
setTimeout(mockDo, 1.8 * 1000);
}); // Promise
}; // doAnotherTask
const handleError = function(error) {
console.error(error);
}; // handleError
const cleaarnUpResources = function(_) {
console.log('Dọn dẹp tài nguyên.');
}; // cleaarnUpResources
new Promise(requestData)
.then(updateView)
.then(doNextTask)
.then(doAnotherTask)
.catch(handleError)
.finally(cleaarnUpResources);
// kết quả:
// ...trễ 2.7s
// 'Dữ liệu trả về từ máy chủ.'
// ...trễ 1.8s
// 'Dữ liệu kết quả hoạt động của View.'
// ...trễ 1.8s
// 'Dữ liệu kết quả hoạt động của Next.'
// 'Dọn dẹp tài nguyên.'
Ở đây chúng ta cần lưu ý là tất cả những thao tác này đều đang được thực hiện trên tiến trình riêng tạo ra cho requestData
và không làm trì trệ các đoạn code phía sau trong chương trình chính.
Và bởi vì các thao tác nối tiếp updateView
, doNextTask
, và doAnotherTask
, đều là các Promise
và chờ đợi thao tác liền kề phía trước resolve
xong rồi mới bắt đầu được thực thi; Chúng ta vẫn còn một lựa chọn cú pháp khác giúp thể hiển sự nối tiếp của các thao tác này trong code một cách tự nhiên hơn, trông gần giống với các thao tác xử lý đồng bộ thông thường.
Các từ khóa async
và await
Từ khóa await
sẽ giúp chúng ta tạm dừng một tiến trình thực thi code ở vị trí một Promise
cho đến khi nó được resolve
và đồng thời trả về giá trị được resolve
cho tiến trình code hiện tại.
Tuy nhiên chúng ta cần lưu ý, tiến trình thực thi mà await
được phép tạm dừng chỉ có 2 trường hợp - hoặc là sử dụng bên trong một hàm được đánh dấu là async
- hoặc là có thể được sử dụng ở scope lớn nhất bên trong các module
.
const requestData = function() {
const mockRequest = async function() {
try {
var data = 'Dữ liệu trả về từ máy chủ.';
var viewData = await updateView(data);
var nextData = await doNextTask(viewData);
await doAnotherTask(nextData);
}
catch (error) {
handleError(error);
}
finally {
cleaarnUpResources();
}
}; // mockRequest
setTimeout(mockRequest, 2.7 * 1000);
}; // requestData
/*
* const updateView = ...
* const doNextTask = ...
* const doAnotherTask = ...
* const cleanUpResources = ...
*/
requestData();
// kết quả:
// ...trễ 2.7s
// 'Dữ liệu trả về từ máy chủ.'
// ...trễ 1.8s
// 'Dữ liệu kết quả hoạt động của View.'
// ...trễ 1.8s
// 'Dữ liệu kết quả hoạt động của Next.'
// 'Dọn dẹp tài nguyên.'
Bây giờ chúng ta thấy rằng các câu lệnh gọi các hàm xử lý tiếp theo đã có thể được viết giống như các câu lệnh thông thường. Và từ khóa await
cũng có tính mô tả rất tốt - đó là tiến trình chạy code cần phải chờ lời gọi hàm này thực thi xong đã, rồi mới được thực hiện phép gán giá trị sang biến ở bên trái và đi tới câu lệnh tiếp theo bên dưới.
Trên thực tế thì await
không chỉ có hiệu lực với các object Promise
mà còn có thể được sử dụng với bất kỳ object
nào có thể .then
. Trong trường hợp này, chúng ta sẽ thấy await
giống với một phép thực thi operator
hơn là một từ khóa chỉ thị directive
. Phương thức .then
của object chứa nó sẽ được tự động kích hoạt khi chúng ta đặt từ khóa await
đứng trước object
.
void async function() {
var thenable = {
then: function(resolve, _reject) {
resolve('resolved')
}
}; // thenable
console.log(await thenable); // 'resolved'
} ();
Trong trường hợp muốn xử lý ngoại lệ của các Promise
mà không sử dụng cú pháp try .. catch
thì chúng ta vẫn có thể .catch
ngay tại vị trí của Promise
.
// `response` sẽ nhận giá trị `null` nếu có `error`
var response = await promisedFunction()
.catch((error) => console.error(err));
Và cuối cùng là trường hợp sử dụng await
ở scope
lớn nhất của module
.
const weatherData = fetch('data-from-server.json')
.then((response) => response.json());
export default await weatherData;
Trong trường hợp này, bất kỳ module
nào sử dụng weatherData
sẽ phải chờ hàm fetch
được resolve
xong rồi mới có thể tiếp tục tiến trình thực thi code.
Kết thúc bài viết
Như vậy là chúng ta đã hoàn thành xong bài viết về bộ công cụ mới, hỗ trợ chúng ta làm việc thuận tiện hơn với các tác vụ được xử lý không đồng bộ asynchronous
. Tính tới thời điểm hiện tại, chúng ta đã biết tất cả các kiểu dữ liệu và các cú pháp lệnh phổ biến của JavaScript; Và mình đã dự định sẽ kết thúc Sub-Series JavaScript
của Tự Học Lập Trình Web Một Cách Thật Tự Nhiên tại đây.
Tuy nhiên, sau khi quan sát tổng quan lại danh sách các bài viết thì mình phát hiện ra rằng chúng ta còn thiếu 2 thứ rất quan trọng. Đó là các bài viết tập trung nội dung cho các kiểu dữ liệu cơ bản có tần suất sử dụng nhiều như Number
, String
, Date
, ... và các bài viết giới thiệu về các mô hình lập trình phổ biến.
Do đó nên chúng ta sẽ thực hiện thêm một vài bài viết nữa về 2 nhóm nội dung này. Mình rất hy vọng rằng bạn sẽ tiếp tục đồng hành cùng với mình trong những bài viết bổ sung của Sub-Series
này. Hẹn gặp lại bạn trong bài viết tiếp theo.