- Nút More trên website BBC UK chỉ không xử lý được thao tác nhấp trong một số môi trường làm việc tại nhà nhất định; một lỗi UI trông có vẻ bình thường thực ra là vấn đề về hệ tọa độ đa màn hình
- Khi màn hình ngoài được đặt ở phía trên·bên trái màn hình chính,
screenX, screenY trong sự kiện click của Chrome và Firefox có thể trở thành số âm
- Mã hiện có xác định cú nhấp bằng con trỏ bằng điều kiện
event.screenX > 0 || event.screenY > 0, nên không xem các cú nhấp có tọa độ âm là nhấp chuột
- Cách sửa rất đơn giản: không kiểm tra
screenX, screenY có lớn hơn 0 hay không, mà kiểm tra có khác 0 hay không, thành dạng event.type === 'click' && (event.screenX!== 0 || event.screenY!== 0)
- Dù đã qua kiểm thử đơn vị, Puppeteer, kiểm thử thủ công và kiểm thử bằng công nghệ hỗ trợ, những lỗi như vậy vẫn có thể sót lại vì sự mơ hồ của đặc tả UI Events và giả định về tọa độ đa màn hình
Lỗi điều hướng BBC chỉ tái hiện trong môi trường cụ thể
- Thanh điều hướng của website BBC UK sẽ mở menu khi người dùng kích hoạt nút More
- Nút này dùng sự kiện
click, và sự kiện đó có thể phát sinh không chỉ từ chuột mà còn từ cảm ứng, phím Enter và Space trên bàn phím
- Một thành viên trong nhóm chỉ gặp vấn đề khi dùng laptop làm việc ở nhà; cùng chiếc laptop đó khi dùng ở văn phòng thì hoạt động bình thường
- Ngay cả ở nhà, lỗi chỉ xảy ra khi cửa sổ trình duyệt nằm trên màn hình ngoài; trên màn hình laptop thì nút hoạt động bình thường
- Khi xảy ra lỗi, thay vì handler JavaScript mở menu, menu được mở bằng hành vi fallback khi không có JavaScript
- Safari không gặp vấn đề tương tự
Điều kiện tái hiện là vị trí màn hình
- Nhóm đã thu hẹp điều kiện tái hiện bằng cách kiểm tra yếu tố nào trong môi trường ở nhà gây ra vấn đề
- Màn hình ngoài được bố trí ở phía trên màn hình laptop, và khi thay đổi cách bố trí này trong phần cài đặt OS thì lỗi dừng lại
- Một thành viên khác trong nhóm cũng có thể tái hiện lỗi khi chỉnh cách bố trí màn hình trong OS theo cùng cách
- Hai điều kiện được xác nhận ở giai đoạn đầu điều tra là:
- Safari không xảy ra lỗi
- Lỗi xảy ra khi màn hình ngoài nằm ở phía trên và bên trái màn hình chính
Tọa độ âm của screenX, screenY
- Khi kiểm tra sự kiện
click của nút More bằng console.log, các giá trị screenX, screenY trong Chrome và Firefox hiện ra là số âm
- Dù được phát sinh bởi đầu vào nào, sự kiện
click cũng là một loại PointerEvent, nên đối tượng sự kiện chứa thông tin về con trỏ chuột hoặc cảm ứng đã tạo ra cú nhấp
screenX, screenY biểu thị tọa độ của điểm được nhấp trên màn hình, theo đơn vị pixel
- Trong DOM UI Events spec, không thấy thông tin cụ thể về việc các thuộc tính này có thể là số âm hay không
- Khác biệt giữa Safari và Chrome·Firefox cho thấy trong cấu hình đa màn hình, từng trình duyệt có thể có cách biểu diễn tọa độ màn hình khác nhau
- Vấn đề tương tác liên thông này đã được báo cáo cho nhóm WebKit
Khác biệt giữa các trình duyệt trong cách tính tọa độ đa màn hình
- Trong cấu hình đa màn hình, hệ tọa độ màn hình của trình duyệt coi nhiều màn hình như một màn hình lớn duy nhất
- Nếu có 2 màn hình 800px đặt ngang nhau, phạm vi tọa độ x có thể từ 0 đến 1600
- Trên Safari, phạm vi tọa độ dường như luôn là phạm vi dương bắt đầu từ màn hình ở góc trên bên trái nhất
- Trên Chrome và Firefox, tọa độ dường như được tính dựa trên màn hình chính, nên trên màn hình nằm phía trên hoặc bên trái màn hình chính có thể xuất hiện tọa độ âm
- Lỗi lần này chỉ xảy ra khi
screenX, screenY là số âm
Mã gây lỗi thực tế và cách sửa
isInvokedByMouse trong mã gây lỗi kiểm tra screenX, screenY có phải số dương hay không để xác định sự kiện click có phát sinh từ chuột hoặc con trỏ cảm ứng hay không
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;
const isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);
// ...
const toggleMenu = event => {
// ...
if (isInvokedByMouse(event) || isInvokedByKeyboard(event)) {
event.preventDefault();
// Do stuff to open the menu and move the focus...
}
};
- Mã này giả định rằng
screenX, screenY của sự kiện click phát sinh từ con trỏ sẽ là số dương
- Khi người dùng nhấp nút
More trên một màn hình có tọa độ màn hình âm, handler sự kiện không công nhận đó là cú nhấp, và rơi về hành vi mặc định của liên kết More
- Cách sửa là thay vì xem
screenX, screenY có lớn hơn 0 hay không, hãy kiểm tra có khác 0 hay không
const isInvokedByMouse = event =>
event.type === 'click' && (event.screenX !== 0 || event.screenY !== 0);
- Thay đổi này giúp cả những người dùng có bố trí đa màn hình đặc biệt cũng có thể dùng thanh điều hướng của website BBC
Vấn đề thiết kế còn lại và refactor tiếp theo
- Bản thân cách sửa thì đơn giản, nhưng trong mã vẫn còn những điểm kỳ lạ
- Không cần kiểm tra
click phát sinh từ chuột hay bàn phím, và handler sự kiện trở nên phức tạp vì còn xử lý cả sự kiện keydown
- Cần thận trọng với những giả định đặt lên hành vi API; việc đặc tả không nói rõ
screenX, screenY có thể là số âm hay không cũng đã che giấu vấn đề
- Mã này đã trải qua kiểm thử đơn vị, kiểm thử Puppeteer, kiểm thử thủ công trên nhiều trình duyệt·thiết bị·công cụ công nghệ hỗ trợ, nhưng lỗi vẫn không được phát hiện
- Theo bản sửa ngày 19/11/2024, component điều hướng sau đó đã được refactor, và handler sự kiện của nút
menu cũng thay đổi đáng kể
- Bài viết tiếp theo trả lời về cách refactor và các câu hỏi thường gặp: How I refactored the BBC navigation bar and a follow-up FAQ
1 bình luận
Ý kiến trên Hacker News
Bổ sung cho những ai chưa bấm xem cả báo cáo lỗi WebKit: một nhà phát triển WebKit đã hỏi BBC vì sao việc có thể phát hiện sự kiện đến từ bàn phím lại hữu ích, và tác giả trả lời rằng cần khả năng tương tác vì các trường hợp sử dụng liên quan đến khả năng tiếp cận.
Nút menu trên thanh điều hướng của website BBC tại Anh có hành vi hơi khác nhau khi mở bằng con trỏ và khi mở bằng bàn phím. Sự kiện click luôn mở menu, nhưng nếu mở bằng con trỏ thì focus chuyển tới container của menu, còn nếu mở bằng bàn phím thì focus chuyển tới liên kết đầu tiên trong menu mà không có hoạt ảnh mở menu. Sự kiện
clickđộc lập với thiết bị nên hữu ích khi tạo trải nghiệm cho người dùng bàn phím, và trên bàn phím nó chỉ được gọi bằng Space hoặc Enter. Nếu dùngkeydownthì phải tự kiểm tra xem có phải Space/Enter hay không.Nguồn: https://bugs.webkit.org/show_bug.cgi?id=281430
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;vàconst isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);, nhìn bề ngoài có vẻ như muốn phân loại sự kiện thành một trong hai loại: chuột hoặc bàn phím.Thực tế lại có bốn nhóm: là chuột nhưng không phải bàn phím, là bàn phím nhưng không phải chuột, cả hai, không cái nào. Như lỗi ban đầu, trường hợp “không cái nào” bị xử lý không phù hợp, và tôi cũng nghi ngờ liệu trường hợp “cả hai” có hoạt động đúng không. Mã nên chủ động xử lý việc trạng thái bàn phím và trạng thái chuột là hai boolean riêng biệt, hoặc được cấu trúc sao cho
eventSourcetrả về các nhóm loại trừ lẫn nhau như"keyboard","mouse","not sure".Tốt hơn là thiết kế component bám theo hành vi mặc định và hoạt động được cho cả hai trường hợp sử dụng. Với khả năng tiếp cận, không nên cố tỏ ra quá thông minh. Rốt cuộc nó trở thành một giải pháp gần như hack, và cách làm đó tất yếu sẽ hỏng hoặc gây tác dụng phụ. Lý do hiếm có handle tốt để xử lý khác đi trong ngữ cảnh khả năng tiếp cận là vì ngay từ đầu đây không phải lĩnh vực được thiết kế để xử lý khác nhau.
Đồng thời họ cũng muốn sự tiện lợi của việc chỉ bind vào một sự kiện.
clickcho phép làm điều này, nhưng vì không có cách biết sự kiện được tạo ra bởi click chuột hay nhập liệu bàn phím, nên trên Chrome họ dùng một heuristic không ổn định: nếu vị trí chuột làscreenX=0,screenY=0thì xem là click tại gốc tọa độ hoặc trigger từ bàn phím. Với kinh nghiệm từng làm dự án về khả năng tiếp cận, tôi thấy đây là một ý tưởng khá tệ, và nếu thấy trong PR thì tôi đã yêu cầu viết lại. Lý tưởng là các trình duyệt có cùng hành vi, nhưng vấn đề thật sự dường như là trongclickphát sinh từ bàn phím,screenXvàscreenYgần như không có ý nghĩa.Lý tưởng nhất là không phát ra
MouseEvent, mà có một sự kiện tổng quát hơn áp dụng được cho cả bàn phím lẫn chuột, chẳng hạn"trigger", và cung cấp thông tin về nguồn kích hoạt. Vì hiện chưa có trong đặc tả và cần giải pháp ngay, ổn định hơn nhiều và ít mang tính hack hơn là bind thêm vàokeydown, rồi nếuclickxảy ra cùng vớikeydowntrên cùng một phần tử thì coi đó là nhập liệu từ bàn phím.screenXvàscreenY, nhưng vẫn thắc mắc vì saoscreenXphải trả về tọa độ màn hình thực tế thay vì vị trí nội bộ của renderer hay vị trí trên trang đã render nhưlayerX,layerY.Nhu cầu của tác giả cũng có thể được đáp ứng bằng vị trí trong renderer, và không cần rò rỉ vị trí cửa sổ trình duyệt cho mọi website đã truy cập.
don’tlà lỗi đánh máy khiến nghĩa bị đảo ngược so với ý định.Ở đoạn “chỉ cần đổi
isInvokedByMousetừ kiểm trascreenXvàscreenYcó lớn hơn 0 hay không sang kiểm tra có khác 0 hay không”, tôi tò mò điều gì xảy ra nếu, dù cực hiếm, người dùng thật sự click chuột ở vị trí 0,0.Tôi không rành JS, nhưng kiểm tra
!= 0có thật sự là cách tốt nhất hoặc duy nhất không? Đọc lại thì có vẻ câu nói rằng event handler cũng xử lýkeydownnên khá phức tạp và về sau cần refactor thêm, nhưng hiện tại bản sửa này là đủ, đã phần nào đề cập đến điểm này.instanceof MouseEvent, nhưng cách đó cũng có cảm giác rủi ro hoặc giống hack.Tôi thắc mắc vì sao lại phải dựa vào heuristic như vậy. Có thể vì
toggleMenuđược dùng trong nhiều event handler, hoặc có lý do đặc thù khác của codebase. Nếu không biết toàn cảnh thì khó phán xét. Có vẻ câu trả lời nằm ở đây: https://news.ycombinator.com/item?id=42174177event.name == 'click'rồi. Vậy thì tôi không hiểu vì sao còn muốn lọc bỏ một số sự kiện click hợp lệ.Trước đây tôi từng dùng nó để chọn layout nào sẽ được hiển thị. Nếu chỉ muốn lắng nghe input cảm ứng thì làm vậy rồi gọi
preventDefaulttrên sự kiện để trình duyệt không tạo tiếp sự kiệnclick. Hoặc đơn giản là đỡ tốn công và viết một click handler là xong.Việc BBC đầu tư vào khả năng tiếp cận rồi phát hiện ra một lỗi khó chịu là điều đáng ghi nhận. Nhưng tại sao ngành này đến giờ vẫn chưa thể làm đúng một dropdown mở nhất quán cho mọi người dùng?
Khả năng tiếp cận khó đến vậy sao? Lẽ ra BBC nên dùng một web framework hay web component nào đó đã xử lý sẵn những việc như thế này? Là một lập trình viên full-stack thiên về backend, tôi khá thận trọng khi đụng tới các component trên trình duyệt. Hành vi có rất nhiều điểm tinh vi, và các triển khai đã được kiểm chứng trong thời gian dài. Ví dụ, tự làm một text box tùy biến mà không nghiên cứu sâu hành vi text box theo từng nền tảng thì rất dễ thất bại. Ngay cả trên các website của công ty lớn, tôi cũng thường thấy copy/paste bị hỏng và mất ký tự. Tôi không hiểu vì sao đến năm 2024 text box vẫn bị hỏng, và giờ React khiến tôi thấy thật ngạo mạn
Cá nhân tôi sẽ xử lý bằng template phía server, một CSS framework như Bulma, và lượng JS tối thiểu. Cách này không phù hợp với các site đòi hỏi branding tùy biến mượt mà, nhưng text box hoạt động tốt và chi phí phát triển cũng không quá cao. Tôi không chắc nó có đáp ứng tiêu chuẩn khả năng tiếp cận của BBC hay không
Một ví dụ thực tế là modal. Nếu không bị khiếm thị, bạn có thể thấy một hộp trắng nổi lên trên vùng xám “đừng đụng vào”, và bên trong có các UI component. Khi dùng screen reader, không có gì đảm bảo bạn nhận được thông tin đó. Khi tab qua các phần tử UI rồi quay lại đầu hộp, một screen reader cụ thể có báo điều đó không? Nó có liệt kê các phần tử tương tác khả dụng không? Nó có liệt kê theo cùng thứ tự với screen reader khác không? Trên điện thoại thì sao, trên Mac thì sao? Screen reader và trình duyệt có báo đúng các phần tử nhập liệu không, hay sẽ lặng lẽ cho phép người dùng thoát khỏi modal và quay lại phần còn lại của site?
Trong khả năng tiếp cận, bạn không thể tin rằng hệ điều hành, trình duyệt và screen reader sẽ phối hợp với nhau hoặc hành xử hợp lý trong đúng hoàn cảnh. Năm 2019, tôi đã phải báo một lỗi trên VoiceOver + Safari, trong đó CSS margin âm khiến screen reader đọc một khối văn bản RTL sai thứ tự. Về mặt hiển thị nó trông như
9/10/2019, nhưng trên screen reader lại nghe như “ten slash nine slash two-thousand-and-nineteen”, và cách xử lý tạm thời là đặt văn bản thànharia-hidden, rồi thêm một thẻpvô hình có thứ tự đúng. Vì vậy, khi thấy mã kỳ lạ liên quan đến khả năng tiếp cận, đôi khi thật sự không có cách nào tốt hơn. Ngay cả khi bạn lật tung codebase và đặt khả năng tiếp cận lên ưu tiên hàng đầu, nó vẫn có thể hỏng theo cách khó hiểu ngay khi JAWS hoặc VoiceOver cập nhậtNhìn chung thì ổn, nhưng file
reset.csstồn tại là có lý do, và ở đây có vẻ họ có thể đã dùng một cách tiếp cận cực đoan hơn để né hoàn toàn các vấn đề kiểu này. Tôi đang cố suy luận quyết định của họĐây trông giống một lỗi tự gây ra từ một heuristic sai. Họ giả định rằng giá trị
screenX/Ydương nghĩa là sự kiện chuột, và việc thiếu theo dõi/ghi log cũng làm quá trình điều tra phức tạp hơnThay vì kiểm tra thuộc tính phù hợp hơn là
pointerTypenhư các bình luận khác đề xuất, tôi hơi ngạc nhiên khi giải pháp của tác giả lại là chồng thêm heuristic lên một heuristic vốn đã lung lay. Kiểu như từ hai manh mối cuối cùng, họ kết luận rằng khi kiểm tra tọa độscreenXvàscreenYthì không chỉ cần kiểm tra số dương mà cả số âm nữapointerId === -1, rồi fallback sangscreenX === 0Vào khoảng 4 năm trước, khi đoạn mã này được viết lần đầu, không phải trình duyệt nào cũng dùng PointerEvent cho
clickNgay từ đầu tôi không hiểu tại sao website lại có thể lấy được vị trí chuột trong hệ tọa độ màn hình
window.screenX/window.screenY, và vị trí click cũng có thể được báo theo hệ tọa độ đó, nghe có vẻ vô lý trên desktopTOR Browser dường như giả mạo
screenXvàscreenYđể tránh fingerprinting. Tôi tò mò liệu có ai từng thấy use case tốt cho tính năng này chưa. Tôi chỉ nghĩ đến ứng dụng hai cửa sổ tương tác với nhau, hoặc site thay đổi hành vi tùy theo vị trí trên màn hình ảoVí dụ: https://youtu.be/3al8prbfK5o?si=loNtyqIfMFkppm5V
Tôi không hiểu tại sao không kiểm tra
event.typemà lại kiểm tra tọa độ. Dù vậy, bài viết đúng là một câu đố hay, và tôi đồng cảm với cảnh nhìn vào đoạn mã không phải do mình viết rồi tự hỏi “vì sao tọa độ click phải khác 0 thì mới quan trọng?”, “sao không chỉ kiểm traevent.targetcó phải là nút cần kích hoạt không?”, “có thể làm cùng việc bằng thẻdetails/summary, sao lại dùng JavaScript?”Ngay từ đầu tại sao lại lọc theo tọa độ màn hình? Nếu người dùng dùng thiết bị nhập liệu thay thế không có màn hình thì sao?
Chỉ riêng sự kiện
clickđã là tín hiệu đủ rằng người dùng muốn kích hoạt menu. Tôi không hiểu vì sao lại phát minh lại bánh xeisInvokedByMousekiểm tra xem tọa độscreenXhoặcscreenYcó dương không để xác định sự kiệnclickđược gọi bởi chuột hoặc con trỏ cảm ứng, chứ không phải bàn phímTức là họ cố phát hiện đó là kích hoạt bằng bàn phím hay bằng chuột, và tác giả đã giả định rằng tọa độ màn hình của sự kiện chuột sẽ luôn là số dương
Tôi đã đăng thêm một bài blog để giải thích bối cảnh mà mọi người thắc mắc và trả lời các câu hỏi. Bài viết giải thích vì sao ban đầu lại kiểm tra
screenX === 0, vì sao tôi muốn hành vi khác nhau tùy theo nhập liệu từ bàn phím và chuột, và đã refactor như thế nào để ngăn sự cố tương tự xảy ra thêmHy vọng sẽ hữu ích: https://www.joshtumath.uk/posts/2024-11-18-how-i-refactored-...
Cách đúng để xác định đó là click chuột hay click bằng bàn phím là gì? Có lẽ tôi sẽ muốn đặt một cờ ở mức module dựa trên sự kiện xảy ra gần nhất: nếu
mousedownlà sự kiện gần hơn thì đặtisKeyboard=false,isMouse=true, còn nếukeydowngần hơn thì đặt ngược lạiNhư vậy sẽ không cần các hàm
isInvokedByMousevàisInvokedByKeyboardnữa. Có cách nào tốt hơn không? Dựa vào tọa độ màn hình cho việc này thì trông rất đáng ngờ và giống một mẹo hackevent.detail[1] bằng 0 đối với “click” bằng bàn phím và bằng 1 đối với click bằng con trỏ1: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/det...
Rất thú vị, nhưng tôi không hiểu vì sao trình duyệt lại báo các tọa độ khác nhau tùy theo màn hình. Tôi từng nghĩ trình duyệt coi trang web như đang ở toàn màn hình, bất kể nó nằm trên màn hình nào
Có lý do gì để Web API cần có loại thông tin này không? Nó trông giống rủi ro về bảo mật, rò rỉ thông tin và theo dõi
Không phải là vấn đề năng lực phát triển sao? Đáng ra phải dùng tọa độ viewport chứ không phải tọa độ màn hình, và đọc bằng
.clientXcùng.clientY. Tôi không hiểu vì sao việc có giá trị âm trong không gian màn hình lại là bughttps://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_view/...