无障碍访问(A11y)
什么是 Web 无障碍?
官方定义
Web 无障碍(Web Accessibility,简称 A11y)是指让网站和应用程序对所有人可用,包括残障人士。这涉及到视觉、听觉、运动和认知障碍的用户。
通俗理解
想象你在一个完全黑暗的房间里使用手机 —— 你只能依靠声音来操作。无障碍设计就是确保即使在这种情况下,用户也能正常使用你的网站。
常见的无障碍场景:
- 盲人使用屏幕阅读器浏览网页
- 色盲用户无法区分某些颜色
- 手部残疾用户只能使用键盘
- 老年人视力下降,需要放大字体
WCAG 标准
Web Content Accessibility Guidelines(WCAG)是 W3C 制定的无障碍指南。
四大原则(POUR)
| 原则 | 说明 | 示例 |
|---|---|---|
| 可感知 (Perceivable) | 信息必须以用户能感知的方式呈现 | 图片提供替代文本 |
| 可操作 (Operable) | 界面组件必须可操作 | 所有功能可通过键盘访问 |
| 可理解 (Understandable) | 信息和操作必须可理解 | 清晰的错误提示 |
| 健壮性 (Robust) | 内容必须能被各种用户代理解析 | 使用语义化 HTML |
合规级别
- A 级:最低要求,解决最严重的障碍
- AA 级:中等要求,大多数网站的目标(推荐)
- AAA 级:最高要求,适用于特定场景
语义化 HTML
使用正确的 HTML 元素是无障碍的基础。
正确使用标题层级
html
<!-- ✅ 正确:标题层级清晰 -->
<h1>网站标题</h1>
<main>
<article>
<h2>文章标题</h2>
<section>
<h3>章节标题</h3>
<p>内容...</p>
</section>
</article>
</main>
<!-- ❌ 错误:跳过标题层级 -->
<h1>网站标题</h1>
<h3>直接使用 h3</h3> <!-- 跳过了 h2 -->
<!-- ❌ 错误:用样式代替语义 -->
<div class="title">这不是真正的标题</div>
<span style="font-size: 24px; font-weight: bold;">这也不是</span>使用语义化标签
html
<!-- ✅ 使用语义化标签 -->
<header>
<nav aria-label="主导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/about">关于</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>文章标题</h1>
<p>文章内容...</p>
</article>
<aside aria-label="相关文章">
<h2>相关推荐</h2>
<ul>
<li><a href="#">推荐文章1</a></li>
</ul>
</aside>
</main>
<footer>
<p>© 2024 版权所有</p>
</footer>
<!-- ❌ 全部使用 div -->
<div class="header">
<div class="nav">...</div>
</div>
<div class="main">
<div class="article">...</div>
</div>按钮和链接
html
<!-- ✅ 正确使用按钮 -->
<button type="button" onclick="handleClick()">点击我</button>
<!-- ❌ 错误:用 div 模拟按钮 -->
<div class="button" onclick="handleClick()">点击我</div>
<!-- ✅ 正确使用链接 -->
<a href="/about">关于我们</a>
<!-- ❌ 错误:用按钮做导航 -->
<button onclick="location.href='/about'">关于我们</button>
<!-- ✅ 链接在新窗口打开时提示用户 -->
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
外部链接
<span class="sr-only">(在新窗口打开)</span>
</a>ARIA 属性
ARIA(Accessible Rich Internet Applications)提供额外的语义信息。
常用 ARIA 角色
html
<!-- 地标角色 -->
<div role="banner">网站头部</div>
<div role="navigation">导航</div>
<div role="main">主要内容</div>
<div role="complementary">侧边栏</div>
<div role="contentinfo">页脚</div>
<!-- 组件角色 -->
<div role="button">自定义按钮</div>
<div role="dialog">对话框</div>
<div role="alert">警告信息</div>
<div role="tab">标签页</div>
<div role="tabpanel">标签面板</div>
<div role="menu">菜单</div>
<div role="menuitem">菜单项</div>常用 ARIA 属性
html
<!-- aria-label:为元素提供标签 -->
<button aria-label="关闭弹窗">×</button>
<nav aria-label="主导航">...</nav>
<!-- aria-labelledby:引用其他元素作为标签 -->
<h2 id="section-title">用户设置</h2>
<section aria-labelledby="section-title">
<!-- 内容 -->
</section>
<!-- aria-describedby:提供额外描述 -->
<input type="password" aria-describedby="password-hint">
<p id="password-hint">密码至少包含 8 个字符</p>
<!-- aria-hidden:对屏幕阅读器隐藏 -->
<span aria-hidden="true">🔍</span> <!-- 装饰性图标 -->
<span class="sr-only">搜索</span> <!-- 仅屏幕阅读器可见 -->
<!-- aria-live:动态内容通知 -->
<div aria-live="polite">
<!-- 内容变化时会被朗读 -->
</div>
<div aria-live="assertive">
<!-- 立即打断并朗读 -->
</div>
<!-- aria-expanded:展开状态 -->
<button aria-expanded="false" aria-controls="menu">
菜单
</button>
<ul id="menu" hidden>
<li>选项1</li>
<li>选项2</li>
</ul>
<!-- aria-current:当前项 -->
<nav>
<a href="/" aria-current="page">首页</a>
<a href="/about">关于</a>
</nav>
<!-- aria-disabled:禁用状态 -->
<button aria-disabled="true">提交中...</button>
<!-- aria-invalid:表单验证错误 -->
<input type="email" aria-invalid="true" aria-describedby="email-error">
<span id="email-error">请输入有效的邮箱地址</span>实际组件示例
html
<!-- 模态框 -->
<div role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">确认删除</h2>
<p id="dialog-desc">确定要删除这条记录吗?此操作不可恢复。</p>
<button>确认</button>
<button>取消</button>
</div>
<!-- 标签页 -->
<div role="tablist" aria-label="产品信息">
<button role="tab"
id="tab-1"
aria-selected="true"
aria-controls="panel-1">
详情
</button>
<button role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2">
评价
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
产品详情内容...
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
用户评价内容...
</div>
<!-- 下拉菜单 -->
<div class="dropdown">
<button aria-haspopup="true"
aria-expanded="false"
aria-controls="dropdown-menu">
选择选项
</button>
<ul id="dropdown-menu" role="menu" hidden>
<li role="menuitem"><a href="#">选项 1</a></li>
<li role="menuitem"><a href="#">选项 2</a></li>
<li role="menuitem"><a href="#">选项 3</a></li>
</ul>
</div>
<!-- 进度条 -->
<div role="progressbar"
aria-valuenow="75"
aria-valuemin="0"
aria-valuemax="100"
aria-label="下载进度">
<span style="width: 75%"></span>
</div>
<!-- 警告信息 -->
<div role="alert" aria-live="assertive">
操作失败,请重试!
</div>图片无障碍
alt 属性
html
<!-- ✅ 有意义的图片:描述内容 -->
<img src="chart.png" alt="2024年销售趋势图,显示销售额从1月的100万增长到12月的500万">
<!-- ✅ 装饰性图片:空 alt -->
<img src="decorative-line.png" alt="">
<!-- ✅ 图片作为链接:描述链接目的 -->
<a href="/products">
<img src="product.jpg" alt="查看所有产品">
</a>
<!-- ✅ 复杂图片:使用 figure 和 figcaption -->
<figure>
<img src="complex-diagram.png" alt="系统架构图">
<figcaption>
图1:微服务架构图,展示了用户服务、订单服务和支付服务之间的交互关系。
</figcaption>
</figure>
<!-- ❌ 错误:无意义的 alt -->
<img src="photo.jpg" alt="图片">
<img src="photo.jpg" alt="photo.jpg">
<img src="photo.jpg" alt="点击这里">SVG 无障碍
html
<!-- 装饰性 SVG -->
<svg aria-hidden="true" focusable="false">
<!-- 图标内容 -->
</svg>
<!-- 有意义的 SVG -->
<svg role="img" aria-labelledby="svg-title svg-desc">
<title id="svg-title">饼图</title>
<desc id="svg-desc">显示市场份额:A公司40%,B公司35%,其他25%</desc>
<!-- 图形内容 -->
</svg>
<!-- 交互式 SVG -->
<button aria-label="播放视频">
<svg aria-hidden="true" focusable="false">
<polygon points="0,0 0,20 15,10" />
</svg>
</button>表单无障碍
标签关联
html
<!-- ✅ 使用 for 属性关联 -->
<label for="username">用户名</label>
<input type="text" id="username" name="username">
<!-- ✅ 隐式关联 -->
<label>
邮箱
<input type="email" name="email">
</label>
<!-- ✅ 使用 aria-label -->
<input type="search" aria-label="搜索产品" placeholder="搜索...">
<!-- ❌ 错误:没有标签 -->
<input type="text" placeholder="用户名">表单验证
html
<form novalidate>
<div class="form-group">
<label for="email">邮箱 <span aria-hidden="true">*</span></label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid="true"
aria-describedby="email-error email-hint"
>
<p id="email-hint" class="hint">我们不会分享您的邮箱</p>
<p id="email-error" class="error" role="alert">
请输入有效的邮箱地址
</p>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
type="password"
id="password"
name="password"
minlength="8"
aria-describedby="password-requirements"
>
<ul id="password-requirements">
<li>至少 8 个字符</li>
<li>包含大小写字母</li>
<li>包含数字</li>
</ul>
</div>
<button type="submit">注册</button>
</form>必填字段
html
<!-- ✅ 正确标记必填字段 -->
<label for="name">
姓名
<span class="required" aria-hidden="true">*</span>
<span class="sr-only">(必填)</span>
</label>
<input type="text" id="name" required aria-required="true">
<!-- 表单开头说明 -->
<p>标有 <span aria-hidden="true">*</span> 的字段为必填项。</p>分组控件
html
<!-- 使用 fieldset 和 legend 分组 -->
<fieldset>
<legend>收货地址</legend>
<label for="street">街道地址</label>
<input type="text" id="street" name="street">
<label for="city">城市</label>
<input type="text" id="city" name="city">
</fieldset>
<!-- 单选按钮组 -->
<fieldset>
<legend>支付方式</legend>
<label>
<input type="radio" name="payment" value="card"> 银行卡
</label>
<label>
<input type="radio" name="payment" value="alipay"> 支付宝
</label>
<label>
<input type="radio" name="payment" value="wechat"> 微信支付
</label>
</fieldset>键盘导航
焦点管理
html
<!-- tabindex 使用 -->
<!-- 0:正常焦点顺序 -->
<div tabindex="0">可以获得焦点</div>
<!-- -1:可通过 JS 聚焦,但不在 tab 顺序中 -->
<div tabindex="-1" id="modal">模态框内容</div>
<!-- 正数(避免使用):指定 tab 顺序 -->
<input tabindex="1"> <!-- 不推荐 -->javascript
// 焦点管理示例
function openModal() {
const modal = document.getElementById('modal');
const firstFocusable = modal.querySelector('button, input, a');
modal.hidden = false;
firstFocusable.focus();
// 焦点陷阱:保持焦点在模态框内
modal.addEventListener('keydown', trapFocus);
}
function trapFocus(e) {
const modal = e.currentTarget;
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
if (e.key === 'Escape') {
closeModal();
}
}跳过链接
html
<!-- 跳过导航链接 -->
<body>
<a href="#main-content" class="skip-link">
跳到主要内容
</a>
<header>
<nav>
<!-- 很长的导航菜单 -->
</nav>
</header>
<main id="main-content" tabindex="-1">
<!-- 主要内容 -->
</main>
</body>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
transition: top 0.3s;
}
.skip-link:focus {
top: 0;
}
</style>键盘快捷键
javascript
// 自定义键盘快捷键
document.addEventListener('keydown', (e) => {
// Alt + S 搜索
if (e.altKey && e.key === 's') {
e.preventDefault();
document.getElementById('search-input').focus();
}
// Alt + H 返回首页
if (e.altKey && e.key === 'h') {
e.preventDefault();
window.location.href = '/';
}
});
// 提供快捷键提示
<button aria-keyshortcuts="Alt+S" onclick="focusSearch()">
搜索 (Alt+S)
</button>颜色和对比度
对比度要求
| 级别 | 正常文本 | 大文本 |
|---|---|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.5:1 |
大文本定义:18px 以上,或 14px 加粗
css
/* ✅ 高对比度 */
.text-good {
color: #333333; /* 深灰色文字 */
background-color: #ffffff; /* 白色背景 */
/* 对比度约 12.6:1 */
}
/* ❌ 低对比度 */
.text-bad {
color: #999999; /* 浅灰色文字 */
background-color: #ffffff; /* 白色背景 */
/* 对比度约 2.8:1,不满足 AA 级 */
}
/* ✅ 链接颜色 */
a {
color: #0066cc; /* 蓝色链接 */
text-decoration: underline; /* 不仅靠颜色区分 */
}
/* ✅ 焦点样式 */
:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* ❌ 不要移除焦点样式 */
:focus {
outline: none; /* 错误! */
}不仅依赖颜色
html
<!-- ✅ 使用图标 + 颜色 + 文字 -->
<div class="alert alert-error">
<span class="icon">⚠️</span>
<span class="text">错误:用户名已存在</span>
</div>
<div class="alert alert-success">
<span class="icon">✓</span>
<span class="text">成功:账号已创建</span>
</div>
<!-- ✅ 表单错误:颜色 + 图标 + 文字 -->
<input type="email" class="error" aria-invalid="true">
<span class="error-icon">⚠️</span>
<span class="error-message">请输入有效的邮箱地址</span>
<!-- ❌ 仅使用颜色表示状态 -->
<input type="email" style="border-color: red;">媒体无障碍
视频字幕
html
<video controls>
<source src="video.mp4" type="video/mp4">
<!-- 字幕轨道 -->
<track
kind="subtitles"
src="subtitles-zh.vtt"
srclang="zh"
label="中文字幕"
default
>
<track
kind="subtitles"
src="subtitles-en.vtt"
srclang="en"
label="English"
>
<!-- 音频描述 -->
<track
kind="descriptions"
src="descriptions.vtt"
srclang="zh"
label="音频描述"
>
</video>WebVTT 字幕格式:
WEBVTT
00:00:01.000 --> 00:00:04.000
大家好,欢迎来到我们的频道
00:00:04.500 --> 00:00:08.000
今天我们要讲的是 Web 无障碍音频转录
html
<figure>
<audio controls>
<source src="podcast.mp3" type="audio/mpeg">
</audio>
<figcaption>
<details>
<summary>查看文字记录</summary>
<p>主持人:大家好,欢迎收听今天的播客...</p>
<p>嘉宾:谢谢邀请,很高兴来到这里...</p>
</details>
</figcaption>
</figure>屏幕阅读器隐藏文本
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;
}
/* 可聚焦时显示 */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
}html
<!-- 使用示例 -->
<button>
<svg aria-hidden="true">...</svg>
<span class="sr-only">关闭菜单</span>
</button>
<a href="tel:+8612345678900">
<span aria-hidden="true">📞</span>
<span class="sr-only">拨打电话:</span>
123-4567-8900
</a>
<!-- 表格标题 -->
<table>
<caption class="sr-only">用户列表,共3列:姓名、邮箱、操作</caption>
<thead>
<tr>
<th>姓名</th>
<th>邮箱</th>
<th>操作</th>
</tr>
</thead>
<!-- ... -->
</table>测试工具
自动化测试
bash
# axe-core
npm install axe-core
# pa11y
npm install -g pa11y
pa11y https://example.com
# lighthouse
npx lighthouse https://example.com --viewjavascript
// axe-core 使用
import axe from 'axe-core';
axe.run(document, {}, (err, results) => {
if (err) throw err;
console.log('违规项:', results.violations);
console.log('通过项:', results.passes);
});浏览器扩展
- axe DevTools - Chrome/Firefox 扩展
- WAVE - Web 可访问性评估工具
- Lighthouse - Chrome DevTools 内置
手动测试
键盘测试
- 仅使用 Tab、Enter、方向键导航
- 检查焦点可见性
- 测试所有交互功能
屏幕阅读器测试
- NVDA(Windows,免费)
- VoiceOver(macOS/iOS,内置)
- TalkBack(Android,内置)
对比度检查
- Chrome DevTools 颜色选择器
- WebAIM Contrast Checker
常见面试题
Q1: 什么是 ARIA?什么时候使用?
答案: ARIA(Accessible Rich Internet Applications)是一组属性,用于增强 HTML 元素的可访问性语义。
使用原则:
- 首选原生 HTML - 能用
<button>就不用<div role="button"> - 不改变语义 - 不要用 ARIA 覆盖原生语义
- 可键盘访问 - 添加 ARIA 角色的元素必须可键盘操作
- 不隐藏可聚焦元素 - 不要对可交互元素使用
aria-hidden
html
<!-- ❌ 错误使用 -->
<button role="heading">这是标题</button>
<!-- ✅ 正确使用 -->
<div role="alert">操作成功!</div>Q2: 如何让自定义组件具有无障碍性?
答案:
javascript
// 自定义下拉框示例
class AccessibleSelect {
constructor(element) {
this.element = element;
this.button = element.querySelector('.select-button');
this.listbox = element.querySelector('.select-listbox');
this.options = element.querySelectorAll('.select-option');
this.init();
}
init() {
// 添加 ARIA 属性
this.button.setAttribute('role', 'combobox');
this.button.setAttribute('aria-haspopup', 'listbox');
this.button.setAttribute('aria-expanded', 'false');
this.listbox.setAttribute('role', 'listbox');
this.listbox.setAttribute('tabindex', '-1');
this.options.forEach((option, index) => {
option.setAttribute('role', 'option');
option.setAttribute('id', `option-${index}`);
});
// 键盘事件
this.button.addEventListener('keydown', this.handleKeydown.bind(this));
this.listbox.addEventListener('keydown', this.handleKeydown.bind(this));
}
handleKeydown(e) {
switch (e.key) {
case 'Enter':
case ' ':
this.toggle();
break;
case 'ArrowDown':
this.focusNext();
break;
case 'ArrowUp':
this.focusPrevious();
break;
case 'Escape':
this.close();
break;
}
}
toggle() {
const isExpanded = this.button.getAttribute('aria-expanded') === 'true';
this.button.setAttribute('aria-expanded', !isExpanded);
this.listbox.hidden = isExpanded;
if (!isExpanded) {
this.options[0].focus();
}
}
// ... 其他方法
}Q3: 如何测试网站的无障碍性?
答案:
自动化测试:
- 使用 axe-core、pa11y 等工具
- 集成到 CI/CD 流程
手动测试:
- 键盘导航测试
- 屏幕阅读器测试
- 颜色对比度检查
用户测试:
- 邀请残障用户参与测试
- 收集真实反馈
总结
核心要点
- 语义化 HTML 是基础 - 使用正确的元素
- ARIA 是补充 - 在原生语义不足时使用
- 键盘可访问 - 所有功能都能通过键盘操作
- 颜色不是唯一依据 - 使用多种方式传达信息
- 测试是关键 - 自动化 + 手动 + 用户测试
快速检查清单
- [ ] 所有图片都有合适的 alt 文本
- [ ] 表单元素都有关联的标签
- [ ] 页面有清晰的标题层级
- [ ] 所有功能可通过键盘访问
- [ ] 焦点样式可见
- [ ] 颜色对比度满足 AA 级
- [ ] 动态内容有 aria-live 通知
- [ ] 错误信息清晰且关联到表单字段