Web无障碍(A11y)指南
约 2238 字大约 7 分钟
accessibilitya11y
2025-08-06
概述
Web 无障碍(Accessibility,简称 A11y)是指确保所有人——包括视觉、听觉、运动或认知障碍的用户——都能有效访问和使用 Web 内容。遵循无障碍标准不仅是法律要求(如 ADA、EU Accessibility Act),更是提升用户体验和扩大受众覆盖的最佳实践。
WCAG 指南
WCAG(Web Content Accessibility Guidelines)是 W3C 制定的无障碍标准,基于四大原则:
合规级别
| 级别 | 说明 | 要求 |
|---|---|---|
| A | 最低标准 | 基本无障碍要求 |
| AA | 推荐标准 | 大多数法规要求达到此级别 |
| AAA | 最高标准 | 所有无障碍要求 |
1. 语义化 HTML
语义化 HTML 是无障碍的基石——正确的元素自带内置的辅助功能。
<!-- Bad: div 不是按钮 -->
<div class="btn" onclick="submit()">提交</div>
<!-- 问题:不可聚焦、无键盘支持、屏幕阅读器不识别为按钮 -->
<!-- Good: 使用原生按钮 -->
<button type="submit" onclick="submit()">提交</button>
<!-- 自带:键盘聚焦、Enter/Space 触发、屏幕阅读器识别 -->
<!-- Bad: 用 div 模拟导航 -->
<div class="nav">
<div class="nav-item"><a href="/">首页</a></div>
<div class="nav-item"><a href="/about">关于</a></div>
</div>
<!-- Good: 语义化导航 -->
<nav aria-label="主导航">
<ul>
<li><a href="/" aria-current="page">首页</a></li>
<li><a href="/about">关于</a></li>
</ul>
</nav>页面地标区域
<body>
<header role="banner">
<nav aria-label="主导航">...</nav>
</header>
<main role="main" id="main-content">
<article>
<h1>文章标题</h1>
<section aria-labelledby="section1">
<h2 id="section1">第一部分</h2>
...
</section>
</article>
<aside aria-label="相关内容">...</aside>
</main>
<footer role="contentinfo">...</footer>
</body>2. ARIA Roles / States / Properties
ARIA(Accessible Rich Internet Applications)在原生语义不足时补充无障碍信息。
ARIA 角色
<!-- 对话框 -->
<div role="dialog" aria-labelledby="dialog-title" aria-modal="true">
<h2 id="dialog-title">确认删除</h2>
<p>确定要删除此项目吗?</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 aria-live="polite" aria-atomic="true">
<!-- 内容更新时屏幕阅读器会自动播报 -->
<p>搜索结果:找到 42 条记录</p>
</div>
<div aria-live="assertive">
<!-- 紧急通知,立即打断当前播报 -->
<p>错误:网络连接已断开</p>
</div>ARIA 状态和属性
<!-- 展开/折叠 -->
<button aria-expanded="false" aria-controls="dropdown-menu">
菜单
</button>
<ul id="dropdown-menu" hidden>
<li><a href="#">选项 1</a></li>
<li><a href="#">选项 2</a></li>
</ul>
<!-- 表单验证 -->
<label for="email">邮箱</label>
<input
id="email"
type="email"
aria-required="true"
aria-invalid="true"
aria-describedby="email-error"
/>
<span id="email-error" role="alert">请输入有效的邮箱地址</span>
<!-- 加载状态 -->
<button aria-busy="true" aria-disabled="true">
<span class="spinner" aria-hidden="true"></span>
提交中...
</button>
<!-- 进度条 -->
<div role="progressbar" aria-valuenow="65" aria-valuemin="0"
aria-valuemax="100" aria-label="文件上传进度">
65%
</div>第一规则:不要滥用 ARIA
<!-- ARIA 第一规则:如果原生元素能满足,不要用 ARIA -->
<!-- Bad: ARIA 模拟链接 -->
<span role="link" tabindex="0" onclick="navigate()">关于我们</span>
<!-- Good: 原生链接 -->
<a href="/about">关于我们</a>
<!-- Bad: ARIA 模拟复选框 -->
<div role="checkbox" aria-checked="false" tabindex="0">同意协议</div>
<!-- Good: 原生复选框 -->
<label>
<input type="checkbox" /> 同意协议
</label>3. 键盘导航
所有功能必须可通过键盘访问。
// 自定义组件的键盘支持
function Dropdown({ items, onSelect }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const handleKeyDown = (event) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
if (isOpen && activeIndex >= 0) {
onSelect(items[activeIndex]);
setIsOpen(false);
} else {
setIsOpen(true);
}
break;
case 'ArrowDown':
event.preventDefault();
if (!isOpen) setIsOpen(true);
setActiveIndex(prev => Math.min(prev + 1, items.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
setActiveIndex(prev => Math.max(prev - 1, 0));
break;
case 'Escape':
setIsOpen(false);
setActiveIndex(-1);
break;
case 'Home':
event.preventDefault();
setActiveIndex(0);
break;
case 'End':
event.preventDefault();
setActiveIndex(items.length - 1);
break;
}
};
return (
<div onKeyDown={handleKeyDown}>
<button
aria-haspopup="listbox"
aria-expanded={isOpen}
>
Select...
</button>
{isOpen && (
<ul role="listbox">
{items.map((item, i) => (
<li
key={item.id}
role="option"
aria-selected={i === activeIndex}
tabIndex={-1}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}Skip Navigation Link
<!-- 跳过导航链接:让键盘用户快速跳到主内容 -->
<a href="#main-content" class="skip-link">跳到主内容</a>
<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; /* Tab 到此链接时显示 */
}
</style>4. Focus Management
// 模态框的焦点管理
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// 保存之前的焦点位置
previousFocusRef.current = document.activeElement;
// 将焦点移入模态框
const firstFocusable = modalRef.current.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
} else if (previousFocusRef.current) {
// 关闭时恢复焦点
previousFocusRef.current.focus();
}
}, [isOpen]);
// 焦点陷阱:Tab 不会跳出模态框
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') onClose();
};
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" ref={modalRef}
onKeyDown={handleKeyDown} aria-labelledby="modal-title">
<h2 id="modal-title">标题</h2>
{children}
<button onClick={onClose}>关闭</button>
</div>
);
}5. 色彩对比度
/* 好的对比度 */
.text-good {
color: #333333; /* 深灰文字 */
background-color: #ffffff; /* 白色背景 */
/* 对比度: 12.63:1 */
}
/* 差的对比度 */
.text-bad {
color: #999999; /* 浅灰文字 */
background-color: #ffffff;
/* 对比度: 2.85:1 — 不符合 AA 标准 */
}
/* 焦点指示器 */
:focus-visible {
outline: 3px solid #005fcc; /* 清晰可见的焦点样式 */
outline-offset: 2px;
}
/* 不要完全移除焦点样式! */
/* Bad */
*:focus { outline: none; }
/* Good: 自定义但保留可见的焦点指示 */
*:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}6. 图片和媒体的无障碍
<!-- 有意义的图片:提供描述性 alt -->
<img src="chart.png" alt="2024年销售额:Q1 100万,Q2 150万,Q3 200万,Q4 180万">
<!-- 装饰性图片:空 alt -->
<img src="divider.png" alt="">
<!-- 或使用 CSS background-image -->
<!-- 复杂图片:长描述 -->
<figure>
<img src="architecture.png" alt="系统架构图" aria-describedby="arch-desc">
<figcaption id="arch-desc">
系统由三层组成:前端使用 React,后端采用 Node.js 微服务架构,
数据层使用 PostgreSQL 和 Redis。
</figcaption>
</figure>
<!-- SVG 图标 -->
<button>
<svg aria-hidden="true" focusable="false">
<use href="#icon-close" />
</svg>
<span class="sr-only">关闭</span>
</button>
<!-- 视频 -->
<video controls>
<source src="video.mp4" type="video/mp4">
<track kind="captions" src="captions-zh.vtt" srclang="zh" label="中文字幕">
<track kind="descriptions" src="descriptions.vtt" srclang="zh" label="音频描述">
</video>7. 屏幕阅读器测试
<!-- 仅屏幕阅读器可见的内容 -->
<style>
.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;
}
</style>
<!-- 使用示例 -->
<a href="/cart">
<span class="cart-icon" aria-hidden="true"></span>
购物车
<span class="badge">3</span>
<span class="sr-only">(3 件商品)</span>
</a>常见屏幕阅读器
| 屏幕阅读器 | 平台 | 浏览器 |
|---|---|---|
| NVDA | Windows | Firefox / Chrome |
| JAWS | Windows | Chrome / Edge |
| VoiceOver | macOS / iOS | Safari |
| TalkBack | Android | Chrome |
8. 常见问题与修复
<!-- 问题 1: 仅靠颜色传达信息 -->
<!-- Bad -->
<span style="color: red">必填</span>
<!-- Good -->
<span style="color: red">* 必填</span>
<!-- 使用颜色 + 文字/图标双重指示 -->
<!-- 问题 2: 自动播放媒体 -->
<!-- Bad -->
<video autoplay>...</video>
<!-- Good: 默认静音或提供控制 -->
<video autoplay muted controls>...</video>
<!-- 问题 3: 表格缺少标题 -->
<!-- Bad -->
<table>
<tr><td>姓名</td><td>年龄</td></tr>
<tr><td>Alice</td><td>25</td></tr>
</table>
<!-- Good -->
<table>
<caption>用户列表</caption>
<thead>
<tr><th scope="col">姓名</th><th scope="col">年龄</th></tr>
</thead>
<tbody>
<tr><td>Alice</td><td>25</td></tr>
</tbody>
</table>自动化测试
// eslint-plugin-jsx-a11y
// .eslintrc.js
module.exports = {
extends: ['plugin:jsx-a11y/recommended'],
};
// axe-core 自动化测试
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('页面无 a11y 违规', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});总结
Web 无障碍不是可选的附加功能,而是高质量 Web 开发的基本要求。核心实践包括:使用语义化 HTML 作为基础,仅在必要时补充 ARIA 属性;确保所有功能可通过键盘访问;正确管理焦点流向;满足色彩对比度标准;为图片和媒体提供替代文本。使用 axe-core 等工具进行自动化测试,结合屏幕阅读器的手动测试,确保应用对所有用户都是可用的。
贡献者
更新日志
2026/3/14 13:09
查看所有更新日志
9f6c2-feat: organize wiki content and refresh site setup于