Phát hiện tính năng dự đoán trong Công cụ của Chrome cho nhà phát triển: Lý do khó phát hiện và cách cải thiện

Eric Leese
Eric Leese

Việc gỡ lỗi ngoại lệ trong các ứng dụng web có vẻ đơn giản: tạm dừng quá trình thực thi khi có sự cố và điều tra. Tuy nhiên, bản chất không đồng bộ của JavaScript khiến việc này trở nên phức tạp một cách đáng ngạc nhiên. Làm cách nào để Công cụ của Chrome cho nhà phát triển biết thời điểm và vị trí tạm dừng khi các ngoại lệ bay qua các lời hứa và hàm không đồng bộ?

Bài đăng này sẽ đi sâu vào các thách thức của tính năng dự đoán lỗi – khả năng của DevTools để dự đoán xem liệu một ngoại lệ có được phát hiện sau này trong mã của bạn hay không. Chúng ta sẽ tìm hiểu lý do việc này khó khăn như vậy và những điểm cải tiến gần đây trong V8 (công cụ JavaScript hỗ trợ Chrome) đang giúp việc này chính xác hơn, mang lại trải nghiệm gỡ lỗi mượt mà hơn.

Lý do tính năng dự đoán lượt bắt cá lại quan trọng 

Trong Chrome DevTools, bạn có thể chọn chỉ tạm dừng quá trình thực thi mã đối với các ngoại lệ chưa phát hiện được, bỏ qua các ngoại lệ đã phát hiện được. 

Công cụ của Chrome cho nhà phát triển cung cấp các tuỳ chọn riêng để tạm dừng khi phát hiện hoặc không phát hiện ngoại lệ

Ở chế độ nền, trình gỡ lỗi sẽ dừng ngay lập tức khi xảy ra ngoại lệ để giữ lại ngữ cảnh. Đây là một dự đoán vì tại thời điểm này, không thể biết chắc chắn liệu ngoại lệ có được phát hiện hay không trong mã sau này, đặc biệt là trong các trường hợp không đồng bộ. Sự không chắc chắn này bắt nguồn từ khó khăn vốn có trong việc dự đoán hành vi của chương trình, tương tự như Vấn đề dừng.

Hãy xem xét ví dụ sau: trình gỡ lỗi nên tạm dừng ở đâu? (Tìm câu trả lời trong phần tiếp theo.)

async function inner() {
  throw new Error(); // Should the debugger pause here?
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ... or should the debugger pause here?
  }
}

Việc tạm dừng trên các ngoại lệ trong trình gỡ lỗi có thể gây gián đoạn và dẫn đến việc thường xuyên bị gián đoạn và chuyển sang mã không quen thuộc. Để giảm thiểu vấn đề này, bạn có thể chọn chỉ gỡ lỗi các ngoại lệ chưa phát hiện được. Những ngoại lệ này có nhiều khả năng báo hiệu lỗi thực tế hơn. Tuy nhiên, điều này phụ thuộc vào độ chính xác của tính năng dự đoán lượng cá đánh bắt được.

Thông tin dự đoán không chính xác gây khó chịu:

  • Kết quả âm tính giả (dự đoán "không phát hiện" khi lỗi sẽ được phát hiện). Các điểm dừng không cần thiết trong trình gỡ lỗi.
  • Dương tính giả (dự đoán "bị phát hiện" khi không bị phát hiện). Bỏ lỡ cơ hội phát hiện lỗi nghiêm trọng, có thể buộc bạn phải gỡ lỗi tất cả các trường hợp ngoại lệ, bao gồm cả các trường hợp ngoại lệ dự kiến.

Một phương pháp khác để giảm tình trạng gián đoạn khi gỡ lỗi là sử dụng danh sách bỏ qua. Danh sách này giúp ngăn chặn các điểm ngắt trên các ngoại lệ trong mã bên thứ ba được chỉ định.  Tuy nhiên, việc dự đoán chính xác khả năng bắt cá vẫn rất quan trọng. Nếu một ngoại lệ bắt nguồn từ mã của bên thứ ba thoát ra và ảnh hưởng đến mã của riêng bạn, bạn sẽ muốn có thể gỡ lỗi ngoại lệ đó.

