Nếu bạn chỉ mới bắt đầu lập trình, rất có thể bạn đang nghĩ về các chương trình như một tập hợp các khối logic tuần tự, trong đó mỗi khối thực hiện một việc cụ thể và chuyển kết quả của nó để khối tiếp theo có thể chạy, v.v. Hầu hết bạn đều đúng, hầu hết các chương trình chạy theo cách tuần tự, mô hình này cho phép chúng tôi xây dựng các chương trình dễ viết và bảo trì. Tuy nhiên, có những trường hợp sử dụng cụ thể trong đó mô hình tuần tự này không hoạt động hoặc không tối ưu. Ví dụ, hãy xem xét một ứng dụng đọc sách. Ứng dụng này có một số tính năng nâng cao như tìm tất cả các lần xuất hiện của một từ, điều hướng giữa các dấu trang và các tính năng tương tự. Bây giờ hãy tưởng tượng người dùng hiện đang đọc một cuốn sách dài và quyết định tìm kiếm tất cả các lần xuất hiện của một từ phổ biến, chẳng hạn như “The”. Thông thường, ứng dụng sẽ mất vài giây để tìm và lập chỉ mục tất cả các lần xuất hiện của từ đó. Trong chương trình tuần tự, người dùng không thể tương tác với ứng dụng (thay đổi trang hoặc đánh dấu văn bản) cho đến khi hoàn thành thao tác tìm kiếm. Hy vọng rằng bạn có thể thấy rằng đó không phải là trải nghiệm người dùng tối ưu!
Sơ đồ minh họa luồng thực thi điển hình của ứng dụng đọc sách. Nếu người dùng bắt đầu một thao tác kéo dài (trong trường hợp này là tìm kiếm tất cả các lần xuất hiện của “the” trong một cuốn sách lớn), ứng dụng sẽ “đóng băng” trong suốt thời gian của thao tác đó. Trong trường hợp này, người dùng sẽ tiếp tục nhấp vào nút đánh dấu tiếp theo mà không có kết quả cho đến khi thao tác tìm kiếm kết thúc và tất cả các thao tác sẽ có hiệu lực ngay lập tức khiến người dùng cuối có cảm giác như một ứng dụng bị chậm.
Bạn có thể nhận thấy rằng ví dụ này không thực sự tương ứng với mô hình tuần tự mà chúng tôi đã giới thiệu trước đó. Điều này là do các hoạt động ở đây độc lập với nhau. Người dùng không cần biết về số lần xuất hiện của “the” để điều hướng đến dấu trang tiếp theo, do đó thứ tự thực hiện các thao tác không thực sự quan trọng. Chúng ta không phải đợi kết thúc thao tác tìm kiếm trước khi có thể điều hướng đến dấu trang tiếp theo. Một cải tiến có thể có đối với luồng thực thi trước đó dựa trên logic này: chúng tôi có thể chạy thao tác tìm kiếm dài ở chế độ nền, tiếp tục với mọi thao tác sắp tới và sau khi hoàn tất thao tác dài, chúng tôi có thể chỉ cần thông báo cho người dùng. Luồng thực hiện trở nên như sau:
Với luồng thực thi này, trải nghiệm người dùng được cải thiện đáng kể. Giờ đây, người dùng có thể bắt đầu một thao tác kéo dài, tiếp tục sử dụng ứng dụng một cách bình thường và nhận được thông báo sau khi thao tác hoàn tất. Đây là cơ sở của lập trình không đồng bộ.
Javascript, cùng với các ngôn ngữ khác, hỗ trợ kiểu lập trình không đồng bộ này bằng cách cung cấp các API mở rộng để đạt được hầu hết mọi hành vi không đồng bộ mà bạn có thể nghĩ tới. Suy cho cùng, Javascript vốn là một ngôn ngữ không đồng bộ. Nếu chúng ta tham khảo ví dụ trước, logic không đồng bộ là nền tảng của tất cả các ứng dụng tương tác với người dùng và Javascript chủ yếu được xây dựng để sử dụng trên trình duyệt nơi hầu hết các chương trình đều phản hồi hành động của người dùng.
Phần sau đây sẽ cung cấp cho bạn hướng dẫn ngắn gọn về Javascript không đồng bộ:
Cuộc gọi lại
Trong một chương trình điển hình, bạn thường sẽ tìm thấy một số hàm. Để sử dụng một hàm, chúng ta gọi nó bằng một tập hợp các tham số. Mã hàm sẽ thực thi và trả về một kết quả, không có gì khác thường. Lập trình không đồng bộ làm thay đổi logic này một chút. Quay lại ví dụ về ứng dụng đọc sách, chúng ta không thể sử dụng hàm thông thường để triển khai logic thao tác tìm kiếm vì thao tác này mất một khoảng thời gian không xác định. Về cơ bản, một hàm thông thường sẽ trả về trước khi thao tác được thực hiện và đây không phải là hành vi mà chúng tôi mong đợi. Giải pháp là chỉ định một chức năng khác sẽ được thực thi sau khi hoàn tất thao tác tìm kiếm. Điều này mô hình hóa trường hợp sử dụng của chúng tôi vì chương trình của chúng tôi có thể tiếp tục luồng bình thường và sau khi thao tác tìm kiếm kết thúc, chức năng được chỉ định sẽ thực thi để thông báo cho người dùng về kết quả tìm kiếm. Hàm này chúng ta gọi là hàm gọi lại:
// Search occurrences function
function searchOccurrences(word, callback) {
try {
// search operation logic, result is in result variable
//....
callback(null, word, result);
} catch (err) {
callback(err);
}
}
// Search occurrences callback function
function handleSearchOccurrencesResult(err, word, result) {
if (err) {
console.log(`Search operation for ${word} ended with an error`);
} else console.log(`Search results for ${word}: ${result}`);
return;
}
searchOccurrences("the", handleSearchOccurrencesResult);
Đầu tiên, chúng ta xác định hàm hoạt động tìm kiếm, searchOccurrens. Nó cần từ để tìm kiếm và tham số thứ hai “gọi lại”, đây sẽ là chức năng được thực thi sau khi thao tác tìm kiếm hoàn tất. Hàm hoạt động tìm kiếm được cố tình giữ ở dạng trừu tượng, chúng ta chỉ cần tập trung vào hai kết quả có thể xảy ra của nó: trường hợp đầu tiên là mọi thứ đều thành công và chúng ta có kết quả tìm kiếm trong biến kết quả. Trong trường hợp này, chúng ta chỉ cần gọi hàm gọi lại với các tham số sau: tham số đầu tiên là null nghĩa là không có lỗi xảy ra, tham số thứ hai là từ được tìm kiếm và tham số thứ ba và có lẽ là quan trọng nhất trong ba tham số., là kết quả của thao tác tìm kiếm.
Trường hợp thứ 2 là xảy ra lỗi, đây cũng là trường hợp việc thực hiện thao tác tìm kiếm được thực hiện và chúng ta phải gọi hàm callback. Chúng tôi sử dụng khối thử và bắt để chặn bất kỳ lỗi nào và chúng tôi chỉ gọi hàm gọi lại với đối tượng lỗi từ khối bắt.
Sau đó, chúng tôi đã xác định hàm gọi lại, handSearchOccurrences, chúng tôi giữ logic của nó khá đơn giản. Nó chỉ là vấn đề in một tin nhắn tới bàn điều khiển. Trước tiên chúng ta kiểm tra tham số “err” để xem có lỗi nào xảy ra trong hàm chính không. Trong trường hợp đó, chúng tôi chỉ cho người dùng biết rằng thao tác tìm kiếm đã kết thúc có lỗi. Nếu không có lỗi nào xảy ra, chúng tôi sẽ in một thông báo có kết quả của thao tác tìm kiếm.
Cuối cùng, chúng ta gọi hàm searchOccurrences bằng từ “the”. Bây giờ hàm sẽ chạy bình thường mà không chặn chương trình chính và sau khi tìm kiếm xong, lệnh gọi lại sẽ được thực thi và chúng ta sẽ nhận được thông báo kết quả kèm theo kết quả tìm kiếm hoặc thông báo lỗi.
Điều quan trọng cần đề cập ở đây là chúng ta chỉ có quyền truy cập vào biến kết quả bên trong hàm chính và hàm gọi lại. Nếu chúng ta thử một cái gì đó như thế này:
let result;
function searchOccurrences(word, callback) {
try {
// search operation logic, result is in searchResult variable
//....
result = searchResult;
callback(null, word, result);
} catch (err) {
callback(err);
}
}
searchOccurrences("the", handleSearchOccurrencesResult);
console.log(result);
kết quả của bản in sẽ không được xác định vì các chương trình không đợi hàm searchOccurrences thực thi. Nó chuyển sang lệnh tiếp theo là câu lệnh in trước khi biến kết quả được gán bên trong hàm chính. Kết quả chúng ta sẽ in ra biến kết quả chưa được gán.
Vì vậy, dựa trên logic này, chúng ta nên giữ tất cả mã sử dụng biến kết quả bên trong hàm gọi lại. Điều này bây giờ có vẻ không phải là vấn đề nhưng nó có thể nhanh chóng trở thành một vấn đề thực sự. Hãy tưởng tượng trường hợp chúng ta có một chuỗi các hàm không đồng bộ cần chạy theo trình tự. Trong logic gọi lại điển hình, bạn sẽ triển khai một cái gì đó như thế này:
functionA(function (err, resA) {
///......
functionB(resA, function (err, resB) {
///......
functionC(resB, function (err, resC) {
///......
functionD(resC, function (err, resD) {
///......
});
});
});
});
Hãy nhớ rằng mỗi lệnh gọi lại có một tham số lỗi và mỗi lỗi phải được xử lý riêng. Điều này làm cho đoạn mã vốn đã phức tạp ở trên lại càng phức tạp và khó bảo trì hơn. Hy vọng rằng Promise có ở đây để giải quyết vấn đề liên quan đến việc gọi lại, chúng tôi sẽ đề cập đến vấn đề đó tiếp theo.
Lời hứa
Lời hứa được xây dựng dựa trên các cuộc gọi lại và hoạt động theo cách tương tự. Chúng được giới thiệu như một phần của tính năng ES6 nhằm giải quyết một số vấn đề rõ ràng với các cuộc gọi lại, chẳng hạn như địa ngục gọi lại. Lời hứa cung cấp các chức năng riêng chạy khi hoàn thành thành công (giải quyết) và khi xảy ra lỗi (từ chối). Phần sau đây giới thiệu ví dụ về searchOccurrences được triển khai bằng các lời hứa:
// Search occurrences function
function searchOccurrences(word) {
return new Promise((resolve, reject) => {
try {
// search operation logic, result is in result variable
//....
resolve(word, result);
} catch (err) {
reject(err);
}
});
}
searchOccurrences("the")
.then((word, result) => {
console.log(`Search results for ${word}: ${result}`);
})
.catch((err) => {
console.log(`Search operation ended with an error`);
});
Hãy xem qua những thay đổi chúng tôi đã áp dụng:
Hàm searchOccurrens trả về một lời hứa. Bên trong lời hứa, chúng tôi giữ nguyên logic: chúng tôi có hai hàm giải quyết và từ chối đại diện cho lệnh gọi lại thay vì có một hàm gọi lại duy nhất xử lý cả việc thực thi thành công và việc thực thi có lỗi. Promise tách biệt hai kết quả và cung cấp cú pháp rõ ràng khi gọi hàm chính. Hàm giải quyết được “nối” vào hàm chính bằng cách sử dụng từ khóa “then”. Ở đây chúng ta chỉ cần xác định hai tham số của hàm phân giải và in kết quả tìm kiếm. Điều tương tự cũng áp dụng cho hàm từ chối, nó có thể được nối bằng từ khóa “catch”. Hy vọng rằng bạn có thể đánh giá cao những lợi ích mà lời hứa mang lại về khả năng đọc mã và độ sạch của mã. Nếu bạn vẫn đang tranh luận về vấn đề này, hãy xem cách chúng tôi có thể giải quyết vấn đề liên quan đến lệnh gọi lại bằng cách xâu chuỗi các hàm không đồng bộ lại với nhau để chạy lần lượt từng hàm:
searchOccurrences("the")
.then(searchOccurrences("asynchronous"))
.then(searchOccurrences("javascript"))
.then(searchOccurrences("guide"))
.catch((err) => {
console.log(`Search operation ended with an error`);
});
Không đồng bộ/Đang chờ
Async/Await là phần bổ sung mới nhất cho dải công cụ không đồng bộ của chúng tôi trong Javascript. Được giới thiệu với ES8, chúng cung cấp một lớp trừu tượng mới bên trên các hàm không đồng bộ bằng cách chỉ cần “chờ” thực thi một hoạt động không đồng bộ. Luồng chương trình chặn tại lệnh đó cho đến khi kết quả được trả về từ hoạt động không đồng bộ và sau đó chương trình sẽ tiếp tục với lệnh tiếp theo. Nếu bạn đang nghĩ về luồng thực thi đồng bộ thì bạn đã đúng. Chúng tôi đã đi hết vòng tròn! Async/await cố gắng mang lại sự đơn giản của lập trình đồng bộ cho thế giới không đồng bộ. Xin lưu ý rằng điều này chỉ được cảm nhận khi thực thi và mã của chương trình. Mọi thứ vẫn như cũ, Async/await vẫn đang sử dụng lời hứa và lệnh gọi lại là nền tảng của chúng.
Hãy xem lại ví dụ của chúng tôi và triển khai nó bằng Async/await:
async function searchOccurrences(word) {
try {
// search operation logic, result is in result variable
//....
return result;
} catch (err) {
console.log(`Search operation for ${word} ended with an error`);
}
}
const word = "the";
const result = await searchOccurrences(word, handleSearchOccurrencesResult);
console.log(`Search results for ${word}: ${result}`);
Mã của chúng tôi không thay đổi nhiều, điều quan trọng cần chú ý ở đây là từ khóa “async” trước khi khai báo hàm searchOccurrences. Điều này chỉ ra rằng chức năng này không đồng bộ. Ngoài ra, hãy chú ý từ khóa “await” khi gọi hàm searchOccurrences. Điều này sẽ hướng dẫn chương trình chờ thực thi hàm cho đến khi kết quả được trả về trước khi chương trình có thể chuyển sang lệnh tiếp theo, nói cách khác, biến kết quả sẽ luôn giữ giá trị trả về của hàm searchOccurrences chứ không phải lời hứa của theo nghĩa đó, chức năng Async/Await không có trạng thái chờ xử lý như Lời hứa. Sau khi thực thi xong, chúng ta chuyển sang câu lệnh in và lần này kết quả thực sự chứa kết quả của thao tác tìm kiếm. Đúng như mong đợi, mã mới có hành vi tương tự như thể nó đồng bộ.
Một điều nhỏ khác cần lưu ý là vì chúng ta không còn có hàm gọi lại nữa nên chúng ta cần xử lý lỗi searchOccurrences bên trong cùng một hàm vì chúng ta không thể truyền lỗi đến hàm gọi lại và xử lý lỗi đó ở đó. Ở đây chúng tôi chỉ in một thông báo lỗi trong trường hợp có lỗi để làm ví dụ.
Tóm tắt
Trong bài viết này, chúng ta đã tìm hiểu các cách tiếp cận khác nhau được sử dụng để triển khai logic không đồng bộ trong Javascript. Chúng tôi bắt đầu bằng cách khám phá một ví dụ cụ thể về lý do tại sao chúng tôi cần chuyển từ kiểu lập trình đồng bộ thông thường sang mô hình không đồng bộ. Sau đó, chúng tôi chuyển sang lệnh gọi lại, đây là các khối xây dựng chính của Javascript không đồng bộ. Những hạn chế của lệnh gọi lại đã dẫn chúng tôi đến các lựa chọn thay thế khác nhau đã được thêm vào trong nhiều năm để khắc phục những hạn chế này, chủ yếu là các lời hứa và Async/await. Logic không đồng bộ có thể được tìm thấy ở bất kỳ đâu trên web, cho dù bạn đang gọi API bên ngoài, bắt đầu truy vấn cơ sở dữ liệu, ghi vào hệ thống tệp cục bộ hay thậm chí đang chờ người dùng nhập vào biểu mẫu đăng nhập. Hy vọng rằng bây giờ bạn cảm thấy tự tin hơn khi giải quyết những vấn đề này bằng cách viết Javascript không đồng bộ rõ ràng và có thể bảo trì!
Nếu bạn thích bài viết này, vui lòng xem Blog CLA nơi chúng tôi thảo luận về nhiều chủ đề khác nhau về cách tiếp cận công nghệ. Ngoài ra, hãy xem kênh youtube để biết các hội thảo miễn phí trước đây của chúng tôi và theo dõi chúng tôi trên mạng xã hội để không bỏ lỡ những khóa học sắp tới!
Đảm bảo sự nghiệp của bạn trong tương lai bằng cách nâng cao kỹ năng về HTML, CSS và JavaScript với Code Labs Academy(/courses/web-development).