semantic HTML、accessibility tree 跟 ARIA
一個你大概寫過的按鈕
你接到一個需求要做一個「送出」按鈕。設計稿給的是一個圓角方塊。最直覺的寫法可能是這樣:
<div class="btn" onclick="submitForm()">送出</div>.btn {
background: #4a90e2;
color: white;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
}看起來、點起來都跟一般按鈕沒兩樣。但如果你打開 Chrome DevTools 的 Lighthouse 跑一下 accessibility 分數,會看到紅字:
Buttons must have discernible text
Interactive elements must be keyboard accessible「奇怪,我明明就有寫『送出』兩個字,怎麼會說我沒文字?」
然後你又試試看用鍵盤的 Tab 鍵能不能跳到這個 div —— 跳不過去。用 Enter 鍵能不能觸發 —— 不行。
這個 div 在『視覺上』是按鈕,但在『瀏覽器眼中』不是按鈕。
等等,瀏覽器其實偷偷做了一件事
這就要揭露一個很多前端工程師不知道的事實:瀏覽器拿到你的 HTML 之後,其實默默建了兩棵樹,不是一棵。
第一棵是大家都熟的 DOM tree,用來決定怎麼把畫面渲染出來。
第二棵叫 accessibility tree(無障礙樹),用來告訴螢幕閱讀器(screen reader)、語音控制軟體、其他輔助科技(assistive technology, 簡稱 AT):「這個頁面長什麼樣、有哪些可以操作的東西、它們叫什麼名字」。
你看不到第二棵樹,因為它不會渲染。但視障使用者用的螢幕閱讀器看到的就是它。
當你寫 <div>送出</div> 的時候,accessibility tree 裡那一個節點長這樣:
[generic] "送出"generic 的意思是「就是個普通文字容器」。螢幕閱讀器掃過去只會唸「送出」兩個字,不會告訴使用者這是可以按的。鍵盤也不會把它列入「可以 Tab 過去」的清單。
但如果你寫 <button>送出</button>,accessibility tree 裡會變成:
[button] "送出" (focusable, clickable)螢幕閱讀器會唸「送出,按鈕」,使用者立刻知道這個能按。Tab 鍵會自動把焦點跳過來。按 Enter 或空白鍵會觸發 click 事件。這些全部都不用你寫,瀏覽器全包了。
心智模型:把它想成「兩棵樹」
💡 記憶錨點:DOM tree 是給眼睛看的,accessibility tree 是給耳朵聽的。
整個故事可以濃縮成這張圖:
你寫的 HTML
│
├──→ DOM tree ──→ 瀏覽器渲染畫面 ──→ 視覺使用者看到
│
└──→ Accessibility tree ──→ 螢幕閱讀器 / 語音控制 ──→ 視障 / 行動不便使用者聽到而我們今天要講的三個名詞,各自的角色是:
- Semantic HTML:用對的 tag(
<button>、<nav>、<header>、<input>……),瀏覽器會幫你把兩棵樹解析好 - Accessibility tree:那棵你看不到但真實存在的樹。你可以在 Chrome DevTools 的 Elements 面板裡,切到 Accessibility 分頁看到它。
- ARIA(Accessible Rich Internet Applications):當 semantic HTML 不夠用的時候,用來手動修補 accessibility tree 的補丁工具。
下面三節我們分別拆開講。
拆解一:Semantic HTML —— 為什麼 <button> 不只是長得像按鈕
「Semantic」翻成中文是「語意」。Semantic HTML 的意思就是「用能表達意義的 tag」,而不是把所有東西都包成 <div> 跟 <span>。
光把 <div> 換成 <button>,你就會免費拿到下面這些東西:
<!-- 你寫的 -->
<button onclick="submitForm()">送出</button>
<!-- 瀏覽器自動幫你做的事: -->
<!-- 1. accessibility tree 標記為 role="button" -->
<!-- 2. 可以用 Tab 鍵 focus -->
<!-- 3. focus 時有預設的 outline(你不喜歡可以改,但別拿掉) -->
<!-- 4. 按 Enter 跟空白鍵會觸發 click -->
<!-- 5. 螢幕閱讀器會唸「送出,按鈕」 -->
<!-- 6. 包在 <form> 裡時,按 Enter 會自動送出表單 -->再看一個例子。比較這兩段:
<!-- 寫法 A:div code exmaple -->
<div class="header">
<div class="logo">我的網站</div>
<div class="menu">
<div class="menu-item"><a href="/">首頁</a></div>
<div class="menu-item"><a href="/about">關於</a></div>
</div>
</div>
<!-- 寫法 B:semantic HTML -->
<header>
<h1>我的網站</h1>
<nav>
<ul>
<li><a href="/">首頁</a></li>
<li><a href="/about">關於</a></li>
</ul>
</nav>
</header>視覺上你可以把它們刻得一模一樣。但對於用螢幕閱讀器的使用者,寫法 B 多了一個超實用的功能:跳轉地標(landmark navigation)。螢幕閱讀器有個快捷鍵可以列出頁面上所有的 <nav>、<header>、<main>、<footer>,使用者可以直接跳到他要的區塊。
寫法 A 全部是 div,全部都是「generic」節點,使用者只能從頭聽到尾。
💡 記住這個:每次你正要寫
<div>的時候,先停一秒想:「這個東西在語意上是什麼?」如果是按鈕就用<button>,是導覽就用<nav>,是清單就用<ul>,是表單欄位就用<input>配<label>。
拆解二:Accessibility Tree —— 用 DevTools 看見那棵隱形的樹
光講太抽象,我們動手看一下這棵樹長什麼樣。
打開 Chrome,按 F12 打開 DevTools,切到 Elements 面板。在 Elements 面板的右側分頁列裡,找到 Accessibility 這個分頁(如果沒看到,點 >> 展開更多分頁)。
點選頁面上的任何一個元素,右邊 Accessibility 面板會顯示這個元素在 accessibility tree 裡長什麼樣。例如點到一個 <button>送出</button>,你會看到:
Computed Properties
Name: "送出"
Role: button
Focusable: true
Keyboard Focusable: true
Editable: false
...這四個欄位是最重要的:
- Name:螢幕閱讀器會唸出來的「名字」。
<button>的 name 預設來自它的 text content。 - Role:這個元素是什麼角色(button、link、heading、textbox……)。
<button>的 role 預設就是button。 - Focusable:能不能被 focus(被 Tab 鍵選到)。
- Properties / States:其他狀態,例如 disabled、checked、expanded 之類的。
現在你回頭點剛才那個 <div onclick>送出</div>,會看到:
Computed Properties
Name: ""
Role: generic
Focusable: false
...Name 空的、Role 是 generic、Focusable 是 false。對螢幕閱讀器來說,這個東西就跟一塊純文字沒兩樣。
💡 養成一個習慣:當你做了任何「自己刻的元件」(custom dropdown、custom modal、custom tab),開 Accessibility 面板看一下它的 Name 跟 Role。如果 Name 是空的、或 Role 是 generic —— 就知道事情大條了。
拆解三:ARIA —— 補丁工具,不是萬靈丹
OK,那如果有些東西就是沒有對應的 semantic tag 怎麼辦?例如:
- 一個「只有 icon、沒有文字」的按鈕(垃圾桶圖示)
- 一個自己刻的 dropdown menu
- 一個 tab 切換介面
- 一個可以展開/收合的 accordion
這時候 ARIA 才登場。ARIA 提供一組 attribute,讓你手動告訴 accessibility tree:「這個元素的 role 是什麼、name 是什麼、目前是什麼狀態」。
最常用的三類:
第一類:role="..." —— 告訴瀏覽器這個元素「應該被當作」什麼。
<!-- 自己刻的 tab 元件 -->
<div role="tablist">
<div role="tab" aria-selected="true">一般設定</div>
<div role="tab" aria-selected="false">進階設定</div>
</div>第二類:aria-label / aria-labelledby —— 告訴螢幕閱讀器這個元素叫什麼名字。
<!-- 只有 icon、沒有文字的按鈕 -->
<button aria-label="刪除這筆資料">
<svg><!-- 垃圾桶 icon --></svg>
</button>如果沒有 aria-label,螢幕閱讀器只能唸「按鈕」,使用者不知道按下去會發生什麼事。加上之後會唸「刪除這筆資料,按鈕」。
第三類:aria-expanded / aria-checked / aria-disabled …… —— 告訴螢幕閱讀器目前的狀態。
<!-- accordion 的標頭 -->
<button aria-expanded="false" aria-controls="panel-1">
常見問題
</button>
<div id="panel-1" hidden>
...答案內容...
</div>當使用者展開的時候,要記得用 JS 把 aria-expanded 改成 "true",並把 hidden 拿掉。螢幕閱讀器會唸「常見問題,已展開,按鈕」。
但是!ARIA 的第一條規則是:別用 ARIA
這聽起來很矛盾,但這真的是 W3C 官方文件第一條寫的:
The first rule of ARIA use: If you can use a native HTML element with the semantics and behavior you require already built in, instead of repurposing an element and adding an ARIA role, state or property to make it accessible, then do so.
翻譯成白話:能用原生 HTML 解決的就用原生 HTML,不要硬上 ARIA。
為什麼?因為 ARIA 只改 accessibility tree,完全不改瀏覽器的實際行為。
舉個慘案:
<div role="button" onclick="submitForm()">送出</div>你加了 role="button",螢幕閱讀器確實會唸「送出,按鈕」。看起來搞定了,對吧?
不對。 這個 div 還是:
- 不能用 Tab focus(要再加
tabindex="0") - 按 Enter 不會觸發 click(要自己寫 keydown handler)
- 按空白鍵也不會觸發 click(同上)
- 放在
<form>裡按 Enter 不會送出表單(這個沒得救)
完整修好它要這樣:
<div
role="button"
tabindex="0"
onclick="submitForm()"
onkeydown="if(event.key === 'Enter' || event.key === ' ') submitForm()"
>
送出
</div>看到那一長串你還想用 div 嗎?直接 <button>送出</button> 不就好了。
💡 記住這個:ARIA 只能修補「螢幕閱讀器聽到什麼」,修補不了「鍵盤能不能用」、「Enter 鍵會不會觸發」這類行為。這些行為只有原生 semantic HTML 自帶。