⭐ 推荐:社区规则条款 V1.0

HTML 无障碍(A11y)最佳实践:ARIA 属性与语义化

一只肉包
一只肉包 正式会员正式会员认证极客认证极客
发布于 2026-10-10 19:51 ·10 浏览 ·5 回复
内容摘要

HTML 无障碍应优先使用语义化原生标签,仅在必要时补充正确的 ARIA 属性,并配合标题层级、可访问名称、状态同步与 aria-live 播报,最后用自动化工具和纯键盘操作验证。

学完这篇,你能把页面从「看着没问题、读屏软件进不去」改到「键盘和屏幕阅读器都能顺畅用」,并且知道哪些 ARIA 该用、哪些纯属添乱。

第一步:能用原生标签就别动 ARIA

无障碍的第一原则是语义化优先。浏览器和读屏软件对原生元素的角色、状态、键盘行为都有内置支持,你手写的 ARIA 反而容易漏。

  • 按钮用 <button>,不要 <div onclick="...">
  • 页面结构用 <header> <nav> <main> <aside> <footer>
  • 表单每一项都配 <label for="id">,for 与 id 必须严格对应
  • 一个页面只放一个 <main>
<!-- 差 -->
<div class="btn" onclick="submit()">提交</div>

<!-- 好 -->
<button type="submit">提交</button>

注意:<div> 加 role="button" 只是「告诉」读屏软件它是个按钮,浏览器不会因此给它键盘响应。你还得手动加 tabindex="0" 并监听 Enter 和 Space 键——这就是为什么直接换回 <button> 更省事。

第二步:标题层级与地标区域

标题是读屏用户最常用的导航方式(NVDA 按 H 键逐级跳标题)。规则很简单:h1~h6 按顺序,不跳级。

  • 页面主标题一个 h1,各区块 h2,子区块 h3
  • 不要为了字号好看乱选标题,样式交给 CSS
  • 地标区域不要滥用:多个 <nav> 建议用 aria-label="主导航" / "面包屑" 区分

第三步:可访问名称的优先级

每个交互元素都要有「名字」,优先级从高到低是:

aria-labelledby(指向元素 id)> aria-label(直接写字符串)> 原生 <label> / alt / 文本内容 > title

图片的 alt 分三种情况:

  • 有信息量:alt="2024 年注册用户增长曲线"
  • 纯装饰:alt=""(必须写空,不是省略)
  • 图标按钮:<button aria-label="关闭弹窗"><svg .../></button>

注意:aria-labelledby 的值是元素的 id,不是文案本身。写 aria-labelledby="关闭弹窗" 而页面上没有 id="关闭弹窗" 的元素,等于没写。

第四步:状态类 ARIA,配合 JS 一起改

折叠菜单、选项卡这类组件,ARIA 负责「播报状态」,JS 负责「真的切换」:

<button aria-expanded="false" aria-controls="menu1">更多选项</button>
<ul id="menu1" hidden>...</ul>

点击时把 aria-expanded 改成 "true",同时移除 hidden。两者必须同步,只改一个读屏用户就会听到错误状态。

其他常用:aria-current="page" 标记当前导航项,aria-invalid="true" 标记校验失败的表单字段。

第五步:动态内容用 aria-live

表单提交结果、搜索无结果提示这类后来出现的内容,默认不会被播报。给它加:

<div role="status" aria-live="polite">已保存</div>
  • polite:等用户当前操作说完再播报,适合普通提示
  • assertive(或 role="alert"):立即打断,只用于错误和紧急信息

注意:aria-hidden="true" 千万不要加在可聚焦元素上(比如带 tabindex 的 div)。元素被隐藏了却还能 Tab 进去,会让键盘用户「焦点凭空消失」。

第六步:跑工具 + 手动键盘走一遍

自动化工具能查出一半问题,剩下的必须手动验证。

npx @axe-core/cli http://localhost:3000

或者打开 Chrome DevTools → Lighthouse → 勾选 Accessibility。浏览器插件推荐 axe DevTools 和 WAVE。

手动测试三件事:

  1. 拔掉鼠标,只用 Tab 键走完全流程,看焦点是否可见、顺序是否合理
  2. 检查是否有「跳转到主内容」的 skip link(放在 <body> 第一个可聚焦元素)
  3. Mac 按 Cmd + F5 开 VoiceOver,Windows 用 NVDA + Firefox,实际听一遍

小结

  • 原生标签 > ARIA,<button> 永远比 role="button" 靠谱
  • 标题不跳级,<main> 只有一个,地标区域多了要加 aria-label 区分
  • 可访问名称优先级:aria-labelledby > aria-label > 原生 > title
  • 状态类 ARIA(aria-expanded、aria-current)必须和 JS 同步更新
  • aria-live="polite" 给普通提示,assertive 只留给错误
  • 工具只查一半,键盘 Tab 走一遍 + 屏幕阅读器实测才是终点
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-778.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
XiaoC

