Các nhà phát triển cung cấp nhiều API Web giúp việc truy cập hay nạp dữ liệu từ các nguồn trên Internet hiệu quả hơn nhờ cơ chế làm việc không đồng bộ.
Lập trình không đồng bộ
Một chương trình máy tính thực thi các nhiệm vụ (tasks) của nó nhờ các tiểu trình (threads). Mỗi tiểu trình (thread) là một tiến trình đơn (single process) chỉ thực hiện được một nhiệm vụ tại một thời điểm:
Task A – -> Task B – -> Task C
Máy tính hiện đại được thiết kế đa lõi (multiple cores) cho phép thực hiện nhiều tiến trình song song:
Thread 1: Task A – -> Task B
Thread 2: Task C – -> Task D
Mặc dù vậy, JavaScript vẫn là ngôn ngữ chỉ có thể thực hiện một tiểu trình (single threaded) như trên các máy tính lõi đơn truyền thống. Tiểu trình duy nhất này gọi là tiểu trình chính (main thread):
Main thread: Task A – -> Task B – -> Task C
Điều này tất nhiên là không hiệu quả trong thế giới đa nhiệm hiện nay và Web Workers ra đời. Web Workers cho phép thực hiện các nhiệm vụ trên một tiểu trình riêng biệt với tiểu trình chính gọi là worker:
Main thread: Task A – -> Task B
Worker thread: Task C
Web worker là một chủ đề phức tạp sẽ được đề cập trong một bài viết khác hoặc có thể tham khả tại https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
Cân nhắc vấn đề sau:
Main thread: Task A – -> Task B – -> |Task D|
Worker thread: Task C – -> |Task D|
Ở đây nhiệm vụ Task D để thự thi sẽ cần kết quả từ Task B và Task C trên hai tiểu trình khác nhau và điều này sẽ phát sinh lỗi nếu hai kết quả (từ Task B và C) không trả về cùng thời điểm. Lúc này chúng ta cần áp dụng cơ chế lập trình không đồng bộ cho phép Task D vẫn thực thi khi một trong hai Task B hay C hoàn thành công việc của nó và task còn lại vẫn đang thực thi.
Kỹ thuật không đồng bộ trong JS
Có 3 kỹ thuật không đồng bộ trong JS:
Sử dụng callbacks
Đây là kỹ thuật lập trình không đồng bộ cổ điển trong JS. Callbacks là tên gọi các hàm (functions) được xác định là các tham số (parameters) cho những hàm khác. Khi chúng ta chuyển một callback như một tham số đến một hàm khác, thực ra chúng ta chỉ chuyển tham chiếu của callback làm tham số, hay nói cách khác, callback sẽ không thực thi ngay lập tức. Nó sẽ được gọi trở lại (called back) tại một nơi nào đó trong thân hàm đang gọi và sẽ thực thi khi thời điểm đến. Một trong những ví dụ kinh điến về kỹ thuật callback là sử dụng các hàm setTimeout và setInterval.
setTimeout (f, time)
Trước khi tìm hiểu chi tiết về hàm setTimeout, chúng ta xem đoạn mã thực hiện tuần tự hay đồng bộ (synchronous) sau:
function display (m){ alert(m); } display("One"); alert("Two");
Kết quả sẽ là đồng bộ One -> Two. Bây giờ, chúng ta thêm hàm setTimeout như sau:
function display (m){ alert(m); } display("One"); let timer = setTimeout(display, 1000, "Two"); alert("Three");
Kết quả sẽ không đồng bộ (asynchronous) như sau: One -> Three -> Two. Như vậy, hàm setTimeout sẽ làm ngắt quá trình đồng bộ của chương trình.
Cú pháp hàm setTimeout: f là hàm callback, time là khoảng thời gian trải qua trước khi thực thi hàm f và được tính bằng mili giây (1000 mili giây = 1 giây). Nếu time là 0 thì hàm f sẽ được thực thi ngay nhưng sau khi mã trong tiểu trình chính đã chạy xong. Hàm setTimeout sẽ trả về một giá trị định danh timeout vừa tạo và sẽ hữu ích trong một số thao tác, ví dụ hủy timeout.
Chú ý: trong danh sách tham số của hàm setTimeout, các tham số thứ 3, 4, v.v. là các tham số của hàm f (nếu có). Xem lại hàm setTimeout từ ví dụ trên với 3 đối số trong đó Two là giá trị của tham số m của hàm display.
Hàm f có thể định nghĩa trực tiếp bên trong hay có thể định nghĩa bên ngoài hàm setTimeout. Ví dụ:
// With a named function let myGreeting = setTimeout(function sayHi_In() { alert('Hello, Mr. Universe!'); }, 2000) // With a function defined separately function sayHi_Out() { alert('Hello Mr. Universe!'); } let myGreeting = setTimeout(sayHi_Out, 2000);
Nếu hàm f được định nghĩa bên ngoài, như sayHi_Out ở ví dụ trên, thì một tham chiếu của hàm này sẽ được chuyển đến setTimeout.
Khi định nghĩa bên trong setTimeout, hàm f có thể không cần tên:
let myGreeting = setTimeout(function() { alert('Hello, Mr. Universe!'); }, 2000);
Chúng ta có thể hủy timeout vừa tạo với hàm clearTimeout(timeout) với timeout là giá trị định danh của timeout được tạo:
clearTimeout(timer); clearTimeout(myGreeting);
setInterval(f, m)
setTimeout chỉ thực thi mã một lần sau một khoảng thời gian. Nếu muốn thực thi mã nhiều lần (lặp) dùng hàm setInterval.
Cú pháp và chức năng của setInterval tương tự setTimeout ngoại trừ rằng nó thực hiện hàm f hơn một lần. Một ứng dụng đơn giản mà thú vị dùng hàm setInterval là hiển thị đồng hồ như đoạn mã minh họa sau:
function displayTime() { let date = new Date(); let time = date.toLocaleTimeString(); document.getElementById('demo').textContent = time; } const createClock = setInterval(displayTime, 1000);
Hàm setInterval thực thi hàm displayTime một lần mỗi giây và lặp lại tạo cảm giác như đồng hồ.
Hàm setInterval sẽ lặp mãi mãi và nếu muốn dừng chúng ta có thể dùng hàm clearInterval():
clearInterval(createClock);
Promise
Một trong những vấn đề nghiêm trọng của kỹ thuật callback là đôi khi gọi quá nhiều hàm gây khó khăn cho việc đọc hiểu. Vấn đề này gọi là “callback hell” (địa ngục callback) như ví dụ:
chooseToppings(function(toppings) { placeOrder(toppings, function(order) { collectOrder(order, function(pizza) { eatPizza(pizza); }, failureCallback); }, failureCallback); }, failureCallback)
Promise là một cách xử lý mã không đồng bộ mà không phải gọi quá nhiều hàm callbacks và được sử dụng phổ biến bởi các Web APIs nhằm xử lý các nhiệm vụ có thể sẽ mất một khoảng thời gian dài, ví dụ tải một video.
Cách làm việc của Promise
Khi một promise được tạo, nó sẽ ở trạng thái chờ xử lý (pending state).
Khi một promise trả về, nó sẽ ở trạng thái được xử lý (resolved state). Trạng thái này có 2 trường hợp:
- Nếu promise được xử lý thành công, ta gọi promise là fulfilled. Nó trả về một giá trị có thể được truy cập bởi khối lệnh .then() trên dây chuyền promise (promise chain) – sẽ được tìm hiểu sau trong bài viết này.
- Nếu promise được xử lý thất bại, ta gọi promise là rejected. Nó trả về một thông điệp nêu lý do promise bị từ chối xử lý (rejected) và có thể được truy cập bởi khối lệnh catch() trên dây chuyền promise.
Các Web API nào dùng promise?
- Battery API https://developer.mozilla.org/en-US/docs/Web/API/Battery_Status_API
- Fetch API https://developer.mozilla.org/vi/docs/Web/API/Fetch_API
- Servive Workers https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
Tạo một promise
Để tạo một promise, chúng ta gọi hàm khởi tạo (constructor) của Promise API bằng lệnh new Promise() như đoạn mã minh họa sau:
let done = true const isItDoneYet = new Promise((resolve, reject) => { if (done) { const workDone = 'Here is the thing I built' resolve(workDone) } else { const why = 'Still working on something else' reject(why) } })
Trong đoạn mã trên, promise kiểm tra biến toàn cục done, nếu nó là true thì chúng ta sẽ nhận một resolved promise, ngược lại sẽ nhận một rejected promise.
Khi tạo promise chúng ta cần chú ý từ khóa new, nếu quên từ khóa này sẽ phát sinh lỗi Uncaught TypeError: undefined is not a promise
Sử dụng promise
Chúng ta đã tạo một promise tên isItDoneYet và bây giờ sẽ sử dụng promise này như sau:
const checkIfItsDone = () => { isItDoneYet .then(ok => { alert(ok) }) .catch(err => { alert(err) }) } checkIfItsDone()
Khi hàm checkIfItsDone thực thi, nó sẽ gọi promise isItDoneYet và chờ xử lý dùng hàm then() và nếu có một lỗi xuất hiện thì sẽ xử lý trong hàm catch().
Chuỗi (hay dây chuyền) promise (promise chain)
Một promise có thể trả về kết quả đến một promise khác tạo ra một chuỗi promise bằng cách dùng hàm then() hay hàm catch(). Ví dụ về chuỗi promise sẽ được trình bày trong ví dụ về sử dụng Fetch API.
Xử lý lỗi
Nếu kết quả xử lý của tại một vị trí trên chuỗi promise là fulfilled thì khối lệnh trong then() gần nhất sẽ thực thi, ngược lại nếu phát sinh lỗi hay kết quả là rejected thì khối lệnh trong catch() gần nhất sẽ được thực thi.
Khối lệnh cacth có thể được nối bởi các khối lệnh catch khác như sau:
new Promise((resolve, reject) => { throw new Error('Error') }) .catch(err => { throw new Error('Error') }) .catch(err => { console.error(err) })
Trong các trình duyệt hiện đại, lệnh finally() có thể được dùng như là mắt xích cuối cùng trong chuỗi các khối lệnh then/catch.
Các hàm promise phổ biến
Promise.all()
Nếu chúng ta cần đồng bộ nhiều promise khác nhau thì Promise.all() có thể giúp định nghĩa một danh sách các promise và thực thi các lệnh nếu tất cả các promise được xử lý. Ví dụ:
const f1 = new Promise() const f2 = new Pomise() Promise.all([f1, f2]) .then(res => { console.log('Array of results', res) }) .catch(err => { console.error(err) }) //Trong ES2015, chúng ta có thể viết Promise.all([f1, f2]).then(([res1, res2]) => { console.log('Results', res1, res2) })
Promise.race()
Sẽ thực thi ngay khi một trong các promise được chuyển đến nó được xử lý và nó chỉ chạy hàm callback chỉ một lần với kết quả của promise đầu tiên được xử lý. Ví dụ:
const promiseOne = new Promise((resolve, reject) => { setTimeout(resolve, 500, 'one') }) const promiseTwo = new Promise((resolve, reject) => { setTimeout(resolve, 100, 'two') }) Promise.race([promiseOne, promiseTwo]).then(result => { console.log(result) // 'two' })
Kết quả là two vì promise thứ hai được xử lý trước (sau 100ms) so với promise thứ nhất (sau 500ms).
Một ví dụ về Fetch API
Một trong những Web API phổ biến dùng promise là Fetch API và API này sẽ được đề cập chi tiết trong một bài viết khác. Phần kế tiếp ngay sau đây chúng ta sẽ xem xét một ví dụ dùng Fetch API được sử dụng nhiều trong các ứng dụng Web tham khảo từ https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Promises
Với Fetch API, một promise sẽ được khởi tạo bằng cách dùng hàm fecth() ( tương đương với new Promise() nếu dùng Promise API). Trong ví dụ này, chúng ta sẽ dùng hàm fetch() để nạp một ảnh từ web và dùng hàm blob() để chuyển nội dung từ fetch đến một đối tượng Blob https://developer.mozilla.org/en-US/docs/Web/API/Blob và sẽ hiển thị đối tượng blob này trong một phần tử img.
Trước khi tạo các tập tin liên quan, chúng ta sẽ tạo một thư mục lưu trữ các tập tin liên quan trong ví dụ này và gọi tên là Promise_Project. Các bước thực hiện:
- Tạo một tập tin index.html có nội dung sau:
<!DOCTYPE html> <html lang="en-US"> <head> <meta charset="utf-8"> <title>My test page</title> </head> <body> <p>This is my page</p> <script></script> </body> </html>
Và tải tập tin ảnh tên coffee.jpg từ https://github.com/mdn/learning-area/blob/master/javascript/asynchronous/promises/coffee.jpg
- Thêm đoạn mã để khởi tạo một promise đến phần tử <script> trong <body>
<script> let promise = fetch('coffee.jpg'); </script>
- Thêm đoạn mã tạo một promise thứ hai sẽ thực thi hàm blob() trong khối then() nếu promisre thứ nhất được xử lý là fulfilled:
<script> let promise = fetch('coffee.jpg'); let promise2 = promise.then(response => response.blob()); </script>
- Nếu promise2 là fullfilled thì sẽ thực hiện tạo một promise thứ 3 thực hiện việc chuyển đối tượng blob đến phần tử <img>:
<script> let promise = fetch('coffee.jpg'); let promise2 = promise.then(response => response.blob()); let promise3 = promise2.then(myBlob => { let objectURL = URL.createObjectURL(myBlob); let image = document.createElement('img'); image.src = objectURL; document.body.appendChild(image); }) </script>
- Cuối cùng, nếu một vài lỗi xảy ra hay trạng thái promise được xử lý là rejected, promise thứ tư sẽ thực thi khối lệnh catch():
<script> let promise = fetch('coffee.jpg'); let promise2 = promise.then(response => response.blob()); let promise3 = promise2.then(myBlob => { let objectURL = URL.createObjectURL(myBlob); let image = document.createElement('img'); image.src = objectURL; document.body.appendChild(image); }) let errorCase = promise3.catch(e => { console.log('There has been a problem with your fetch operation: ' + e.message); }); </script>
Dùng hàm URL.createObjectURL để trả về đường dẫn (URL) đến đối tượng blob và đường dẫn này được gán đến thuộc tính src của một phần tử ảnh được tạo ra từ hàm document.createElement(‘img’) trước khi gán đến cây DOM.
- Trước khi thực thi trang index.html, chúng ta cần di chuyển thư mục Promise_Projec đến thư mục web server cục bộ (localhost). Nếu máy được cài IIS, đường dẫn phổ biến sẽ là C:\inetpub\wwwroot.
- Mở trình duyệt web, ví dụ Chrome, và gõ localhost/Promise_Project, kết quả:
Trong các bước minh họa trên chúng ta đã tạo từng promise nhưng chúng ta có thể viết gọn hơn bằng cách dùng chuỗi promise như sau:
fetch('coffee.jpg') .then(response => response.blob()) .then(myBlob => { let objectURL = URL.createObjectURL(myBlob); let image = document.createElement('img'); image.src = objectURL; document.body.appendChild(image); }) .catch(e => { console.log('There has been a problem with your fetch operation: ' + e.message); });
Như vậy, chúng ta đã khám phá những nét cơ bản của công nghệ promise và cũng đã tìm hiểu cách dùng Fetch API thông qua một ví dụ. Chi tiết về Promise và nhiều ví dụ có thể khám phá thêm tại https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Promises
Async/Await
Kỹ thuật lập trình không đồng bộ trong JS phát tiển không ngừng từ hàm callback đến promise và đến ES2017 đã giới thiệu một kỹ thuật mới hiệu quả và đơn giản hơn là async/await.
Những công nghệ sau ra đời nhằm khắc phục nhược điểm của các công nghệ đi trước; công nghệ promise ra đời khắc phục vấn đề “địa ngục callback” (callback hell) bằng chuỗi promise và công nghệ async/await ra đời khắc phục nhược điểm của promise như vấn đề chuỗi promise hay cú pháp quá phức tạp của nó để có thể được tiếp cận rộng rãi bởi những người dùng có trình độ khác nhau. Tuy nhiên, async/await không phải là công nghệ tách biệt với promise mà là sự phát triển ở mức cao dựa trên promise.
Công nghệ async/await được quyết định bởi hai từ khóa là Async và Await.
Từ khóa async
Một hàm thông thường sẽ trả về một giá trị nào đó hay có thể đơn thuần chỉ thực hiện một nhiệm vụ nào đó. Ví dụ hàm hello sau đây trả về giá trị là chuỗi Hello:
function hello() { return "Hello" };
Sử dụng giá trị trả về của hàm hello():
alert(hello());
Nhưng nếu chúng ta thêm từ khóa async trước khai báo hàm thì điều đó bắt buộc hàm phải trả về một promise:
async function hello() { return "Hello" };
Tương đương cách viết:
let hello = async function() { return "Hello" };
Hay dùng hàm mũi tên:
let hello = async () => { return "Hello" };
Lúc này, nếu chúng ta gọi hàm hello() theo kiểu thông thường:
alert(hello());
Kết quả: [object Promise]
Vì giá trị trả về của hello() khi dùng async là một promise nên chúng ta có thể dùng khối lệnh then() nếu promise là fulfilled:
hello().then((value) => alert(value))
hay đơn giản là:
hello().then(alert)
Sức mạnh thực sự của từ khóa async sẽ phát huy khi dùng kết hợp với từ khóa await.
Từ khóa await
Từ khóa await chỉ được sử dụng bên trong các hàm sử dụng từ khóa async để tạm ngừng thực thi đoạn mã ( dùng await) cho đến khi hàm async trả về kết quả. Trong thời gian này, các đoạn mã khác có thể thực thi.
Chúng ta có thể dùng await khi gọi bất kỳ hàm nào trả về một Promise, bao gồm cả các hàm Web API.
Một ví dụ đơn giản về dùng cặp async/await:
async function hello() { return greeting = await Promise.resolve("Hello"); }; hello().then(alert);
Để thấy được hiệu quả của cặp từ khóa async/await, chúng ta sẽ thay đổi đoạn mã JS dùng Fetch API để nạp một hình ảnh đến đối tượng blob và gán đến cây DOM trong ví dụ phần Promise như sau:
async function myFetch() { let response = await fetch('coffee.jpg'); let myBlob = await response.blob(); let objectURL = URL.createObjectURL(myBlob); let image = document.createElement('img'); image.src = objectURL; document.body.appendChild(image); } myFetch() .catch(e => { console.log('There has been a problem with your fetch operation: ' + e.message); });
Chúng ta không cần dùng bất kỳ khối lệnh then nào mà đơn giản chỉ dùng từ khóa await trước các hàm fetch và blob. Tất nhiên, vì await chỉ làm việc bên trong các hàm có từ khóa async nên chúng ta phải đặt từ khóa async trước khai báo hàm myFetch(). Đoạn mã bây giờ có thể được đọc và hiểu dễ dàng hơn.
Vì một hàm có từ khóa async sẽ trả về một promise nên chúng ta có thể thay đổi đoạn mã trên thành một phiên bản tương đương nhưng mềm dẻo hơn:
async function myFetch() { let response = await fetch('coffee.jpg'); return await response.blob(); } myFetch().then((blob) => { let objectURL = URL.createObjectURL(blob); let image = document.createElement('img'); image.src = objectURL; document.body.appendChild(image); }).catch(e => console.log(e));
Từ khóa await có thể dùng với Promise.all():
let values = await Promise.all([coffee, tea, description]);
Ưu điểm và bất tiện của async/await
Bên cạnh những ưu điểm như làm cho mã không đồng bộ có thể đơn giản và dễ đọc hơn, async/await cũng dễ dàng debug hơn trong các trình duyệt web hiện đại.
Mặc dù hoạt động theo nguyên tắc không đồng bộ nhưng async/await lại có một vài kiểu ứng xử đồng bộ:
- Khi một đoạn mã dùng await, nó sẽ ngừng thực thi cho đến khi kết quả trả về (là promise trạng thái fulfilled). Lúc này, các đoạn mã khác được phép thực thi.
- Một đoạn mã await sẽ chờ cho đoạn mã await trước đó thực thi xong để có thể thực thi.
Việc ứng xử đồng bộ này có thể dẫn đến một số kết quả không mong đợi.
Một bất tiện khác là các đoạn mã dùng await phải được chứa trong một hàm async.
Lời kết
Trong bài viết này chúng ta đã tìm hiểu một trong những khía cạnh quan trọng nhất và cũng tương đối khó hiểu trong JavaScript hiện đại là lập trình không đồng bộ từ kỹ thuật callback đến promise và mới nhất là sử dụng async/await. Hiểu và thực hành cơ chế không đồng bộ giúp chương trình của bạn trở nên dễ đọc hơn, hiệu quả hơn bởi vì hầu hết các Web API hiện đại đều hoạt động theo nguyên tắc không đồng bộ.
Tham khảo và học thêm
https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous
1 Pingback