Cách hoạt động của mã không đồng bộ

Lời hứa, asyncawait cũng như các mẫu không đồng bộ khác có thể dẫn đến các tình huống trong đó ngoại lệ hoặc trường hợp từ chối, trước khi được xử lý, có thể đi theo một đường dẫn thực thi khó xác định tại thời điểm ngoại lệ được gửi. Lý do là bạn không thể chờ các lời hứa hoặc thêm trình xử lý phát hiện ngoại lệ cho đến khi ngoại lệ xảy ra. Hãy xem ví dụ trước:

async function inner() {
  throw new Error();
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ...
  }
}

Trong ví dụ này, trước tiên outer() gọi inner() và ngay lập tức gửi một ngoại lệ. Từ đó, trình gỡ lỗi có thể kết luận rằng inner() sẽ trả về một lời hứa bị từ chối nhưng hiện không có gì đang chờ xử lý lời hứa đó. Trình gỡ lỗi có thể đoán rằng outer() có thể sẽ chờ và đoán rằng nó sẽ làm như vậy trong khối try hiện tại và do đó xử lý nó, nhưng trình gỡ lỗi không thể chắc chắn về điều này cho đến khi lời hứa bị từ chối được trả về và cuối cùng đạt được câu lệnh await.

Trình gỡ lỗi không thể đảm bảo rằng thông tin dự đoán về lỗi sẽ chính xác, nhưng trình gỡ lỗi sử dụng nhiều phương pháp phỏng đoán cho các mẫu lập trình phổ biến để dự đoán chính xác. Để hiểu được các mẫu này, bạn cần tìm hiểu cách hoạt động của các lời hứa.

Trong V8, Promise JavaScript được biểu thị dưới dạng một đối tượng có thể ở một trong ba trạng thái: đã thực hiện, bị từ chối hoặc đang chờ xử lý. Nếu một lời hứa ở trạng thái đã thực hiện và bạn gọi phương thức .then(), thì một lời hứa mới đang chờ xử lý sẽ được tạo và một tác vụ phản ứng mới của lời hứa sẽ được lên lịch. Tác vụ này sẽ chạy trình xử lý, sau đó đặt lời hứa thành đã thực hiện bằng kết quả của trình xử lý hoặc đặt thành bị từ chối nếu trình xử lý gửi một ngoại lệ. Điều tương tự cũng xảy ra nếu bạn gọi phương thức .catch() trên một lời hứa bị từ chối. Ngược lại, việc gọi .then() trên một lời hứa bị từ chối hoặc .catch() trên một lời hứa đã thực hiện sẽ trả về một lời hứa ở cùng trạng thái và không chạy trình xử lý. 

Lời hứa đang chờ xử lý chứa một danh sách phản ứng, trong đó mỗi đối tượng phản ứng chứa một trình xử lý thực hiện hoặc trình xử lý từ chối (hoặc cả hai) và một lời hứa phản ứng. Vì vậy, việc gọi .then() trên một lời hứa đang chờ xử lý sẽ thêm một phản ứng bằng một trình xử lý đã thực hiện cũng như một lời hứa mới đang chờ xử lý cho lời hứa phản ứng mà .then() sẽ trả về. Việc gọi .catch() sẽ thêm một phản ứng tương tự nhưng có trình xử lý từ chối. Việc gọi .then() bằng hai đối số sẽ tạo ra một phản ứng với cả hai trình xử lý, đồng thời việc gọi .finally() hoặc chờ lời hứa sẽ thêm một phản ứng với hai trình xử lý là các hàm tích hợp dành riêng cho việc triển khai các tính năng này.