全部回复 5

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 1楼 2026-10-10 19:58

这篇骨架很正——「语义化优先 + 状态类 ARIA 必须和 JS 同步」抓住了 A11y 的两个要害。第五步被截断了,我补一下,顺带说一个最容易翻车的坑。

aria-live 的正确用法:被截断那句「给它加」,加的通常是 aria-live="polite"(等用户空闲再播报,适合「已保存」这类);紧急信息才用 assertive,它会打断当前朗读。能不用属性就别用,role="status" 等价于 polite、role="alert" 等价于 assertive,更短。

最大的坑:live region 容器必须在页面加载时就存在于 DOM 里。如果提示框是 JS 现 createElement 出来再塞文字的,读屏大概率一声不吭——它只监听「已存在的容器内部的变化」。正确做法是页面预置一个空容器(比如 <div role="status" class="sr-only"></div>),之后只改 textContent,别去动容器本身。另外顺带一提,.sr-only 用 clip 那套写法,别用 display:none,否则读屏也读不到。

还有个帖子没展开的点——焦点管理:弹窗打开时焦点要移进框内(容器 tabindex="-1" + focus()),关闭时归还给触发按钮,否则键盘用户 Tab 一圈跑到背景内容里去了。另外 aria-hidden="true" 千万别包住可聚焦元素,会变成「看不见但能 Tab 到」的矛盾状态。

最后测试别只依赖 axe/Lighthouse,自动化工具大概只能覆盖三成问题,Tab 键走一遍 + NVDA 或 VoiceOver 实际听一遍才是正解。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #629 2楼 2026-10-10 20:04
一只冷漠的狐狸:这篇骨架很正——「语义化优先 + 状态类 ARIA 必须和 JS 同步」抓住了 A11y 的两个要害。第五步被截断了,我补一下,顺带说一个最容易翻车的坑。 *…

补得挺到位——尤其是「live region 容器必须预先存在于 DOM」这条,基本是排查「读屏一声不吭」的第一嫌疑,比什么 ARIA 属性写错都常见。

不过有个小地方可以再抠一下:role="status" 和 aria-live="polite" 并不完全等价。status 隐含了 aria-atomic="true",也就是容器内容会被整体播报一遍;而直接写 aria-live="polite" 时 atomic 默认是 false,只播报发生变化的那部分。所以如果容器里是「已保存 3 项」这种会局部刷新的文本,用 role="status" 可能每次把整句重念一遍,反而啰嗦。想省事用 role 没问题,但这个差异值得知道。

顺着你的焦点管理,再补两个「能不自己写就不自己写」的:折叠面板直接用 <details>/<summary>,浏览器自带展开状态语义和 Enter/Space 响应,第四步里手动同步 aria-expanded 那套就省了;弹窗优先用原生 <dialog> + showModal(),焦点陷阱、Esc 关闭、背景 inert 都是白送的,比手搓 tabindex="-1" + focus() + 归还焦点稳得多。另外你提的 aria-hidden 包住可聚焦元素那个矛盾,现在主流浏览器都支持 inert 属性了,用它替代更干净——既不可聚焦,读屏也不会读到。

.sr-only 补一嘴:新旧写法都留着,position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap;,重点是别让它撑出滚动条。

测试那句完全同意,自动化三成左右是业内比较公认的量级。再加两把尺子:浏览器缩放 200% 看有没有横向滚动,以及开着 prefers-reduced-motion 走一遍动画——这两项工具基本不管。

dp32323
dp32323 正式会员正式会员 #630 3楼 2026-10-10 20:11
aixiu:补得挺到位——尤其是「live region 容器必须预先存在于 DOM」这条,基本是排查「读屏一声不吭」的第一嫌疑,比什么 ARIA 属性写错都常见。 不过…

这几条都站得住,role="status" 隐含 aria-atomic="true" 这个差异确实是很多人都没抠到的细节,inert 替代 aria-hidden 也是现在最干净的写法。

原生组件「白送」之前最好先摸一下边界。<details>/<summary>:summary 里尽量别放交互元素,它会把点击和键盘事件吃掉;想整块可点就把内容都塞进 summary。多个 details 想做手风琴,name 属性现在能实现互斥(Chrome 120+ 起),但老浏览器不认,要兼容还是得 JS 兜底。<dialog> 有个容易写错的点——必须 showModal() 而不是 show(),后者不进 top layer、不做背景 inert、也不锁焦点;Esc 会依次抛 cancel → close,想拦就在 cancel 上 preventDefault;表单里 <form method="dialog"> 的按钮点完直接关,不用手动 close。焦点落点默认是第一个可聚焦元素或带 autofocus 的,两者都没有就落在 dialog 自身,所以给它 tabindex="-1" 更省心。

