让网页更包容 无障碍访问技术实践与优化
先来个实际的,别整虚的
上周上线了个新功能,一个带搜索的下拉选择组件。UI 挺好看,交互也顺滑,但 QA 测完提了一堆 accessibility 的问题——键盘操作不完整、屏幕阅读器读不出来、焦点管理混乱……我心想这玩意儿谁用啊,结果产品甩给我一条 WCAG 准则和公司合规要求,好吧,打脸了。
折腾了两天,从完全不懂到把所有 a11y 问题干掉,还顺手给团队写了份内部指南。今天就把我踩过的坑、亲测有效的写法分享出来,全是实战经验,不整理论那一套。
按钮?别光靠视觉判断
很多人做“可点击区域”直接上 div + onclick,比如:
<div class="custom-select-trigger" onclick="toggleOptions()">
请选择选项
</div>
看着没问题,鼠标点也 OK,但一上键盘就崩了——Tab 键根本跳不到这个元素上,screen reader(比如 VoiceOver 或 NVDA)也识别不了这是个按钮。
解决方法很简单:该用语义化标签就用。改成 button:
<button type="button" class="custom-select-trigger" onclick="toggleOptions()" aria-haspopup="true" aria-expanded="false">
请选择选项
</button>
关键点:
- type=”button”:防止意外提交表单
- aria-haspopup:告诉辅助技术这个按钮会弹出内容(比如菜单)
- aria-expanded:动态切换 true/false 表示展开或收起状态
JavaScript 控制的时候记得同步更新 aria-expanded:
function toggleOptions() {
const btn = document.querySelector('.custom-select-trigger');
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', !expanded);
}
别小看这几个属性,screen reader 用户全靠它们理解界面逻辑。
下拉列表怎么搞才合规
接着上面的,下拉选项列表我也一开始用了 ul + li 套路,但没加角色和关系绑定,结果读屏软件完全不知道这些选项属于哪个控件。
正确姿势:
<ul role="listbox" aria-labelledby="select-label" id="option-list" tabindex="-1">
<li role="option" aria-selected="false" data-value="1">选项一</li>
<li role="option" aria-selected="true" data-value="2">选项二</li>
<li role="option" aria-selected="false" data-value="3">选项三</li>
</ul>
解释几个重点:
- role=”listbox”:声明这是一个可选列表容器
- role=”option”:每个选项都要标注为 option
- aria-labelledby:关联到触发按钮的文本,建立父子关系
- tabindex=”-1″:允许通过 JS 聚焦,但不在自然 Tab 流中出现(不然用户得一个个按过去)
这里注意我踩过好几次坑:如果 listbox 初始是隐藏的(display: none),那你用 JS 显示后一定要手动 focus 到第一个选项,否则键盘用户会“失焦”。
function showOptions() {
const list = document.getElementById('option-list');
list.style.display = 'block';
// 等 DOM 更新后再 focus
setTimeout(() => {
const firstOption = list.querySelector('[role="option"]');
if (firstOption) firstOption.focus();
}, 10);
}
键盘支持不是 optional
很多开发者只测试鼠标操作,忽略了键盘导航。但很多用户(比如视力障碍者、手部不便者)完全依赖键盘。
你得支持以下行为:
- 打开下拉:触发按钮获得焦点后按 Space 或 Enter
- 上下切换选项:↑ ↓ 方向键
- 确认选择:Enter 或 Tab
- 关闭面板:Esc
监听 keydown 就完事了:
document.addEventListener('keydown', function(e) {
const list = document.getElementById('option-list');
const isVisible = list.style.display === 'block';
const options = Array.from(list.querySelectorAll('[role="option"]'));
const current = document.activeElement;
if (!isVisible) return;
switch(e.key) {
case 'ArrowUp':
e.preventDefault();
const prevIndex = Math.max(0, options.indexOf(current) - 1);
options[prevIndex].focus();
break;
case 'ArrowDown':
e.preventDefault();
const nextIndex = Math.min(options.length - 1, options.indexOf(current) + 1);
options[nextIndex].focus();
break;
case 'Enter':
e.preventDefault();
selectOption(current);
closeList();
break;
case 'Escape':
e.preventDefault();
closeList();
document.querySelector('.custom-select-trigger').focus();
break;
}
});
这里有个细节:必须 preventDefault(),否则 ↑↓ 会滚动页面,Enter 会触发多次 click,体验极差。
隐藏内容 ≠ 删除 DOM
曾经我以为 display: none 就万事大吉,结果发现 screen reader 居然“看不见”这些元素了!对,它们真看不见。
如果你用 aria-live 区域、tooltip 或 modal,不能简单 hide 掉,要用视觉隐藏但保留可访问性的方式。
推荐 CSS 写法:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
这样元素在视觉上不可见,但仍能被 screen reader 读取。常用于动态提示:
<div aria-live="polite" class="sr-only" id="live-announcement"></div>
function announce(message) {
const el = document.getElementById('live-announcement');
el.textContent = message;
// 再次赋值确保触发更新(有些浏览器需要)
setTimeout(() => { el.textContent = ''; }, 1000);
}
表单控件别乱搞 label
最常见的错误就是写了 input 却没绑 label。你以为 placeholder 就够了?错,screen reader 不一定读 placeholder,而且键盘用户也不知道这输入框是干啥的。
正确做法:
<label for="search-input">搜索关键词</label>
<input type="text" id="search-input" name="q" aria-describedby="search-help">
<span id="search-help" class="sr-only">按回车开始搜索</span>
或者嵌套写法也行:
<label>
搜索关键词
<input type="text" name="q">
</label>
两种都可以,但我建议用 for + id 显式绑定,更可控。
另外,如果有错误提示,记得关联:
<input type="text" id="email" aria-invalid="true" aria-describedby="email-error">
<div id="email-error" role="alert">请输入有效邮箱地址</div>
API 请求状态要通知到所有人
加载中、成功、失败……这些状态变化不仅要视觉反馈,还得让 screen reader 知道。
我现在的做法是封装一个 notify 函数:
async function fetchData() {
announce('开始加载数据');
try {
const res = await fetch('https://jztheme.com/api/data');
const data = await res.json();
renderData(data);
announce('数据加载成功,共' + data.length + '条记录');
} catch (err) {
announce('数据加载失败,请稍后重试');
}
}
结合前面的 aria-live 区域,信息就能及时传达。
踩坑提醒:这三点一定注意
这都是我拿 bug 换来的经验:
- 不要用 visibility: hidden 或 display: none 包裹需要被读取的内容,screen reader 会跳过。要用 .sr-only 这类技巧。
- 动态生成的 DOM 记得补全 ARIA 属性,尤其是单页应用(SPA),router 切换后容易漏掉 title 和 live region 重置。
- 避免使用 role=”presentation” 或 aria-hidden=”true” 过度,删错了可能把整个可交互区域屏蔽掉,我自己就误删过一个菜单导致无法操作。
还有一个隐蔽问题:移动端 iOS VoiceOver 在 Safari 中对某些 ARIA 属性支持不一致,建议上线前用真机测试基本流程。
Chrome DevTools 其实挺能打
别以为 a11y 测试只能靠盲人用户反馈。Chrome 的 Accessibility 面板就能查不少问题。
打开方式:Elements 面板 → 右侧 Accessibility 树,你能看到浏览器“认为”的语义结构。
还可以用 Lighthouse 跑一次 audit,分数低的基本都有明显问题。不过别迷信分数,60 分也能满足基础合规,关键是核心功能路径通顺。
这个技术的拓展用法还有很多
以上是我个人对无障碍访问的阶段性总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如高对比度模式适配、prefers-reduced-motion 动画控制、iframe 的 title 管理等,后续会继续分享这类博客。
最后说句实在话:做 a11y 不是为了加分,而是为了不让任何人被排除在外。改完之后我们收到一封来自视障用户的邮件,说终于能独立完成下单了——那一刻我觉得这两天加班值了。

暂无评论