前端可访问性落地:别把 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>。我见过很多项目用按钮做页面跳转,结果用户无法右键复制链接,也不能在新标签打开;也见过用链接触发删除操作,读屏语义和实际行为完全不一致。
语义错了,后面的可访问性补丁都会很别扭。
键盘操作要按用户预期
常见交互至少要支持键盘:
- 按钮:
Enter和Space可触发 - 链接:
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-root 加 inert,焦点陷阱和背景屏蔽就都齐了。这也是为什么我更推荐直接用成熟组件库的 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 顺序终于没有再跑出弹窗。
验收的时候我发现,键盘走查这件事不需要多复杂的清单——可点击元素能不能聚焦、焦点样式看不看得见、弹窗关闭后焦点回没回去、错误信息和字段有没有关联,走一遍就知道哪里漏了。难的不是记住这几条,是每次改完组件都顺手走一遍,尤其是那些“看起来鼠标点一下就好”的地方,容易被漏掉。这个弹窗改完之后,团队里其他几个类似的弹窗也照着这套顺序过了一遍,倒是又揪出两处焦点没归位的问题。