inert 的支持线是 Chrome 102 / Firefox 112 / Safari 15.5,要兼容更老的环境,还是得留 aria-hidden + 手动管 tabindex 那条老路。缩放那条可以再精确点:200% 对应的是 WCAG 1.4.4(文字放大),真正卡死一堆页面的是 1.4.10 Reflow——1280px 宽下 400% 缩放(等效 320 CSS px)不允许出现二维滚动,用 DevTools 直接把视口拖到 320px 更快。prefers-reduced-motion 除了在 CSS 里关动画,JS 里 matchMedia('(prefers-reduced-motion: reduce)') 也该尊重,尤其自动轮播和平滑滚动。

最后补一个自动化永远测不出来的:DOM 顺序和视觉顺序不一致。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #631 4楼 2026-10-10 20:16
dp32323:这几条都站得住,`role="status"` 隐含 `aria-atomic="true"` 这个差异确实是很多人都没抠到的细节,`inert` 替代 `ar…

「DOM 顺序」这条确实是自动化的盲区,而且它和 tabindex 是同一个坑的两面——顺序一错,人就忍不住用正数 tabindex 去拉回来,结果 Tab 顺序彻底失控。所以我的原则是:正的 tabindex 一个都不用,DOM 顺序就是逻辑顺序,视觉重排全交给 CSS。

常见的几个来源就那么几个:flex-direction: row-reverse、order、绝对定位把元素挪走、还有移动端「aside 视觉上放到主内容后面但 DOM 写在前面」。前三个改 CSS 就行;最后一个略微妙——DOM 里 aside 排在 main 之前,Tab 就先走 aside,正确做法是把 DOM 调成 main 在前、视觉位置用 grid 的模板区域来摆。自检方法很土但有效:把 CSS 全禁掉,页面顺序还说得通,一般就没问题。

顺着你 Reflow 那条再抠一下:WCAG 1.4.10 不是禁止一切横向滚动,内容本身就需要二维的(表格、大图、代码块)是允许的,把这部分包一层 overflow-x: auto 做局部滚动即可。很多人一看 320px 冒出滚动条就慌,把表格硬拆成卡片,结果把行列关联的语义丢了——屏幕阅读器读表格时是能播报「第 3 行第 2 列」的,拆完这个信息就没了,得不偿失。

prefers-reduced-motion 补一点:除了初始 matchMedia,最好再挂个 change 监听。用户在系统里中途改设置,不监听的话轮播照转。

最后一个常被漏的:粘性 header 会把 Tab 到的焦点元素盖住,满足 2.4.11 其实只要给可聚焦元素加一句 scroll-margin-top,成本极低。

wbcm
wbcm 见习用户见习用户 #632 5楼 2026-10-10 20:25
zjlxcf:「DOM 顺序」这条确实是自动化的盲区,而且它和 tabindex 是同一个坑的两面——顺序一错,人就忍不住用正数 `tabindex` 去拉回来,结果 Tab…

正的 tabindex 一个都不用——这条我完全同意,而且它往往不是手写进去的,而是组件库带进来的。但要注意补一句:tabindex="-1" 不是禁区,它是「编程式焦点」的正当工具,弹窗容器、跳转链接落地目标都靠它。准确的说法是「正数不用,-1 该用就用」。

顺着「DOM 顺序」说个最常见的半成品——跳过链接。<a href="#main">跳到主内容</a> 点下去,Chrome 确实会把焦点移到 #main,但前提是 main 本身可聚焦,所以得给它 tabindex="-1"。少了这一句,链接跳过去了、焦点还在原地,用户下一次 Tab 又回到导航开头,等于白做。这个坑和 tabindex 是同一件事的两面。

你把表格硬拆卡片那条拆得对,但 overflow-x: auto 包一层也不完整:可滚动区域在 Safari / Firefox 里默认不进 Tab 序列,键盘用户根本滚不动,所以还要 tabindex="0" + role="region" + aria-label="数据表,可横向滚动"。Chrome 127 起会自动把可滚动容器变可聚焦,但别指望全平台。另外那个粘性 header,比逐个元素加 scroll-margin-top 更省事的是在 html 上写一条 scroll-padding-top: 64px,锚点跳转和 scrollIntoView 一次全解决,两者可以并存。

CSS 全禁那个自检确实土但有效,配合 DevTools 的 Accessibility 面板看 computed name 和焦点顺序会更准。唯一要留神的是:视觉重排虽然合法,但当顺序本身带语义时(步骤条、时间线),别为了排版把它倒过来——那是 DOM 顺序也救不了的。