前端可访问性落地:别把 aria 当万能补丁

把鼠标从桌上拿开,只用键盘走一遍后台的“新建订单”弹窗,是我最近给团队定的一条验收动作。结果第一个弹窗就露馅了:Tab 键按下去,焦点没停在弹窗里的输入框,而是径直跑到了弹窗背后的侧边导航上,再按一下回车,误触了导航里的“退出登录”。功能没坏,鼠标点起来一切正常,可对一个只能用键盘的用户来说,这个弹窗基本不可用。

可访问性经常被当成“给少数用户做的额外优化”,但从这类现象往回看,它更像一套基础质量要求。按钮能不能用键盘触发、弹窗打开后焦点有没有跑丢、表单错误能不能被读屏软件感知、图片加载失败有没有替代信息——这些问题不只影响读屏用户,也影响键盘用户、临时受伤只能用一只手的人、移动端用户,甚至影响能不能写自动化测试。判断一个组件做得好不好,最省事的办法就是上面那条:收起鼠标,只用键盘走一遍完整交互,看焦点走到哪、出错提示能不能被感知。

这类问题的根子不在于漏写了几个 aria-*,而在于组件从设计之初就没有按正确的语义和交互模型搭建。可访问性补丁能修表面症状,修不了骨架问题。

语义化优先于 aria

如果一个元素本来就是按钮,就用 <button>

1<button type="button">保存</button>

不要为了样式写成:

1<div class="button" onclick="save()">保存</div>

后者看起来也能点,但它默认不能被键盘聚焦,也没有按钮语义,回车和空格触发行为还要自己补。最后你会加一堆:

1<div role="button" tabindex="0" aria-label="保存">保存</div>

这不是更专业,而是绕远路。原生元素自带语义、键盘行为和浏览器兼容性,能用原生就先用原生。

链接也一样。跳转用 <a href="">,动作才用 <button>。我见过很多项目用按钮做页面跳转,结果用户无法右键复制链接,也不能在新标签打开;也见过用链接触发删除操作,读屏语义和实际行为完全不一致。

语义错了,后面的可访问性补丁都会很别扭。

键盘操作要按用户预期

常见交互至少要支持键盘:

  • 按钮:EnterSpace 可触发
  • 链接:Enter 可打开
  • 弹窗:打开后焦点进入弹窗,Esc 可关闭
  • 下拉菜单:方向键移动,Enter 选择,Esc 关闭
  • Tab 面板:方向键切换标签,Tab 进入面板内容

还有一个常被忽略的键盘细节是“跳过导航”。键盘用户每进一个页面,都要先 Tab 过一长串顶部导航才能到正文,很折磨。给页面开头放一个默认隐藏、聚焦时才出现的跳转链接,能省掉这段重复劳动:

1<a href="#main" class="skip-link">跳到主内容</a>
2...
3<main id="main">...</main>
1.skip-link {
2  position: absolute;
3  left: -9999px;
4}
5.skip-link:focus {
6  left: 16px;
7  top: 16px;
8}

注意别用 display: none 藏它——那样它连焦点都拿不到,Tab 也就到不了它。用移出视口的方式,聚焦时再拉回来。

最容易漏的是焦点管理。

弹窗打开时,焦点应该移动到弹窗内第一个可交互元素,或者移动到弹窗标题:

1function Dialog({ open, onClose }) {
2  const closeRef = useRef(null)
3
4  useEffect(() => {
5    if (open) {
6      closeRef.current?.focus()
7    }
8  }, [open])
9
10  if (!open) return null
11
12  return (
13    <div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
14      <h2 id="dialog-title">确认删除</h2>
15      <p>删除后无法恢复。</p>
16      <button ref={closeRef} type="button" onClick={onClose}>
17        取消
18      </button>
19    </div>
20  )
21}

关闭弹窗后,焦点还应该回到打开弹窗的那个按钮。否则键盘用户会迷失在页面里。

真实项目里我更建议使用成熟组件库的 Dialog,而不是自己从零写。因为完整弹窗还要处理焦点陷阱、背景不可操作、滚动锁定、嵌套弹窗、Esc 关闭和恢复焦点,要顾的细节很多。

