How Browsers Work · Part 18 — Accessibility Tree Internals
Accessibility tree được dẫn xuất từ DOM thế nào: role/name/state, thuật toán tính tên, ignored/inert nodes, API nền tảng và cách kiểm tra bằng DevTools/CDP.
Ta đã theo DOM qua render và compositing. Còn một biểu diễn ngữ nghĩa developer ít khi thấy: accessibility tree (cây a11y). Đây là cách trình duyệt mô tả trang cho công nghệ hỗ trợ như screen reader và công cụ điều khiển bằng giọng nói. Hiểu nó biến accessibility từ “đoán mò ARIA” thành mô hình rõ ràng.
1. Một cây ngữ nghĩa dẫn xuất từ DOM
Bạn đã biết DOM và các cấu trúc render/compositor. Accessibility tree là một biểu diễn khác, dẫn xuất từ DOM cùng style/semantics liên quan để phục vụ assistive technology (AT). Nó không phải bản sao DOM và cũng không có một thứ tự “cây thứ ba” cố định trong mọi engine.
HTML ─► DOM tree ──► Render tree ─► Layout ─► Paint (cho mắt)
│
└──────► Accessibility tree ─► platform API ─► screen reader (cho AT)
Mỗi node a11y chứa bộ thuộc tính ngữ nghĩa, quan trọng nhất là role, name, state/value và quan hệ cha–con.
2. Role, name, state — bộ ba ngữ nghĩa
| Thuộc tính | Nghĩa | Nguồn |
|---|---|---|
| Role | ”Đây là cái gì” (button, link, heading…) | thẻ HTML ngầm (<button>) hoặc role= |
| Name | ”Tên gọi để đọc lên” | thuật toán tính tên (mục 3) |
| State/Value | checked, expanded, disabled, value… | thuộc tính HTML + ARIA |
HTML ngữ nghĩa cho cả ba miễn phí: <button>Lưu</button> có role=button, name=“Lưu”, và state khi disabled. ARIA chỉ nên dùng khi không có phần tử HTML phù hợp — “No ARIA is better than bad ARIA”.
<!-- ✅ HTML ngữ nghĩa: role + name + state tự có -->
<button disabled>Lưu</button>
<!-- 🟡 phải tự cấp mọi thứ, dễ sai -->
<div role="button" tabindex="0" aria-disabled="true">Lưu</div>
3. Thuật toán tính tên (accessible name computation)
“Name” không hiển nhiên — user agent chạy thuật toán Accessible Name and Description Computation (ACCName). Thứ tự cụ thể phụ thuộc role và host language; mental model thực dụng cho nhiều control HTML là:
Thứ tự ưu tiên (đơn giản hoá):
1. aria-labelledby (trỏ tới id phần tử khác → dùng text của nó)
2. aria-label (chuỗi trực tiếp)
3. nguồn HTML theo từng element: <label>, alt, value, legend/caption…
4. text bên trong nếu role cho phép name-from-content (như <button>)
5. title (fallback yếu)
→ nếu rỗng: phần tử "không có tên" (screen reader đọc chung chung)
<!-- aria-labelledby thắng mọi thứ khác -->
<button aria-label="Đóng" aria-labelledby="t1">
<span id="t1">Huỷ</span> <!-- name = "Huỷ" (labelledby thắng) -->
</button>
<!-- input lấy name từ label liên kết -->
<label for="email">Email</label>
<input id="email" type="email"> <!-- name = "Email" -->
Cạm bẫy:
aria-label/aria-labelledbycó ưu tiên cao hơn text nhìn thấy.<button aria-label="Đóng">Huỷ</button>có accessible name “Đóng”; người dùng điều khiển giọng nói thấy “Huỷ” nhưng lệnh theo chữ nhìn thấy có thể không khớp. Giữ visible label nằm trong accessible name.
4. Cây bị cắt tỉa — vì sao thiếu node
Accessibility tree không ánh xạ 1–1 với DOM. Trình duyệt cắt tỉa:
display:none/visibility:hidden→ loại khỏi cây a11y.aria-hidden="true"→ loại bỏ node và con khỏi a11y (nhưng vẫn hiển thị cho mắt!).- Node thuần trình bày (div bao bọc) có thể bị ignore; con của nó được nối lên ancestor gần nhất thay vì mất theo.
inert→ subtree thường không focus, không hit-test và không được expose qua accessibility API.
DOM Accessibility tree
<header> header
<div class="wrap"> (gộp, bỏ wrapper)
<button>Menu</button> button "Menu"
<span aria-hidden> ✕ (loại bỏ)
Hệ quả thực tế: không đặt aria-hidden="true" lên phần tử focusable hoặc ancestor của nó; hành vi phục hồi/cảnh báo khác nhau theo engine và có thể tạo “focus mồ côi”. Khi vô hiệu hoá cả một vùng (ví dụ nền phía sau modal), inert đồng bộ focus, hit testing và a11y tốt hơn — đồng thời phải bảo đảm UI thị giác cũng thể hiện vùng đó không dùng được.
5. Screen reader truy vấn thế nào
Trình duyệt không tự “đọc” nội dung — nó xuất accessibility tree qua API nền tảng, rồi assistive technology truy vấn qua đó:
Renderer (a11y tree)
│ ánh xạ sang API hệ điều hành
▼
Windows: UI Automation (UIA), MSAA/IAccessible2
macOS: NSAccessibility (AX)
Linux: AT-SPI
│
▼
AT (NVDA / JAWS / VoiceOver / Orca...) ─► giọng nói / chữ nổi / điều khiển
Dựng và duy trì dữ liệu a11y có chi phí. Chromium bật full accessibility support on demand khi phát hiện AT hoặc khi được yêu cầu (DevTools/CDP có thể là tác nhân). Sau khi bật, renderer phải đẩy update để browser-process cache luôn nhất quán; phần frontend của DevTools chỉ fetch nhánh cây khi bạn mở rộng. “Lazy” vì thế không có nghĩa thay đổi DOM bị bỏ qua cho tới lúc screen reader hỏi.
6. Kiểm tra cây a11y
- DevTools → Elements → Accessibility pane: xem role/name computed và “Accessibility Tree” đầy đủ.
axe-core/ Lighthouse: bắt một lớp lỗi tự động (thiếu name, tương phản, quan hệ ARIA…).- Test thật bằng VoiceOver/NVDA cùng keyboard và zoom; automated checks không thể xác nhận luồng thao tác hay chất lượng nội dung.
// Puppeteer + Chrome DevTools Protocol (CDP)
const cdp = await page.createCDPSession();
await cdp.send('Accessibility.enable');
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
await cdp.send('Accessibility.disable');
getFullAXTree hiện được CDP đánh dấu experimental, nên pin phiên bản tool và ưu tiên assertion theo role/name cần thiết thay vì snapshot toàn cây dễ vỡ.
7. Tóm tắt
- Trình duyệt dựng accessibility tree song song DOM, mô tả trang cho AT bằng role / name / state.
- HTML ngữ nghĩa cho role+name+state miễn phí; ARIA chỉ dùng khi không có phần tử phù hợp.
- Accessible name phụ thuộc role/HTML mapping; thường ưu tiên
aria-labelledby→aria-label→ nguồn host language/name-from-content →title. - Cây a11y bị cắt tỉa:
display:none/aria-hidden/inertloại node; cẩn thậnaria-hiddentrên phần tử focus được (dùnginert). - Trình duyệt xuất cây qua API nền tảng (UIA/IA2/AX/AT-SPI); Chromium bật full support on demand nhưng phải duy trì update sau khi bật.
- Kiểm tra bằng Accessibility pane, axe-core/Lighthouse và AT thật; CDP full-tree API còn experimental.
Phần tiếp theo: Image & media decoding pipeline — ảnh đi từ byte tới pixel ra sao, decoding/Image.decode() khác gì nhau, và worker + ImageBitmap + OffscreenCanvas chia việc ở đâu.