Khi lời hứa đang chờ xử lý cuối cùng được thực hiện hoặc bị từ chối, các công việc phản ứng sẽ được lên lịch cho tất cả trình xử lý đã thực hiện hoặc tất cả trình xử lý đã từ chối. Sau đó, các lời hứa tương ứng về lượt phản ứng sẽ được cập nhật, có thể kích hoạt các công việc phản ứng của riêng chúng.

Ví dụ

Hãy xem xét mã sau:

return new Promise(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

Có thể bạn không nhận ra rằng mã này liên quan đến ba đối tượng Promise riêng biệt. Mã trên tương đương với mã sau:

const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;

Trong ví dụ này, các bước sau sẽ xảy ra:

  1. Hàm khởi tạo Promise được gọi.
  2. Một Promise mới đang chờ xử lý sẽ được tạo.
  3. Hàm ẩn danh được chạy.
  4. Hệ thống sẽ gửi một trường hợp ngoại lệ. Tại thời điểm này, trình gỡ lỗi cần quyết định có dừng hay không.
  5. Hàm khởi tạo lời hứa sẽ phát hiện ngoại lệ này, sau đó thay đổi trạng thái của lời hứa thành rejected với giá trị được đặt thành lỗi đã gửi. Phương thức này trả về lời hứa này, được lưu trữ trong promise1.
  6. .then() không lên lịch công việc phản ứng vì promise1 ở trạng thái rejected. Thay vào đó, một lời hứa mới (promise2) sẽ được trả về, lời hứa này cũng ở trạng thái bị từ chối với cùng một lỗi.
  7. .catch() lên lịch công việc phản ứng bằng trình xử lý được cung cấp và một lời hứa phản ứng mới đang chờ xử lý, được trả về dưới dạng promise3. Tại thời điểm này, trình gỡ lỗi biết rằng lỗi sẽ được xử lý.
  8. Khi tác vụ phản ứng chạy, trình xử lý sẽ trả về bình thường và trạng thái của promise3 sẽ thay đổi thành fulfilled.

Ví dụ tiếp theo có cấu trúc tương tự nhưng cách thực thi lại hoàn toàn khác:

return Promise.resolve()
    .then(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

Giá trị này tương đương với:

const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;

Trong ví dụ này, các bước sau sẽ xảy ra:

  1. Promise được tạo ở trạng thái fulfilled và lưu trữ trong promise1.
  2. Một tác vụ phản ứng hứa hẹn được lên lịch bằng hàm ẩn danh đầu tiên và lời hứa phản ứng (pending) của hàm này được trả về dưới dạng promise2.
  3. Một phản ứng được thêm vào promise2 bằng một trình xử lý đã thực hiện và lời hứa phản ứng được trả về dưới dạng promise3.
  4. Một phản ứng được thêm vào promise3 với một trình xử lý bị từ chối và một lời hứa phản ứng khác, được trả về dưới dạng promise4.
  5. Tác vụ phản ứng được lên lịch trong bước 2 sẽ chạy.
  6. Trình xử lý sẽ gửi một ngoại lệ. Tại thời điểm này, trình gỡ lỗi cần quyết định có dừng hay không. Hiện tại, trình xử lý là mã JavaScript duy nhất đang chạy.
  7. Vì tác vụ kết thúc bằng một ngoại lệ, nên lời hứa phản ứng được liên kết (promise2) được đặt thành trạng thái bị từ chối với giá trị được đặt thành lỗi đã gửi.
  8. promise2 có một phản ứng và phản ứng đó không có trình xử lý bị từ chối, nên lời hứa phản ứng (promise3) cũng được đặt thành rejected với cùng một lỗi.
  9. promise3 có một phản ứng và phản ứng đó có một trình xử lý bị từ chối, nên một tác vụ phản ứng hứa hẹn được lên lịch với trình xử lý đó và lời hứa phản ứng (promise4).
  10. Khi tác vụ phản ứng đó chạy, trình xử lý sẽ trả về bình thường và trạng thái của promise4 sẽ thay đổi thành đã thực hiện.

Phương thức dự đoán lượt bắt

Có hai nguồn thông tin tiềm năng để dự đoán số lượng cá đánh bắt được. Một là ngăn xếp lệnh gọi. Đây là cách hợp lý đối với các ngoại lệ đồng bộ: trình gỡ lỗi có thể đi qua ngăn xếp lệnh gọi theo cách tương tự như mã gỡ bỏ ngoại lệ và dừng nếu tìm thấy một khung nằm trong khối try...catch. Đối với các lời hứa hoặc ngoại lệ bị từ chối trong hàm khởi tạo lời hứa hoặc trong các hàm không đồng bộ chưa bao giờ bị tạm ngưng, trình gỡ lỗi cũng dựa vào ngăn xếp lệnh gọi, nhưng trong trường hợp này, dự đoán của trình gỡ lỗi không thể đáng tin cậy trong mọi trường hợp. Lý do là thay vì gửi một ngoại lệ đến trình xử lý gần nhất, mã không đồng bộ sẽ trả về một ngoại lệ bị từ chối và trình gỡ lỗi phải đưa ra một số giả định về những gì phương thức gọi sẽ làm với ngoại lệ đó.

Trước tiên, trình gỡ lỗi giả định rằng một hàm nhận được một lời hứa được trả về có thể trả về lời hứa đó hoặc một lời hứa phái sinh để các hàm không đồng bộ ở phía trên ngăn xếp có cơ hội chờ lời hứa đó. Thứ hai, trình gỡ lỗi giả định rằng nếu một lời hứa được trả về cho một hàm không đồng bộ, thì trình gỡ lỗi sẽ sớm chờ lời hứa đó mà không cần phải nhập hoặc rời khỏi khối try...catch trước. Không có giả định nào trong số này được đảm bảo là chính xác, nhưng chúng là đủ để đưa ra dự đoán chính xác cho các mẫu lập trình phổ biến nhất với các hàm không đồng bộ. Trong Chrome phiên bản 125, chúng tôi đã thêm một phương pháp phỏng đoán khác: trình gỡ lỗi sẽ kiểm tra xem phương thức được gọi có sắp gọi .catch() trên giá trị sẽ được trả về hay không (hoặc .then() có hai đối số hoặc một chuỗi lệnh gọi đến .then() hoặc .finally() theo sau là .catch() hoặc .then() có hai đối số). Trong trường hợp này, trình gỡ lỗi giả định rằng đây là các phương thức trên lời hứa mà chúng ta đang theo dõi hoặc một phương thức liên quan đến lời hứa đó, vì vậy, trường hợp từ chối sẽ được phát hiện.

Nguồn thông tin thứ hai là cây phản ứng của lời hứa. Trình gỡ lỗi bắt đầu bằng một lời hứa gốc. Đôi khi, đây là một lời hứa mà phương thức reject() của nó vừa được gọi. Thường thì khi một ngoại lệ hoặc trường hợp từ chối xảy ra trong một công việc phản ứng theo lời hứa và không có gì trên ngăn xếp lệnh gọi có vẻ như phát hiện được ngoại lệ hoặc trường hợp từ chối đó, trình gỡ lỗi sẽ theo dõi từ lời hứa liên kết với phản ứng. Trình gỡ lỗi xem xét tất cả các phản ứng trên lời hứa đang chờ xử lý và xem liệu các phản ứng đó có trình xử lý từ chối hay không. Nếu có bất kỳ phản ứng nào không đáp ứng, thì hàm này sẽ xem xét lời hứa phản ứng và truy vết đệ quy từ lời hứa đó. Nếu tất cả các phản ứng cuối cùng đều dẫn đến một trình xử lý từ chối, thì trình gỡ lỗi sẽ xem xét việc từ chối lời hứa. Có một số trường hợp đặc biệt cần đề cập, chẳng hạn như không tính trình xử lý từ chối tích hợp sẵn cho lệnh gọi .finally().

Cây phản ứng của lời hứa thường cung cấp một nguồn thông tin đáng tin cậy nếu có thông tin. Trong một số trường hợp, chẳng hạn như lệnh gọi đến Promise.reject() hoặc trong hàm khởi tạo Promise hoặc trong một hàm không đồng bộ chưa chờ đợi bất kỳ điều gì, sẽ không có phản ứng nào để theo dõi và trình gỡ lỗi phải dựa vào ngăn xếp lệnh gọi. Trong các trường hợp khác, cây phản ứng của lời hứa thường chứa các trình xử lý cần thiết để suy luận dự đoán về việc bắt, nhưng luôn có thể thêm các trình xử lý khác sau này để thay đổi ngoại lệ từ đã bắt thành chưa bắt hoặc ngược lại. Ngoài ra, cũng có những lời hứa như những lời hứa do Promise.all/any/race tạo ra, trong đó các lời hứa khác trong nhóm có thể ảnh hưởng đến cách xử lý một lời từ chối. Đối với các phương thức này, trình gỡ lỗi giả định rằng một lời từ chối lời hứa sẽ được chuyển tiếp nếu lời hứa đó vẫn đang chờ xử lý.

Hãy xem hai ví dụ sau:

Hai ví dụ về dự đoán lượt bắt

Mặc dù hai ví dụ về trường hợp ngoại lệ đã phát hiện này có vẻ tương tự nhau, nhưng chúng đòi hỏi các phương pháp dự đoán phát hiện khá khác nhau. Trong ví dụ đầu tiên, một lời hứa đã được giải quyết sẽ được tạo, sau đó một công việc phản ứng cho .then() sẽ được lên lịch và sẽ gửi một ngoại lệ, sau đó .catch() sẽ được gọi để đính kèm một trình xử lý từ chối vào lời hứa phản ứng. Khi chạy tác vụ phản ứng, ngoại lệ sẽ được gửi và cây phản ứng hứa hẹn sẽ chứa trình xử lý catch, vì vậy, ngoại lệ sẽ được phát hiện là đã được xử lý. Trong ví dụ thứ hai, lời hứa bị từ chối ngay lập tức trước khi mã để thêm trình xử lý phát hiện lỗi được chạy, vì vậy, không có trình xử lý từ chối nào trong cây phản ứng của lời hứa. Trình gỡ lỗi phải xem ngăn xếp lệnh gọi nhưng cũng không có khối try...catch nào. Để dự đoán chính xác điều này, trình gỡ lỗi sẽ quét trước vị trí hiện tại trong mã để tìm lệnh gọi đến .catch() và giả định rằng cuối cùng, lệnh từ chối sẽ được xử lý.

Tóm tắt

Hy vọng rằng phần giải thích này đã giúp bạn hiểu rõ cách hoạt động của tính năng dự đoán lỗi bắt trong Công cụ của Chrome cho nhà phát triển, cũng như những điểm mạnh và hạn chế của tính năng này. Nếu bạn gặp vấn đề gỡ lỗi do dự đoán không chính xác, hãy cân nhắc các lựa chọn sau:

  • Thay đổi mẫu lập trình thành một mẫu dễ dự đoán hơn, chẳng hạn như sử dụng các hàm không đồng bộ.
  • Chọn ngắt trên tất cả các trường hợp ngoại lệ nếu DevTools không dừng khi cần.
  • Sử dụng điểm ngắt "Never pause here" (Không bao giờ tạm dừng tại đây) hoặc điểm ngắt có điều kiện nếu trình gỡ lỗi đang dừng ở một vị trí mà bạn không muốn.

Lời cảm ơn

Chúng tôi vô cùng cảm ơn Sofia Emelianova và Jecelyn Yeen đã giúp đỡ chúng tôi chỉnh sửa bài đăng này!