表单错误要能被感知

表单错误不能只靠红色边框。

红色对色弱用户不可靠,读屏软件也不知道这个输入框为什么错。更好的做法是把错误文案和输入框关联起来:

1<label htmlFor="email">邮箱</label>
2<input
3  id="email"
4  name="email"
5  aria-invalid={Boolean(error)}
6  aria-describedby={error ? 'email-error' : undefined}
7/>
8{error ? (
9  <p id="email-error" role="alert">
10    {error}
11  </p>
12) : null}

aria-invalid 表示字段当前无效,aria-describedby 把错误说明关联到输入框。role="alert" 可以让动态出现的错误被读屏软件及时播报。

提交失败时,也不要只在页面顶部弹一个 toast。用户需要知道具体哪个字段错了,焦点最好移动到第一个错误字段:

1const firstErrorField = document.querySelector('[aria-invalid="true"]')
2firstErrorField?.focus()

这类细节对键盘用户特别重要。否则他按了提交,只听到“保存失败”,却不知道该去哪里改。

图片和图标要区分信息性和装饰性

不是所有图片都需要详细 alt。

信息性图片需要替代文本:

1<img src="/avatar.png" alt="张三的头像" />

装饰性图片应该用空 alt:

1<img src="/decor-line.png" alt="" />

如果装饰图没有空 alt,读屏软件可能会读文件名,体验很差。

图标按钮尤其要注意。只有图标没有文字时,要提供可访问名称:

1<button type="button" aria-label="关闭">
2  <XIcon />
3</button>

不要给图标本身乱加 aria-label,可点击的是按钮,名称应该在按钮上。

aria 只能补语义,不能改变行为

aria 最大的误区,是以为写了属性就等于组件可用了。

1<div role="button">提交</div>

这只告诉辅助技术“它是按钮”,但不会自动让它支持键盘,不会自动有禁用状态,也不会自动处理焦点样式。行为还得你自己实现。

所以我的优先级是:

1原生语义元素 > 成熟无障碍组件 > aria 补充语义 > 自己模拟复杂控件

能不用 aria 时就不用。需要 aria 时,也要先查对应模式,不要凭感觉写。

标题层级和地标是读屏用户的目录

读屏用户浏览页面,不是从上到下一个字一个字听的。他们更常用的是“按标题跳转”“按地标跳转”,就像我们用眼睛扫一眼页面结构。这套导航能不能用,取决于页面的标题层级和地标区域标得对不对。

标题层级要连续、不跳级。一个页面应该只有一个 <h1>,下面才是 <h2><h3>,不能因为某个字号看起来合适就跳过 <h2> 直接写 <h3>。字号是视觉问题,用 CSS 解决;标题层级是结构问题,用标签解决。这两件事混在一起,是很多页面标题乱套的根源。

地标区域指的是用语义标签划分出的功能区,读屏软件能识别并快速跳转:

1<header>站点头部</header>
2<nav aria-label="主导航">导航</nav>
3<main>
4  <h1>订单列表</h1>
5  <section aria-labelledby="filter-title">
6    <h2 id="filter-title">筛选条件</h2>
7  </section>
8</main>
9<footer>页脚</footer>

<main><nav><header><footer> 这些语义标签,而不是清一色的 <div class="main">。如果页面里有多个同类地标,比如两个 <nav>,就用 aria-label 把它们区分开(“主导航”“分页导航”),否则读屏软件念出来都是“navigation”,用户分不清。这又回到那条原则:能用原生语义标签,就别用 <div> 加 aria 硬凑。

焦点陷阱和背景隔离要一起做

回到开头那个焦点跑到导航上的弹窗。它缺的其实是两件事:焦点没被“困”在弹窗里,背景也没被挡住——键盘和读屏软件都还能摸到弹窗背后的内容。这两件事要一起做才算完整。

焦点陷阱的意思是,弹窗打开时 Tab 到最后一个可聚焦元素后,再按 Tab 应该回到第一个,而不是跳出弹窗。自己实现要监听 keydown,算出弹窗内可聚焦元素的首尾,在边界处拦截:

1function trapFocus(container: HTMLElement, e: KeyboardEvent) {
2  if (e.key !== 'Tab') return
3  const focusable = container.querySelectorAll<HTMLElement>(
4    'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
5  )
6  const first = focusable[0]
7  const last = focusable[focusable.length - 1]
8  if (e.shiftKey && document.activeElement === first) {
9    last.focus()
10    e.preventDefault()
11  } else if (!e.shiftKey && document.activeElement === last) {
12    first.focus()
13    e.preventDefault()
14  }
15}

背景那头是另一件事:弹窗打开时,背后的内容不该能被 Tab 到,读屏软件也不该念它。以前常用的做法是给背景手动加 aria-hidden="true",但容易漏、容易忘记还原。现在更省心的是 inert 属性——它会让整棵子树既不可聚焦也不被辅助技术感知。Chrome 从 102 起支持,Safari 也在 15.5 跟上了,我在内部后台已经开始用,只是老浏览器还要留个 polyfill 接住:

1useEffect(() => {
2  const root = document.getElementById('app-root')
3  if (open) root?.setAttribute('inert', '')
4  else root?.removeAttribute('inert')
5  return () => root?.removeAttribute('inert')
6}, [open])

把弹窗放到 #app-root 之外的一个 portal 节点里,打开时给 #app-rootinert,焦点陷阱和背景屏蔽就都齐了。这也是为什么我更推荐直接用成熟组件库的 Dialog:焦点陷阱、inert 屏蔽、滚动锁定、Esc 关闭、关闭后恢复焦点,这些细节它们大多已经踩平了。

颜色对比和自动化检查能兜住一部分

前面这些靠人肉走查,容易累也容易漏。有一类问题适合交给工具先兜一遍,最典型的是颜色对比度。

正文文字和背景的对比度,WCAG AA 要求普通文本至少 4.5:1,大号文本至少 3:1。设计稿里那些“看起来还行”的浅灰文字,一测经常不达标。这条不用靠肉眼猜,Chrome DevTools 选中元素后,Styles 面板里的颜色小方块会直接标出对比度和是否通过 AA/AAA。

更系统的做法是把可访问性检查接进流程。axe-core 是这块的事实标准,它能扫出缺失的 alt、没关联的 label、对比度不足、错误的 aria 用法等一批问题。开发时可以挂一个运行时版本:

1if (process.env.NODE_ENV === 'development') {
2  import('@axe-core/react').then(({ default: axe }) => {
3    axe(React, ReactDOM, 1000)
4  })
5}

它会把命中的问题打到控制台,附上是哪个节点、违反了哪条规则。CI 里也可以让 Playwright 配合 axe-core 在关键页面跑一遍,把明显的回归拦在合并之前。

要提醒的是,自动化工具只能覆盖一部分——它查得出“图片没有 alt”,查不出“这个 alt 写得对不对”;查得出对比度数值,查不出焦点顺序是否符合阅读逻辑。语义是否合理、键盘走一遍顺不顺,最终还是得人来判断。工具负责兜住机械性的疏漏,剩下的靠前面那条“收起鼠标走一遍”。

回到那个“新建订单”弹窗

开头那个误触“退出登录”的弹窗,后来我们按这篇里的思路重做了一遍:把 <div role="button"> 换成原生 <button>,弹窗打开时焦点移到取消按钮上,关闭后再还给触发它的那个按钮,背景加上 inert,错误提示用 aria-describedby 挂到输入框上。改完再收起鼠标走一遍,Tab 顺序终于没有再跑出弹窗。

验收的时候我发现,键盘走查这件事不需要多复杂的清单——可点击元素能不能聚焦、焦点样式看不看得见、弹窗关闭后焦点回没回去、错误信息和字段有没有关联,走一遍就知道哪里漏了。难的不是记住这几条,是每次改完组件都顺手走一遍,尤其是那些“看起来鼠标点一下就好”的地方,容易被漏掉。这个弹窗改完之后,团队里其他几个类似的弹窗也照着这套顺序过了一遍,倒是又揪出两处焦点没归位的问题。