Bài 7.2: Async/await trong JavaScript

Bài 7.2: Async/await trong JavaScript
Chào mừng bạn trở lại với chuỗi bài blog về Lập trình Web Front-end! Hôm nay, chúng ta sẽ cùng đi sâu vào một trong những tính năng mạnh mẽ và tiện lợi nhất của JavaScript hiện đại để xử lý các tác vụ bất đồng bộ: async
và await
.
Trong thế giới lập trình web, đặc biệt là với Front-end, chúng ta liên tục làm việc với các tác vụ không xảy ra ngay lập tức. Đó có thể là việc fetch dữ liệu từ máy chủ, đọc tệp, thiết lập bộ đếm thời gian (setTimeout
), hoặc xử lý các sự kiện I/O khác. Theo truyền thống, việc này thường dẫn đến "Callback Hell" hoặc chuỗi .then()
phức tạp khi sử dụng Promise.
async
và await
ra đời như một lớp "đường cú pháp" (syntactic sugar) tuyệt vời trên đỉnh của Promise, giúp code bất đồng bộ của chúng ta trở nên dễ đọc và dễ hiểu hơn rất nhiều, trông gần giống như code đồng bộ (synchronous).
Hãy cùng khám phá chúng chi tiết nhé!
Promise - Nền tảng của Async/await
Trước khi nhảy vào async/await
, điều quan trọng là phải hiểu rằng chúng được xây dựng trên Promise. async/await
không thay thế Promise mà làm cho việc làm việc với Promise trở nên thuận tiện hơn.
Nhớ lại, một Promise là một đối tượng đại diện cho kết quả cuối cùng của một thao tác bất đồng bộ. Nó có thể ở một trong ba trạng thái:
pending
(đang chờ)fulfilled
(đã hoàn thành thành công, với một giá trị)rejected
(đã thất bại, với một lý do lỗi)
Chúng ta thường xử lý kết quả của Promise bằng cách sử dụng .then()
cho trường hợp thành công và .catch()
cho trường hợp thất bại.
async
Function: Khai Báo Hàm Bất Đồng Bộ
Từ khóa async
được đặt trước một khai báo hàm (hoặc arrow function, method trong class) để đánh dấu rằng hàm đó sẽ thực hiện các thao tác bất đồng bộ và luôn luôn trả về một Promise.
Khi một hàm được đánh dấu là async
:
- Nó cho phép bạn sử dụng từ khóa
await
bên trong hàm đó. - Giá trị trả về của hàm sẽ được tự động bọc trong một Promise đã được resolve. Nếu hàm throw lỗi, Promise trả về sẽ bị reject.
Ví dụ đơn giản:
async function xinChao() {
return "Xin chào!";
}
// Hàm async trả về một Promise
xinChao().then(ketQua => {
console.log(ketQua); // Output: Xin chào!
});
// Tương đương với
// function xinChaoPromise() {
// return Promise.resolve("Xin chào!");
// }
Trong ví dụ này, hàm xinChao
trả về chuỗi "Xin chào!"
. Tuy nhiên, vì nó là một hàm async
, giá trị trả về này được tự động đưa vào trong một Promise đã resolve. Do đó, chúng ta có thể dùng .then()
để lấy giá trị đó.
Nếu hàm async
throw ra một lỗi:
async function hamBaoLoi() {
throw new Error("Oops! Có lỗi rồi.");
}
hamBaoLoi().catch(loi => {
console.error(loi.message); // Output: Oops! Có lỗi rồi.
});
// Tương đương với
// function hamBaoLoiPromise() {
// return Promise.reject(new Error("Oops! Có lỗi rồi."));
// }
Đây là cách cơ bản mà async
hoạt động: nó biến giá trị trả về hoặc lỗi được throw thành kết quả của Promise mà hàm đó trả về.
await
Keyword: Chờ Đợi Promise Hoàn Thành
Từ khóa await
chỉ có thể được sử dụng bên trong một hàm async
. Chức năng chính của nó là tạm dừng việc thực thi của hàm async
cho đến khi Promise mà nó đang "đợi" (await) hoàn thành (resolve hoặc reject).
- Nếu Promise resolve,
await
sẽ trả về giá trị đã resolve đó. - Nếu Promise reject,
await
sẽ throw ra lỗi mà Promise đã reject.
Ví dụ sử dụng await
:
Hãy tạo một hàm trả về một Promise sau một khoảng thời gian, mô phỏng một tác vụ bất đồng bộ (ví dụ: fetch data).
function fetchData(data, delay) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Đã nhận dữ liệu: ${data}`);
resolve(data);
}, delay);
});
}
async function xuLyDuLieu() {
console.log("Bắt đầu xử lý...");
// Tạm dừng ở đây cho đến khi Promise từ fetchData(1, 1000) resolve
const duLieu1 = await fetchData(1, 1000); // Đợi 1 giây
console.log(`Tiếp tục xử lý với dữ liệu 1: ${duLieu1}`);
// Tạm dừng ở đây cho đến khi Promise từ fetchData(2, 500) resolve
const duLieu2 = await fetchData(2, 500); // Đợi 0.5 giây
console.log(`Xử lý xong với dữ liệu 2: ${duLieu2}`);
return `Kết quả cuối cùng: ${duLieu1}, ${duLieu2}`;
}
// Gọi hàm async và xử lý kết quả cuối cùng
xuLyDuLieu().then(ketQuaCuoi => {
console.log(ketQuaCuoi);
}).catch(loi => {
console.error("Có lỗi trong quá trình xử lý:", loi);
});
console.log("Hàm xuLyDuLieu đã được gọi (nhưng nó đang chờ).");
Giải thích:
- Khi
xuLyDuLieu()
được gọi, dòngconsole.log("Bắt đầu xử lý...");
chạy ngay lập tức. - Đến dòng
const duLieu1 = await fetchData(1, 1000);
, việc thực thi của hàmxuLyDuLieu
bị tạm dừng (không chặn toàn bộ luồng chương trình, chỉ tạm dừng hàm hiện tại) và chờ đợi Promise dofetchData(1, 1000)
trả về hoàn thành. - Sau 1 giây, Promise của
fetchData(1, 1000)
resolve với giá trị1
. Từ khóaawait
lấy giá trị1
đó và gán vào biếnduLieu1
. - Hàm
xuLyDuLieu
tiếp tục thực thi, dòngconsole.log(...)
thứ hai chạy. - Đến dòng
const duLieu2 = await fetchData(2, 500);
, hàm lại tạm dừng và chờ đợi Promise từfetchData(2, 500)
. - Sau 0.5 giây, Promise thứ hai resolve với giá trị
2
.await
lấy giá trị2
và gán vàoduLieu2
. - Hàm tiếp tục thực thi, dòng
console.log(...)
thứ ba chạy. - Cuối cùng, hàm
xuLyDuLieu
trả về chuỗi kết quả, chuỗi này được tự động bọc trong một Promise đã resolve (vìxuLyDuLieu
là hàmasync
). .then()
được gọi trên Promise đó để in ra kết quả cuối cùng.
Trong khi hàm xuLyDuLieu
đang chờ đợi, các code khác bên ngoài hàm vẫn có thể chạy (như dòng console.log("Hàm xuLyDuLieu đã được gọi (nhưng nó đang chờ).");
), điều này thể hiện tính chất bất đồng bộ không chặn (non-blocking) của JavaScript.
So sánh với việc dùng .then()
lồng nhau, async/await
rõ ràng dễ đọc và theo dõi luồng logic hơn rất nhiều.
// So sánh với dùng .then()
function xuLyDuLieuPromise() {
console.log("Bắt đầu xử lý (Promise)...");
return fetchData(1, 1000)
.then(duLieu1 => {
console.log(`Tiếp tục xử lý với dữ liệu 1 (Promise): ${duLieu1}`);
return fetchData(2, 500)
.then(duLieu2 => {
console.log(`Xử lý xong với dữ liệu 2 (Promise): ${duLieu2}`);
return `Kết quả cuối cùng (Promise): ${duLieu1}, ${duLieu2}`;
});
});
}
// Gọi hàm Promise
xuLyDuLieuPromise().then(ketQuaCuoi => {
console.log(ketQuaCuoi);
}).catch(loi => {
console.error("Có lỗi trong quá trình xử lý (Promise):", loi);
});
Bạn có thể thấy rõ sự khác biệt về độ phức tạp khi xử lý các bước tuần tự.
Xử Lý Lỗi với try...catch
Một trong những điểm cộng lớn của async/await
là cách xử lý lỗi trực quan hơn. Vì một Promise bị reject khi được await
sẽ throw ra lỗi, chúng ta có thể sử dụng cấu trúc try...catch
quen thuộc để bắt lỗi:
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
const delay = Math.random() * 1000;
setTimeout(() => {
if (userId === 123) {
console.log(`Tìm thấy người dùng ${userId}`);
resolve({ id: userId, name: "Alice" });
} else if (userId === 404) {
console.log(`Không tìm thấy người dùng ${userId} (mô phỏng lỗi)`);
reject(new Error(`Người dùng với ID ${userId} không tồn tại`));
} else {
console.log(`Tìm thấy người dùng ${userId}`);
resolve({ id: userId, name: `User ${userId}` });
}
}, delay);
});
}
async function layVaHienThiNguoiDung(id) {
try {
console.log(`Đang cố gắng lấy người dùng ID: ${id}`);
// Nếu fetchUserData bị reject, nó sẽ throw lỗi và khối catch sẽ được thực thi
const user = await fetchUserData(id);
console.log(`Thông tin người dùng: ${user.name}`);
} catch (error) {
// Bắt lỗi nếu Promise bị reject
console.error(`Đã xảy ra lỗi khi lấy người dùng ID ${id}: ${error.message}`);
}
}
// Gọi hàm với ID tồn tại
layVaHienThiNguoiDung(101);
// Gọi hàm với ID mô phỏng lỗi
layVaHienThiNguoiDung(404);
Giải thích:
- Hàm
fetchUserData
mô phỏng việc tìm kiếm người dùng và trả về Promise. Nó sẽ reject nếu ID là404
. - Trong hàm
layVaHienThiNguoiDung
(là hàmasync
), chúng ta đặt việc gọiawait fetchUserData(id)
vào trong khốitry
. - Nếu
fetchUserData(id)
resolve, giá trịuser
sẽ được gán và dòngconsole.log("Thông tin người dùng: ...")
sẽ chạy. - Nếu
fetchUserData(id)
reject (khiid
là 404),await
sẽ throw lỗi mà Promise đã reject. Lỗi này sẽ bị bắt bởi khốicatch
. - Bên trong khối
catch
, chúng ta có thể xử lý lỗi một cách gọn gàng, ví dụ như in thông báo lỗi ra console.
Cách xử lý lỗi này tương tự như cách chúng ta xử lý lỗi trong code đồng bộ, làm cho việc quản lý lỗi trong bất đồng bộ trở nên trực quan hơn rất nhiều so với việc dùng .catch()
ở cuối chuỗi Promise.
Xử Lý Nhiều Tác Vụ Bất Đồng Bộ
Khi làm việc với nhiều tác vụ bất đồng bộ, chúng ta có hai kịch bản chính:
Tuần tự (Sequential): Tác vụ này chỉ bắt đầu sau khi tác vụ kia hoàn thành.
async/await
với nhiều dòngawait
là lựa chọn tuyệt vời cho trường hợp này (như ví dụxuLyDuLieu
ở trên).async function xuLyTuongTac() { const ketQuaBuoc1 = await buoc1Async(); // Chờ xong bước 1 const ketQuaBuoc2 = await buoc2Async(ketQuaBuoc1); // Dùng kết quả bước 1, chờ xong bước 2 const ketQuaCuoi = await buoc3Async(ketQuaBuoc2); // Dùng kết quả bước 2, chờ xong bước 3 return ketQuaCuoi; }
Cách này rất rõ ràng và dễ theo dõi khi các bước phụ thuộc lẫn nhau.
Song song (Parallel): Các tác vụ không phụ thuộc vào nhau và có thể chạy cùng lúc.
async/await
kết hợp vớiPromise.all
là cách hiệu quả để làm điều này.Nếu bạn chỉ đơn thuần
await
từng Promise một trong một vòng lặp hoặc nhiều dòngawait
độc lập, chúng vẫn sẽ chạy tuần tự.```javascript // Ví dụ chạy song song KHÔNG hiệu quả (chạy tuần tự) async function fetchTatCaDuLieuTuanTu(ids) { const results = []; for (const id of ids) {
// await bên trong vòng lặp => mỗi lần lặp chờ xong mới đến lần tiếp theo const data = await fetchData(id, Math.random() * 1000 + 500); // Giả lập thời gian fetch ngẫu nhiên results.push(data); console.log(`Đã fetch xong ID ${id}`); // Dòng này chỉ chạy sau khi await hoàn thành
} console.log("Fetch tuần tự hoàn thành."); return results; }
// Ví dụ chạy song song HIỆU QUẢ (dùng Promise.all) async function fetchTatCaDuLieuSongSong(ids) { console.log("Bắt đầu fetch song song..."); const promises = ids.map(id => fetchData(id, Math.random() * 1000 + 500)); // Tạo mảng các Promise
// Chờ TẤT CẢ các Promise trong mảng hoàn thành cùng lúc const results = await Promise.all(promises); // await 1 Promise duy nhất (Promise.all)
console.log("Fetch song song hoàn thành."); return results; }
const danhSachIDs = [10, 20, 30, 40];
// Chạy thử nghiệm console.log("--- Chạy tuần tự ---"); const startTimeSequential = Date.now(); fetchTatCaDuLieuTuanTu(danhSachIDs).then(() => { const endTimeSequential = Date.now(); console.log(
Tổng thời gian tuần tự: ${endTimeSequential - startTimeSequential}ms
); });
console.log("--- Chạy song song ---");
const startTimeParallel = Date.now();
fetchTatCaDuLieuSongSong(danhSachIDs).then(() => {
const endTimeParallel = Date.now();
console.log(`Tổng thời gian song song: ${endTimeParallel - startTimeParallel}ms`);
});
```
Giải thích:
- Trong ví dụ
fetchTatCaDuLieuTuanTu
, vòng lặpfor...of
vớiawait
bên trong sẽ khiến mỗi lần fetch ID phải chờ xong trước khi fetch ID tiếp theo. Tổng thời gian sẽ là tổng thời gian của từng lần fetch. - Trong ví dụ
fetchTatCaDuLieuSongSong
, chúng ta sử dụngmap
để tạo ra một mảng tất cả các Promise cần thực thi. Các Promise này được khởi tạo gần như cùng lúc. await Promise.all(promises)
sau đó sẽ chờ cho tất cả các Promise trong mảngpromises
hoàn thành. Tổng thời gian sẽ chỉ bằng thời gian của Promise lâu nhất trong mảng.
Promise.all
rất hữu ích khi bạn cần fetch nhiều nguồn dữ liệu độc lập cùng một lúc. Promise.allSettled
là một biến thể tương tự nhưng chờ cho tất cả Promise dù thành công hay thất bại, trả về một mảng kết quả mô tả trạng thái và giá trị/lý do của từng Promise. Điều này hữu ích khi bạn không muốn toàn bộ quá trình dừng lại chỉ vì một Promise bị reject.
await
ở Top-Level (Top-Level Await)
Trước đây, await
chỉ có thể được sử dụng bên trong hàm async
. Điều này đôi khi hơi bất tiện khi bạn muốn sử dụng await
ngay ở phạm vi global (ví dụ: khi làm việc trong module ES6 hoặc môi trường Node.js mới hơn) mà không cần bọc nó trong một hàm async IIFE
(Immediately Invoked Function Expression).
Tuy nhiên, với các phiên bản JavaScript mới hơn và môi trường hỗ trợ (như module ES6 trong trình duyệt hoặc Node.js từ phiên bản 14 trở lên), bạn có thể sử dụng await
trực tiếp ở cấp độ cao nhất của module hoặc script.
// Ví dụ Top-level await (Chỉ hoạt động trong module ES6 hoặc môi trường hỗ trợ)
// Lưu file này dưới dạng .mjs nếu chạy trong Node.js cũ hơn 14, hoặc import nó như một module trong HTML
// <script type="module" src="your_script.js"></script>
import fetch from 'node-fetch'; // Ví dụ import trong Node.js
console.log("Bắt đầu fetch dữ liệu...");
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
const todo = await response.json();
console.log("Dữ liệu fetch được:", todo);
} catch (error) {
console.error("Lỗi khi fetch:", error);
}
console.log("Kết thúc script."); // Dòng này sẽ chạy sau khi await ở trên hoàn thành
Với Top-level await, code bất đồng bộ ở cấp độ module trở nên gọn gàng hơn đáng kể.
Comments