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 một 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.
request.js
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.4 * 1000); // --- trả về kết quả ở vị trí hàm được gọi return [ error, data ];
}; const updateView = function(error, data) { if (error instanceof Error) console.error(error); else console.log(data);
}; var [ data, error ] = requestData();
updateView(data, error); // '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(result)
. 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 result
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 updateView
.
request.js
const requestData = function(callback) { var data; const mockRequest = function() { // --- nhận được dữ liệu 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.4 * 1000);
}; const updateView = function(error, data) { if (error instanceof Error) console.error(error); else console.log(data);
}; requestData(updateView);
// ...trễ 2.4 giây
// '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 hiện 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
promise.js
new Promise(requestData) .then((data) => updateView(data)) .then((viewData) => doNext(viewData)) .then((nextData) => doElse(nextData)) .catch((error) => handleError(error)) .finally((_) => cleanUp);
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 doNext
; Rồi sau đó kết quả hoạt động của doNext
lại được chuyển tiếp cho hàm thực hiện công việc nối tiếp sau đó doElse
.
Ở 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 cleanUp
ở 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:
promise.js
const requestData = function(resolve, reject) { // --- resolve('Dữ liệu trả về từ máy chủ.');
};
Trong đó resolve
là một hàm gọi lại để thực hiện xử lý khi requestData
hoàn thành công việc; Và reject
là một hàm gọi lại để thực hiện xử lý khi requestData
không hoàn thành được công việc và muốn thông báo lỗi.
promise.js
const requestData = function (resolve, reject) { var data; const mockRequest = function () { data = 'Dữ liệu trả về từ máy chủ.' resolve(data); }; var delay = 2.4 * 1000; setTimeout(mockRequest, delay);
};
Các hàm phía 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ụ kế .then
kế tiếp.
promise.js
const requestData = function(resolve, reject) { var data; const mockRequest = function() { data = 'Dữ liệu trả về từ máy chủ.' resolve(data); }; var delay = 2.4 * 1000; setTimeout(mockRequest, delay);
}; const updateView = function(data) { console.log(data); var viewData = 'Dữ liệu kết quả hoạt động của View.'; const update = function(resolve, reject) { resolve(viewData); }; return new Promise(update);
}; const doNext = function(viewData) { console.log(viewData); var nextData = 'Dữ liệu kết quả hoạt động của Next.'; const justDo = function (resolve, reject) { resolve(nextData); }; return new Promise(justDo);
}; const doElse = function(viewData) { console.log(viewData); const justDo = function (resolve, reject) { resolve(); }; return new Promise(justDo);
}; const cleanUp = function() { console.log('Dọn dẹp tài nguyên.');
}; new Promise(requestData) .then((data) => updateView(data)) .then((viewData) => doNext(viewData)) .then((nextData) => doElse(nextData)) .catch((error) => console.error(error)) .finally((_) => cleanUp); // kết quả:
// 'Dữ liệu trả về từ máy chủ.'
// 'Dữ liệu kết quả hoạt động của View.'
// '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 phía sau updateView
, doNext
, và doElse
, đều chờ đợi cho thao tác liền kề phía trước thực hiện xong rồi mới bắt đầu được thực thi. Chúng ta vẫn còn một cú pháp khác giúp thể hiển sự tiếp nối 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 trên tiến trình chính của chương trình.
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 cho đến khi một Promise
được resolve
và đồng thời trả về giá trị được resolve
.
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 phải được chỉ định rõ - hoặc là sử dụng trong phần thân hàm của một hàm được đánh dấu là async
- hoặc là được sử dụng trong scope
lớn nhất trên tiến trình chính của các module
.
promise.js
const requestData = function() { var data; const mockRequest = async function() { data = 'Dữ liệu trả về từ máy chủ.' var viewData = await updateView(data); var nextData = await doNext(viewData); await doElse(nextData); cleanUp(); }; var delay = 2.4 * 1000; setTimeout(mockRequest, delay);
}; /* const updateView = ... * const doNext = ... * const doElse = ... * const cleanUp = ... */ requestData(); // kết quả:
// 'Dữ liệu trả về từ máy chủ.'
// 'Dữ liệu kết quả hoạt động của View.'
// '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 thẳng hàng, trông rất giống với các lệnh thực thi đồng bộ với tiến trình bình 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 phụ 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.
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ộ async
. 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 đặc biệt của JavaScript. Mình đã dự định kết thúc Sub-Series JavaScript của Tự Học Lập Trình Web Một Cách Tự Nhiên tại đây.
Tuy nhiên thì 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 chuyên đề về 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 JavaScript. Hẹn gặp lại bạn trong bài viết tiếp theo.
(Sắp đăng tải) [JavaScript] Bài 19 - Number & Math