网页无障碍翻车现场,为啥用对了按钮却让屏幕阅读器懵圈?

2,529字
11–16 分钟
in

你有没有遇到过这种糟心事?辛辛苦苦写了个组件,键盘操作顺滑,ARIA属性加得满满当当,自动化测试工具一片绿,结果真有人用屏幕阅读器访问时,愣是不知道怎么触发它。前段时间我就干了这么件蠢事,一个看起来再正常不过的按钮,居然把屏幕阅读器用户给整不会了。

目录

起初为了给按钮整点不一样的样式,让它看起来像个链接,又懒得去改那套已经写好的样式类,就顺手给这个按钮加了个 role="link"。想着反正都是点一下,加个角色属性又不影响功能,没准儿还能让语义更清晰点。结果现实啪啪打脸,当自己戴上耳机,关掉显示器,纯靠键盘和NVDA操作时,才发现这个按钮的行为完全不在预期内。按空格键没反应,屏幕阅读器播报的角色也含含糊糊,既不像按钮也不像链接,整个一四不像。

这回踩坑让我彻底明白一件事:语义HTML自带的无障碍能力,比我们想象的要强大得多,而ARIA这东西,用好了是助攻,用不好就是添乱。很多新手(包括当时的我)容易把ARIA当成万能补丁,哪里不对劲就往上贴,结果把好好的原生语义给覆盖了,让浏览器和辅助技术都陷入迷茫。

语义HTML的真实力

先别急着加属性,看看原生<button>到底给了多少好东西。就下面这一行代码:

<button>保存修改</button>

别小看这行代码,浏览器和辅助技术联手给打包送了一套完整交互:

  • 键盘操作自动搞定Tab键聚焦,Enter键和空格键都能触发点击
  • 焦点行为正确:开发者不用写一行JavaScript,聚焦顺序和样式完全符合预期
  • 角色含义明确:屏幕阅读器直接告诉你这是个按钮
  • 播报一致性高:不同屏幕阅读器对按钮的解读方式基本一致

在加任何ARIA之前,先用原生标签把这个基础体验跑通。这时候啥额外代码都没写,无障碍体验就已经及格了。

别急着加属性

这个阶段最重要的就是管住手,先用原生标签把交互搭起来。像上面那个保存按钮,加个类名用来写样式就完事儿了:

<button class="cta">保存修改</button>

这时候打开浏览器的无障碍面板,能看到元素角色是button,可点击状态、焦点状态都一目了然。千万别在这个阶段就开始琢磨“哎呀要不要加个role”这种问题,先把原生行为观察透了再说。

测试只需要三步:

  1. 纯键盘操作:按Tab键能不能聚焦到?按Enter和空格能不能触发?
  2. 屏幕阅读器听听:它怎么播报这个控件?
  3. 看焦点顺序:按Tab键在页面里移动,顺序合不合理?

把这些摸清楚了,就得到了一个行为基准。万一后面加东西加出问题,能立刻定位到是后面加的代码搞的鬼。

好心办了坏事

问题恰恰出在想着“优化”一下的时候。当时设计稿要求某个按钮样式要像链接,又不想动已有的样式类,就想着用role属性来暗示一下角色:

<button class="cta" role="link">保存修改</button>

这么一改,表面上看啥问题没有。自动化工具还是静悄悄,样式也没崩。但真实用起来就露馅了:

  • 空格键失灵:原生按钮的空格激活功能被覆盖了
  • 角色播报混乱:屏幕阅读器先读按钮,又读链接,用户根本不知道这是个啥
  • 交互预期错位:用户以为是个链接,结果跳转逻辑没按链接走

问题出在哪儿?ARIA改的是语义,不是行为。给按钮加上role="link",只是在告诉屏幕阅读器“这东西是个链接”,但浏览器不会因此自动给这个元素加上链接的键盘行为。结果就尴尬了:屏幕阅读器说这是个链接,但按链接的操作方式(比如回车)可能不灵,按按钮的操作方式(空格)也被覆盖了。

<!-- 下面这行代码看着没毛病,其实埋了坑 -->
<button role="link">点我</button>
<!-- 空格键:我该不该工作? -->
<!-- 屏幕阅读器:这到底是个啥? -->

回归语义之路

解决方式简单到让人想撞墙:删掉那个画蛇添足的role属性。如果样式需求确实要区分,就用更纯粹的样式类来处理:

<button class="cta cta-alt">保存修改</button>

这样改完之后,所有问题都消失了。空格键恢复正常,屏幕阅读器播报清晰,用户反馈也确认问题解决。整个修复过程就是做减法,把不该加的ARIA删掉,让浏览器做它擅长的事。

给样式类命名的时候也得注意,别用button-link这种混淆语义的名字,直接用cta-altvariant-secondary这类纯粹描述视觉的命名就行。把样式和语义彻底解耦。

ARIA的正确打开方式

这次经历后,给自己定了个铁律:先用原生HTML表达意图,测试没问题了再考虑ARIA,而且ARIA只能用来补充状态,不能用来覆盖角色

举个例子,一个折叠面板需要告诉屏幕阅读器当前是展开还是收起状态。这时候用原生<button>加上aria-expanded属性就非常合适:

<button id="toggle" aria-expanded="false">
  查看详情
</button>
<div id="panel" hidden>
  这里是折叠面板里的详细内容
</div>

配合一点JavaScript来切换状态:

const button = document.getElementById('toggle');
const panel = document.getElementById('panel');

button.addEventListener('click', () => {
  const expanded = button.getAttribute('aria-expanded') === 'true';
  button.setAttribute('aria-expanded', !expanded);
  panel.hidden = expanded;
});

这个例子中,按钮还是那个按钮,角色没变,但通过ARIA补充了额外的状态信息。这才是ARIA该待的位置:补充原生元素表达不出来的动态信息

ARIA使用场景具体方式典型例子
补充状态aria-expanded折叠面板开关
动态更新aria-live表单提交反馈
自定义控件role组合自定义滑块
关系表达aria-describedby表单错误提示

核心原则就一条:如果ARIA在做“重活”,那大概率是HTML选错了。让语义HTML打好地基,ARIA只负责地基上那些原生表达不了的东西。