Bài 7.4: Error handling trong asynchronous code trong JS

Bài 7.4: Error handling trong asynchronous code trong JS
Chào mừng bạn đến với bài viết sâu hơn về cách xử lý lỗi khi làm việc với mã bất đồng bộ (asynchronous) trong JavaScript. Nếu bạn đã từng "vò đầu bứt tai" khi gặp lỗi trong một callback, một Promise bị rejected mà không biết tại sao, hay một async
function ném ra exception, thì đây chính là bài viết dành cho bạn!
Lỗi trong mã đồng bộ (synchronous) thường dễ xử lý hơn nhiều. Khi một lỗi xảy ra, việc thực thi dừng lại ngay lập tức, và bạn có thể sử dụng khối try...catch
để "bắt" nó.
try {
// Mã đồng bộ có thể ném lỗi
let result = syncFunctionThatMightThrow();
console.log(result);
} catch (error) {
// Bắt lỗi và xử lý
console.error("Đã xảy ra lỗi đồng bộ:", error);
}
Nhưng mã bất đồng bộ thì khác. Các thao tác như gọi API, đọc file, hay đơn giản là sử dụng setTimeout
đều diễn ra sau khi hàm gọi chúng đã kết thúc. Khi lỗi xảy ra trong các tác vụ này, call stack ban đầu đã không còn nữa! Điều này đòi hỏi những kỹ thuật xử lý lỗi chuyên biệt.
Hãy cùng điểm qua các phương pháp xử lý lỗi trong từng "đời" của JavaScript bất đồng bộ.
1. Xử lý lỗi với Callbacks ("Đời đầu")
Trong thời kỳ đầu của JavaScript, callbacks là cách chính để xử lý kết quả của các thao tác bất đồng bộ. Một quy ước phổ biến là sử dụng "error-first callback", nghĩa là đối số đầu tiên của callback luôn là một đối tượng lỗi (nếu có), còn các đối số sau là dữ liệu thành công.
// Một hàm bất đồng bộ giả định sử dụng error-first callback
function fetchDataWithCallback(url, callback) {
setTimeout(() => { // Giả lập gọi API
const success = Math.random() > 0.5; // Thành công hoặc thất bại ngẫu nhiên
if (success) {
const data = { id: 1, name: "Sample Data" };
callback(null, data); // Đối số đầu tiên là null (không có lỗi)
} else {
const error = new Error(`Không thể lấy dữ liệu từ ${url}`);
callback(error, null); // Đối số đầu tiên là lỗi, dữ liệu là null
}
}, 1000);
}
Để xử lý lỗi, bạn phải kiểm tra đối số đầu tiên trong callback:
fetchDataWithCallback("https://api.example.com/data", (error, data) => {
if (error) {
// ***Xử lý lỗi tại đây***
console.error("Lỗi khi fetch dữ liệu:", error.message);
// Có thể hiển thị thông báo cho người dùng, log lỗi server, v.v.
} else {
// Xử lý dữ liệu thành công
console.log("Dữ liệu đã lấy thành công:", data);
}
});
Nhược điểm:
Khi bạn có nhiều thao tác bất đồng bộ phụ thuộc vào nhau (kết quả của cái này là đầu vào cho cái kia), bạn sẽ phải lồng nhiều callbacks vào nhau. Điều này dẫn đến Callback Hell (Địa ngục Callback), và việc xử lý lỗi trở nên vô cùng phức tạp và khó đọc. Bạn phải kiểm tra lỗi ở mỗi bước lồng nhau:
// Minh họa Callback Hell với xử lý lỗi
asyncOperation1((error1, result1) => {
if (error1) {
console.error("Lỗi bước 1:", error1);
return; // Dừng lại nếu có lỗi
}
asyncOperation2(result1, (error2, result2) => {
if (error2) {
console.error("Lỗi bước 2:", error2);
return; // Dừng lại
}
asyncOperation3(result2, (error3, result3) => {
if (error3) {
console.error("Lỗi bước 3:", error3);
return; // Dừng lại
}
// ... Tiếp tục lồng sâu hơn ...
console.log("Thành công cuối cùng:", result3);
});
});
});
Mỗi tầng lồng nhau đều cần một khối if (error) { ... return; }
riêng biệt, làm cho code trở nên khó quản lý và dễ mắc lỗi.
2. Xử lý lỗi với Promises ("Đời thứ hai")
Promises ra đời để giải quyết vấn đề của Callback Hell, và chúng cũng mang đến một mô hình xử lý lỗi thanh thoát hơn nhiều.
Một Promise có thể ở một trong ba trạng thái:
- Pending: Đang chờ xử lý.
- Fulfilled (Resolved): Hoàn thành thành công với một giá trị.
- Rejected: Hoàn thành thất bại với một lý do (thường là một đối tượng
Error
).
Khi một Promise bị rejected, nó sẽ bỏ qua tất cả các hàm xử lý thành công (.then()
) tiếp theo trong chuỗi và nhảy thẳng đến hàm xử lý lỗi gần nhất.
Bạn tạo một Promise và gọi reject()
khi có lỗi:
function fetchDataWithPromise(url) {
return new Promise((resolve, reject) => {
setTimeout(() => { // Giả lập gọi API
const success = Math.random() > 0.5;
if (success) {
const data = { id: 1, name: "Sample Data (Promise)" };
resolve(data); // Hoàn thành thành công
} else {
const error = new Error(`Không thể lấy dữ liệu với Promise từ ${url}`);
reject(error); // ***Hoàn thành thất bại (ném lỗi)***
}
}, 1000);
});
}
Bạn xử lý lỗi bằng phương thức .catch()
của Promise:
fetchDataWithPromise("https://api.example.com/data")
.then(data => {
// Xử lý dữ liệu thành công
console.log("Promise thành công:", data);
})
.catch(error => {
// ***Xử lý lỗi tại đây***
console.error("Lỗi khi fetch dữ liệu với Promise:", error.message);
});
Ưu điểm lớn nhất: Khi xâu chuỗi nhiều Promises, bạn chỉ cần một .catch()
ở cuối chuỗi để bắt lỗi có thể xảy ra ở bất kỳ bước nào trong chuỗi .then()
trước đó.
// Minh họa xử lý lỗi với Promise Chain
asyncPromiseOperation1()
.then(result1 => {
console.log("Bước 1 thành công:", result1);
return asyncPromiseOperation2(result1); // Trả về Promise mới cho bước 2
})
.then(result2 => {
console.log("Bước 2 thành công:", result2);
return asyncPromiseOperation3(result2); // Trả về Promise mới cho bước 3
})
.then(result3 => {
console.log("Bước 3 thành công:", result3);
console.log("Toàn bộ chuỗi Promise thành công!");
})
.catch(error => {
// ***Một .catch() duy nhất bắt tất cả các lỗi từ bất kỳ bước nào***
console.error("Đã xảy ra lỗi trong chuỗi Promise:", error);
// Biết lỗi xảy ra ở bước nào có thể cần kiểm tra chi tiết đối tượng error
});
Nếu asyncPromiseOperation1
, asyncPromiseOperation2
, hoặc asyncPromiseOperation3
bị reject
, chuỗi .then()
sẽ dừng lại và .catch()
cuối cùng sẽ được gọi. Điều này giúp mã gọn gàng, dễ đọc và dễ bảo trì hơn nhiều so với Callbacks.
Quan trọng: Nếu bạn không có .catch()
ở cuối một chuỗi Promise và một Promise bị rejected, lỗi đó sẽ trở thành một lỗi "chưa được xử lý" (unhandled rejection), có thể gây ra cảnh báo trong console hoặc thậm chí làm crash ứng dụng trong một số môi trường.
3. Xử lý lỗi với Async/Await ("Đời thứ ba và hiện tại")
async
và await
là cú pháp "đường" (syntactic sugar) được xây dựng trên nền tảng của Promises. Chúng cho phép bạn viết mã bất đồng bộ theo phong cách đồng bộ, làm cho mã dễ đọc và hiểu hơn đáng kể.
Điều tuyệt vời là với async/await
, bạn có thể sử dụng lại cú pháp xử lý lỗi quen thuộc của mã đồng bộ: try...catch
.
Một hàm được đánh dấu bằng từ khóa async
sẽ luôn trả về một Promise. Bên trong hàm async
, bạn có thể sử dụng từ khóa await
trước một Promise. await
sẽ tạm dừng việc thực thi hàm async
cho đến khi Promise đó hoàn thành.
- Nếu Promise được
resolve
,await
sẽ trả về giá trị resolved. - Nếu Promise bị
reject
,await
sẽ ném ra một ngoại lệ (throw an exception), giống hệt như một lỗi đồng bộ.
Chính vì await
ném ra ngoại lệ khi Promise bị rejected, bạn có thể "bắt" nó bằng try...catch
:
async function processDataAsync(url) {
try {
// ***Đặt mã bất đồng bộ có thể lỗi vào khối try***
const data = await fetchDataWithPromise(url); // await ném lỗi nếu Promise bị reject
console.log("Async/await thành công:", data);
// Các thao tác bất đồng bộ khác có thể ở đây
// const moreData = await fetchAnotherData(data.id);
// console.log("Async/await bước 2 thành công:", moreData);
// Mã đồng bộ bên trong try cũng được bảo vệ
if (!data) {
throw new Error("Dữ liệu rỗng không hợp lệ!");
}
} catch (error) {
// ***Bắt lỗi từ await (khi Promise reject) HOẶC lỗi đồng bộ bên trong try***
console.error("Đã xảy ra lỗi với async/await:", error.message);
// Xử lý lỗi tập trung tại một nơi
} finally {
// Khối finally (tùy chọn) luôn chạy sau try hoặc catch
console.log("Kết thúc quá trình xử lý bất kể thành công hay thất bại.");
}
}
// Gọi hàm async
processDataAsync("https://api.example.com/data");
Ưu điểm:
- Mã trông giống mã đồng bộ, dễ đọc và debug hơn.
- Sử dụng cú pháp
try...catch
quen thuộc cho cả lỗi đồng bộ và bất đồng bộ (từawait
). - Xử lý lỗi tập trung và rõ ràng hơn nhiều so với Callbacks và thậm chí là Promise chains phức tạp.
Ví dụ thực tế: Xử lý lỗi khi sử dụng fetch
API
fetch
API hiện đại trả về một Promise. Tuy nhiên, có một điểm cần lưu ý về xử lý lỗi với fetch
:
- Promise trả về từ
fetch
chỉ bị reject khi có lỗi mạng (network error) hoặc lỗi cors. - Đối với các mã trạng thái HTTP báo lỗi (ví dụ: 404 Not Found, 500 Internal Server Error), Promise vẫn được resolve, nhưng thuộc tính
response.ok
sẽ làfalse
.
Vì vậy, bạn cần xử lý cả hai trường hợp này:
Sử dụng .then().catch()
:
fetch("https://api.example.com/nonexistent-data") // URL giả định gây lỗi 404 hoặc 500
.then(response => {
// Kiểm tra mã trạng thái HTTP trước
if (!response.ok) {
// Nếu trạng thái không ok, tạo và ném một lỗi để .catch() bắt lấy
// response.status và response.statusText cung cấp thông tin chi tiết
throw new Error(`Lỗi HTTP: ${response.status} ${response.statusText}`);
}
// Nếu ok, tiến hành xử lý dữ liệu (ví dụ: JSON)
return response.json();
})
.then(data => {
// Xử lý dữ liệu thành công (nếu response.ok là true)
console.log("Dữ liệu từ Fetch:", data);
})
.catch(error => {
// ***Bắt lỗi mạng HOẶC lỗi HTTP mà chúng ta ném ra***
console.error("Đã xảy ra lỗi khi Fetch:", error.message);
});
Sử dụng async/await
với try...catch
:
Đây là cách phổ biến và được khuyến khích hơn với fetch
:
async function fetchDataAsync(url) {
try {
const response = await fetch(url); // await sẽ ném lỗi nếu có lỗi mạng
// ***Kiểm tra mã trạng thái HTTP sau khi await thành công (không có lỗi mạng)***
if (!response.ok) {
// Nếu trạng thái không ok, ném lỗi để khối catch bắt
throw new Error(`Lỗi HTTP: ${response.status} ${response.statusText}`);
}
// Nếu không có lỗi mạng và mã trạng thái ok, tiến hành xử lý JSON
const data = await response.json(); // Việc await .json() cũng có thể ném lỗi nếu JSON không hợp lệ
console.log("Dữ liệu từ Fetch (async/await):", data);
} catch (error) {
// ***Bắt lỗi mạng, lỗi HTTP mà chúng ta ném, hoặc lỗi khi xử lý JSON***
console.error("Đã xảy ra lỗi khi Fetch (async/await):", error.message);
}
}
// Gọi hàm async fetch
fetchDataAsync("https://api.example.com/some-data"); // URL giả định thành công
fetchDataAsync("https://api.example.com/nonexistent-data"); // URL giả định thất bại HTTP
Trong cả hai ví dụ fetch
, chúng ta đều xử lý lỗi mạng (bị bắt bởi .catch()
hoặc try...catch
) và lỗi HTTP (bằng cách kiểm tra response.ok
và chủ động ném lỗi để nó được bắt).
4. Xử lý nhiều thao tác bất đồng bộ
Khi bạn cần thực hiện nhiều Promises song song và xử lý kết quả hoặc lỗi của chúng, Promise.all
, Promise.race
, Promise.any
, và Promise.allSettled
là các công cụ hữu ích, mỗi loại có cách xử lý lỗi riêng:
Promise.all(promises)
: Chờ tất cả Promises trong mảngpromises
hoàn thành. Nếu bất kỳ Promise nào bịreject
,Promise.all
sẽ ngay lập tứcreject
với lý do của Promise đầu tiên bị reject đó. Bạn xử lý lỗi này bằng một.catch()
duy nhất.async function fetchMultipleData(urls) { try { const promises = urls.map(url => fetch(url).then(res => { if (!res.ok) throw new Error(`Lỗi HTTP: ${res.status}`); return res.json(); })); const results = await Promise.all(promises); // Sẽ ném lỗi nếu bất kỳ fetch nào thất bại console.log("Tất cả dữ liệu đã lấy thành công:", results); } catch (error) { // ***Bắt lỗi từ Promise.all (lỗi của Promise đầu tiên bị reject)*** console.error("Đã xảy ra lỗi khi lấy một trong các dữ liệu:", error.message); } } fetchMultipleData(["url1", "url2", "bad_url", "url4"]);
Promise.race(promises)
: Chờ Promise đầu tiên hoàn thành (resolve hoặc reject). Nó sẽ resolve hoặc reject với kết quả/lý do của Promise đầu tiên đó. Xử lý lỗi bằng.catch()
.// Ví dụ: Cố gắng lấy dữ liệu từ nhiều nguồn, dùng kết quả của nguồn nhanh nhất Promise.race([ fetch('slow_url').then(res => res.json()), fetch('fast_url').then(res => res.json()) // Giả định cái này nhanh hơn ]) .then(firstResult => { console.log("Kết quả đầu tiên:", firstResult); }) .catch(error => { // ***Bắt lỗi nếu Promise đầu tiên hoàn thành là một rejection*** console.error("Promise nhanh nhất đã thất bại:", error); });
Promise.any(promises)
(ES2021): Chờ Promise đầu tiên đượcresolve
. Nó sẽresolve
với giá trị của Promise đó. Nếu tất cả Promises trong mảng đều bịreject
,Promise.any
sẽ bịreject
với mộtAggregateError
(một loại lỗi mới chứa danh sách tất cả các lý do reject).Promise.any([ fetch('bad_url_1').then(res => res.json()), // Sẽ reject fetch('bad_url_2').then(res => res.json()), // Sẽ reject fetch('good_url').then(res => { // Sẽ resolve if (!res.ok) throw new Error('HTTP Error'); return res.json(); }) ]) .then(firstResolvedResult => { console.log("Promise đầu tiên resolve:", firstResolvedResult); // Sẽ là kết quả từ 'good_url' }) .catch(error => { // ***Chỉ bắt lỗi nếu TẤT CẢ promises bị reject*** // error sẽ là một AggregateError console.error("Tất cả promises đều thất bại:", error.errors); // error.errors là mảng các lý do reject });
Promise.allSettled(promises)
(ES2020): Chờ tất cả Promises trong mảng hoàn thành, bất kể làresolve
hayreject
. Nó luônresolve
với một mảng các đối tượng, mỗi đối tượng mô tả kết quả của từng Promise ban đầu (trạng thái: 'fulfilled' hoặc 'rejected', và giá trị/lý do tương ứng). Đây là phương thức hữu ích khi bạn muốn biết kết quả của tất cả các thao tác, thay vì dừng lại khi có lỗi đầu tiên.async function fetchAllResults(urls) { const promises = urls.map(url => fetch(url).then(res => { // Vẫn kiểm tra lỗi HTTP và ném ra để nó được ghi lại trong rejection if (!res.ok) throw new Error(`Lỗi HTTP ${res.status} từ ${url}`); return res.json(); })); const results = await Promise.allSettled(promises); // Luôn chờ và trả về kết quả cho tất cả console.log("Kết quả của tất cả các fetch:", results); // ***Iterate qua kết quả để xử lý thành công/thất bại cho từng Promise*** results.forEach((result, index) => { const url = urls[index]; if (result.status === 'fulfilled') { console.log(`URL ${url} thành công với dữ liệu:`, result.value); } else { // result.status === 'rejected' console.error(`URL ${url} thất bại với lỗi:`, result.reason); // result.reason là lỗi bị ném/reject } }); } fetchAllResults(["url1", "bad_url", "url3"]);
Promise.allSettled
rất hữu ích khi bạn không muốn một lỗi duy nhất làm hỏng toàn bộ nhóm tác vụ bất đồng bộ.
Comments