Mọi lần tải PDF đều bị crash với cùng một lỗi ReferenceError. Stack trace không chỉ ra được gì hữu ích, và bản đặc tả hướng dẫn viết code trông có vẻ rất hợp lý trên lý thuyết. Nó bảo hãy kiểm tra một feature flag, và so sánh từng vùng với một nửa pageWidth. Nó mô tả những gì sẽ xảy ra, nhưng lại không mô tả pageWidth được lấy từ đâu, và chỉ một thiếu sót đó thôi cũng đủ để làm sập toàn bộ pipeline.

Tài liệu kiến trúc rất giỏi trong việc giải thích hành vi, nhưng thường rất tệ trong việc giải thích các ranh giới (boundaries). Một câu nói như "hàm kiểm tra x với pageWidth" không phải là một hợp đồng kỹ thuật; đó là một lời kể ẩn giấu một sự phụ thuộc bên trong những câu chữ tiếng Anh thông thường. Khi một lập trình viên đọc câu đó và viết một hàm cấp module tham chiếu đến pageWidth bằng tên, mã nguồn trông có vẻ đúng vì nó thỏa mãn mô tả. Sau đó, runtime cố gắng giải quyết tên đó, không tìm thấy gì trong phạm vi (scope), và ném lỗi.

Cuộc tái cấu trúc lẽ ra phải đơn giản

Tôi đã thấy chính xác mô hình này trong một đợt refactor lắp ráp trang. Bản đặc tả liệt kê hai yêu cầu có vẻ rất rõ ràng:

  • Kiểm tra FEATURE_LAYOUT.
  • So sánh từng vùng với pageWidth / 2.

Lập trình viên đã tuân thủ hướng dẫn một cách trung thành. Họ trích xuất một hàm tiện ích ở phạm vi module và đưa trực tiếp pageWidth vào thân hàm mà không khai báo nó như một tham số. Bản đặc tả không hề nói rằng pageWidth phải được truyền qua danh sách đối số. Nó cũng không nói rằng hàm này nằm ở phạm vi module, nơi mà pageWidth không còn hiển thị nữa. Nó chỉ đơn giản giả định rằng người triển khai đã ngầm hiểu ngữ cảnh thực thi.

Kết quả là một lỗi ReferenceError xuất hiện trong mỗi lần tải PDF. Vì biến này không tồn tại trong phạm vi module, hàm đã ném lỗi ngay lập tức. Nếu bản đặc tả nêu rõ ranh giới hàm và các đầu vào của nó, lập trình viên đã truyền pageWidth vào, và lỗi này sẽ không thể xảy ra về mặt cấu trúc. Thay vào đó, chỉ dẫn đó giống như một cái bẫy, mời gọi người triển khai truy cập vào một phạm vi cha không hề tồn tại.

Bốn ý nghĩa của từ "Sử dụng"

Vấn đề sâu xa hơn là văn bản thuần túy thiếu một hệ thống kiểu (type system). Khi một bản đặc tả nói "hàm sử dụng X", câu nói đó mơ hồ theo ít nhất bốn cách cụ thể trong một codebase JavaScript hiện đại:

  • Hàm nhận X dưới dạng một tham số chính thức (formal parameter).
  • Hàm đọc X từ một biến cấp module được khai báo trong cùng một tệp.
  • Hàm tạo một closure bao quanh X từ một phạm vi cha lồng nhau.
  • Hàm trích xuất X từ một đối tượng lớn hơn được truyền vào nó.

Mọi lựa chọn này đều thỏa mãn cách diễn đạt của bản đặc tả. Mọi lựa chọn đều vượt qua phân tích tĩnh (static analysis). Nhưng chỉ có một lựa chọn là đúng cho một ranh giới nhất định, và lựa chọn sai sẽ làm rò rỉ các giả định qua ranh giới đó theo cách mà trình biên dịch không hề cảnh báo.

Các lập trình viên thường chọn con đường ít trở ngại nhất tại thời điểm viết code. Nếu pageWidth tình cờ nằm trong một phạm vi bên ngoài, họ sẽ đọc nó từ đó thay vì thay đổi chữ ký hàm (function signature). Một closure sẽ che giấu sự phụ thuộc. Mã nguồn hoạt động trong lần chạy đầu tiên, vượt qua bộ kiểm thử và được triển khai. Vài tuần sau, ai đó di chuyển hàm đó sang một tệp khác để tái sử dụng hoặc để cải thiện khả năng đọc. Phạm vi cha biến mất. Mã nguồn bị lỗi, và lỗi này trông giống như một lỗi hồi quy (regression) mới mặc dù nguyên nhân gốc rễ là sự phụ thuộc ẩn ban đầu.

Web Workers xóa sạch dấu vết

Vấn đề này trở nên thực sự tồi tệ khi Web Workers tham gia vào kiến trúc. Khi một lỗi xảy ra bên trong một worker, trình duyệt sẽ loại bỏ những thông tin mà bạn cần nhất.

Đây là những gì thực sự xảy ra. Bên trong một worker, một ngoại lệ không được bắt (uncaught exception) sẽ kích hoạt một ErrorEvent. Nếu worker chuyển tiếp lỗi đó đến luồng chính (main thread), mô hình thông thường là lấy chuỗi message và gửi nó qua ranh giới. Luồng chính nhận chuỗi đó, tạo một đối tượng Error mới từ nó, rồi log hoặc ném lại lỗi. Những gì xuất hiện trong DevTools là lỗi đã được tái cấu trúc bên trong trình xử lý tin nhắn của luồng chính. Tên tệp gốc, số dòng và stack trace đều bị loại bỏ. Vị trí thực sự của lỗi trở nên vô hình.

Vì vậy, khi pageWidth bị thiếu gây ra lỗi ReferenceError bên trong worker, luồng chính chỉ báo cáo văn bản "pageWidth is not defined" tại nơi tin nhắn được xử lý. Hàm cấp module thực sự nằm ở