Lỗi trong code bất đồng bộ không giống lỗi đồng bộ
Trong JavaScript đồng bộ, một lỗi bị ném ra sẽ nổi lên theo call stack cho đến khi một try/catch nào đó bắt được nó, hoặc chương trình dừng hẳn. Code bất đồng bộ phá vỡ mô hình đó. Đến lúc một request mạng thất bại, hàm khởi động nó đã trả về từ lâu. Không còn call stack nào để lỗi nổi lên nữa.
Promise giải quyết điều này bằng cách cho lỗi một kênh riêng. Một promise có thể fulfill với một giá trị hoặc reject với một lý do. Reject là phiên bản bất đồng bộ của việc ném lỗi. Toàn bộ trang này nói về việc đảm bảo các lần reject rơi vào chỗ bạn kiểm soát, thay vì biến mất.
Khối try/catch chạy và kết thúc êm ả. Lần reject xảy ra 50ms sau đó, lâu sau khi khối try đã xong. Không gì bắt được nó. Đó chính là cái bẫy.
try/catch hoạt động trở lại với await
Ngay khi bạn await một promise, lần reject trở thành một lỗi bị ném ra bên trong hàm async. Một try/catch bao quanh sẽ bắt nó như mọi lỗi đồng bộ khác:
Đây là mẫu nên dùng đầu tiên. await nối thế giới bất đồng bộ trở lại với dạng try/catch quen thuộc. Đặt các lời gọi await có thể thất bại bên trong try, và xử lý chúng trong catch.
Một chi tiết đáng lưu ý: chỉ lời gọi được await mới được bảo vệ. Nếu bạn khởi chạy một promise mà không await, lỗi vẫn thoát ra ngoài.
Bug phổ biến nhất: quên await
Nếu bạn gọi một hàm async mà không có await (hoặc không trả về promise của nó), các lần reject sẽ lọt qua try/catch bao quanh:
Khối try kết thúc thành công. Lần reject xảy ra ở tick tiếp theo, không có gì bắt nó. Bạn sẽ thấy cảnh báo "unhandled promise rejection" trong console.
Cách sửa luôn giống nhau: await lời gọi đó, hoặc return promise để nơi gọi có thể await nó.
.catch() là mặt còn lại của cùng một đồng xu
Bạn có thể xử lý reject mà không cần async/await bằng cách nối .catch():
.catch(fn) là cách viết tắt của .then(undefined, fn). Nó xử lý mọi lần reject xảy ra trước đó trong chuỗi. Một .catch() ở cuối chuỗi là phiên bản bất đồng bộ của một try/catch ở cấp cao nhất: tuyến phòng thủ cuối cùng trước khi lần reject trở thành "unhandled."
Kết hợp hai phong cách hoàn toàn ổn. Một mẫu phổ biến là dùng async/await bên trong hàm và để nơi gọi gắn .catch():
fetch không reject khi gặp lỗi HTTP
Điều này khiến ai cũng vấp ít nhất một lần. fetch chỉ reject khi lỗi ở tầng mạng: tra cứu DNS thất bại, kết nối bị từ chối, request bị hủy. Một response 404 hay 500 vẫn được coi là fetch thành công. Promise vẫn resolve, chỉ là resolve với một response có ok bằng false.
Nếu bạn muốn lỗi HTTP rơi vào khối catch, hãy kiểm tra res.ok và ném lỗi một cách rõ ràng:
Đây là đoạn code lặp lại đáng tách thành một hàm hỗ trợ khi bạn thấy mình viết nó lần thứ hai.
Promise.all thất bại ngay, Promise.allSettled thì không
Promise.all nhận một mảng promise và resolve với một mảng kết quả, trừ khi có một promise reject, khi đó nó reject ngay lập tức với lỗi đó. Các promise khác vẫn chạy tiếp, nhưng kết quả của chúng bị bỏ đi.
Thất bại ngay là hành vi đúng khi bạn cần mọi kết quả và chỉ một lỗi cũng khiến cả thao tác vô nghĩa. Khi bạn muốn nhận mọi kết quả bất kể thế nào ("thử năm lần tải lên này, cho tôi biết lần nào thành công và lần nào thất bại"), hãy dùng Promise.allSettled:
allSettled không bao giờ reject. Mỗi phần tử là {status: "fulfilled", value} hoặc {status: "rejected", reason}.
Ném lại lỗi và bắt có chọn lọc
Không phải lỗi nào cũng nên vào cùng một trình xử lý. Một mẫu phổ biến là bắt lỗi, kiểm tra, rồi ném lại bất cứ thứ gì bạn không lường trước:
Nuốt mọi lỗi bằng một catch (err) {} trống sẽ che giấu bug thật. Hãy bắt những gì bạn xử lý được một cách có ý nghĩa; ném lại phần còn lại.
Unhandled rejection là lưới an toàn của bạn
Dù code cẩn thận đến đâu, sớm muộn cũng có thứ lọt qua. Cả Node.js và trình duyệt đều cung cấp một hook toàn cục cho những lần reject không ai bắt:
// Browser
window.addEventListener("unhandledrejection", event => {
console.error("unhandled:", event.reason);
event.preventDefault(); // suppress the default console warning
});
// Node.js
process.on("unhandledRejection", reason => {
console.error("unhandled:", reason);
});
Đây không phải cách thay thế cho việc xử lý lỗi đúng cách, mà là hook cuối cùng để ghi log hoặc gửi dữ liệu theo dõi. Trong Node.js hiện đại, một unhandled rejection mặc định sẽ dừng hẳn tiến trình, thường đó là điều bạn muốn trên production. Ghi log lỗi, rồi để tiến trình dừng và khởi động lại sạch sẽ.
Danh sách kiểm tra thực tế
Khi một hàm async động đến bất cứ thứ gì có thể thất bại, hãy tự hỏi:
- Mọi
awaitcó rủi ro đã nằm trongtry/catchchưa, hay promise được trả về đã được nơi gọi xử lý bằng.catch()? - Mình có thật sự
awaitlời gọi đó không, hay vô tình gọi rồi bỏ mặc? - Riêng với
fetch, mình đã kiểm trares.oktrước khi tin vào response chưa? - Khi chạy song song,
Promise.allcó phải công cụ phù hợp không, hay mình cầnPromise.allSettled? - Đã có
.catch()ở cấp cao nhất hoặc trình xử lýunhandledrejectionđể không có gì biến mất âm thầm chưa?
Làm đúng năm điều đó, code bất đồng bộ của bạn sẽ không còn khiến bạn bất ngờ với những lỗi biến mất vào event loop.
Tiếp theo: ES module
Xử lý lỗi bất đồng bộ khép lại chương về bất đồng bộ. Tiếp theo, chúng ta chuyển sang cách tổ chức code JavaScript qua nhiều tệp: import, export và hệ thống module làm nền tảng cho mọi dự án hiện đại.
Câu hỏi thường gặp
Làm sao xử lý lỗi trong một hàm async?
Đặt các lời gọi await trong một khối try/catch. Mọi lần reject từ một promise được await đều trở thành một lỗi bị ném ra và catch sẽ nhận được nó. Bạn cũng có thể để lỗi lan ra ngoài và xử lý ở chỗ gọi bằng .catch() trên promise được trả về.
Tại sao try/catch của tôi không bắt được lỗi?
Thường là vì lỗi xảy ra trong đoạn code không được await. Nếu bạn gọi một hàm async mà không có await (hoặc không trả về promise của nó), mọi lần reject sẽ thoát khỏi try/catch bao quanh. Hãy luôn await hoặc return promise mà bạn muốn bắt lỗi.
Chuyện gì xảy ra nếu một promise bị reject mà không ai bắt?
Bạn sẽ có một unhandled rejection. Node.js phát sự kiện unhandledRejection và, ở các phiên bản gần đây, mặc định sẽ dừng hẳn tiến trình. Trình duyệt phát window.onunhandledrejection và ghi một cảnh báo. Dù thế nào, hãy gắn .catch() hoặc xử lý nó trong try/catch quanh một await.
Promise.all xử lý lỗi thế nào?
Promise.all reject ngay khi bất kỳ promise đầu vào nào reject; các promise khác vẫn tiếp tục chạy nhưng kết quả của chúng bị bỏ đi. Nếu bạn muốn nhận mọi kết quả bất kể có lỗi, hãy dùng Promise.allSettled: nó resolve với một mảng gồm các phần tử {status, value} hoặc {status, reason}.