Web 无障碍(a11y):让产品服务每一个人
目标:建立无障碍工程化思维,掌握从代码到流程的可访问性实践方法。
核心要点(TL;DR)
- Web 无障碍(a11y)不是"加分项",而是产品可用性、合规性和品牌责任的组成部分。
- 语义化 HTML 是无障碍的基础,ARIA 是补充而非替代。
- 键盘可访问性是可访问性的底线:所有交互都应可通过键盘完成。
- 屏幕阅读器用户依赖标题层级、地标(landmark)、表单标签和状态提示来理解页面。
- 颜色不应是传递信息的唯一方式,需保证足够的对比度。
- 无障碍需要融入开发流程:设计评审、代码审查、自动化测试、用户测试。
学习时长与前置知识
- 建议学习时长:2-3 周(每周投入 4-6 小时)
- 前置知识:HTML/CSS 基础(F06)、浏览器原理(F03)、JavaScript 基础(F01)
一、什么是 Web 无障碍?
Web 无障碍(Web Accessibility,简称 a11y)是指让网站和应用能够被尽可能多的人使用,包括:
- 视觉障碍者(失明、低视力、色盲)
- 听觉障碍者
- 运动障碍者(无法使用鼠标、肢体震颤)
- 认知障碍者(阅读困难、注意力缺陷)
- 临时性障碍者(手臂受伤、强光环境、嘈杂环境)
- 老年用户
1.1 无障碍不仅是道德义务
| 维度 | 说明 |
|---|---|
| 法律合规 | 中国《无障碍环境建设法》、欧盟《欧洲无障碍法》、美国 ADA/WCAG 诉讼 |
| 商业收益 | 扩大用户群体,提升 SEO 和可用性 |
| 品牌责任 | 体现企业的社会责任感 |
| 技术质量 | 无障碍代码通常也是语义化、可维护性更好的代码 |
1.2 前端在无障碍中的核心位置
前端是用户与系统交互的最后一公里,直接决定:
- 键盘能否操作
- 屏幕阅读器能否理解
- 焦点是否可见且合理
- 颜色是否可辨识
二、WCAG:无障碍的通用标准
2.1 WCAG 2.1 四项原则(POUR)
| 原则 | 含义 | 示例 |
|---|---|---|
| Perceivable(可感知) | 信息和界面组件必须可被用户感知 | 图片有 alt 文本,视频有字幕 |
| Operable(可操作) | 界面组件必须可操作 | 所有功能可通过键盘访问 |
| Understandable(可理解) | 信息和操作必须可理解 | 错误提示清晰,语言简洁 |
| Robust(健壮) | 内容可被各种辅助技术解析 | 使用标准 HTML 和 ARIA |
2.2 conformance 等级
- A:最低要求,必须满足。
- AA:推荐标准,大多数合规要求的目标。
- AAA:最高标准,适用于特定场景。
国内法规通常要求达到 AA 级。
2.3 关键成功标准
| 标准 | 要求 | 前端实践 |
|---|---|---|
| 1.1.1 非文本内容 | 图片必须有替代文本 | alt="描述" |
| 1.4.3 对比度 | 文本与背景对比度至少 4.5:1 | 检查颜色组合 |
| 2.1.1 键盘 | 所有功能可通过键盘操作 | 避免只依赖鼠标事件 |
| 2.4.3 焦点顺序 | 焦点顺序符合逻辑 | DOM 顺序即焦点顺序 |
| 2.4.7 焦点可见 | 当前焦点必须可见 | :focus-visible 样式 |
| 3.3.1 错误识别 | 输入错误必须清晰标识 | 关联错误信息与输入框 |
| 4.1.2 名称、角色、值 | 组件必须有可访问名称和状态 | 正确使用 label、aria-* |
2.4 WCAG 2.2 新增成功标准
WCAG 2.2 于 2023 年 10 月成为正式推荐标准,在 WCAG 2.1 的 50 项成功标准基础上新增了 9 项。以下是对前端影响最大的三项:
2.4.11 Focus Not Obscured(AA 级)
聚焦的元素不能被其他元素完全遮挡。当一个元素获得键盘焦点时,用户必须能够看到焦点的所在位置。常见违规场景包括固定导航栏遮挡、弹窗遮罩层未正确管理、粘性页脚或 Cookie 横幅遮挡页面底部可聚焦元素。
/* ✅ 使用 scroll-margin 确保标题不被固定导航栏遮挡 */
h2, h3 {
scroll-margin-top: 80px;
}
/* ✅ 确保弹窗开启时焦点元素在最顶层 */
[role="dialog"] {
z-index: 1000;
}2.5.7 Dragging Movements(AA 级)
对于需要拖拽完成的操作,必须提供基于单次点击或轻触的替代方式。例如拖拽排序的列表应提供"上移"/"下移"按钮作为替代方案。
<!-- ✅ 拖拽排序同时提供按钮替代 -->
<ul role="listbox" aria-label="排序列表">
<li role="option" draggable="true">
项目一
<button aria-label="上移项目一">↑</button>
<button aria-label="下移项目一">↓</button>
</li>
</ul>2.5.8 Target Size(AA 级)
交互目标的最小尺寸为 24×24 CSS 像素。AAA 级别要求 44×44 像素。例外情况包括:目标位于同一行或句子内(如内联链接)、目标大小由用户代理控制、或者目标是法律或财务功能中的必要元素。
/* ✅ 确保触摸目标至少 24x24 */
.icon-button {
min-width: 24px;
min-height: 24px;
/* 即使图标本身小,点击区域也要足够 */
}三、语义化 HTML:无障碍的基石
3.1 语义标签自带无障碍信息
<!-- ✅ 屏幕阅读器知道这是按钮 -->
<button type="submit">提交</button>
<!-- ❌ 屏幕阅读器只读到"提交"文本,不知道这是可点击按钮 -->
<div class="btn" onclick="submit()">提交</div>3.2 标题层级构成文档地图
屏幕阅读器用户常通过标题快速跳转:
<h1>商品详情</h1>
<h2>商品参数</h2>
<h3>尺寸信息</h3>
<h2>用户评价</h2>禁忌:
- 跳级(h1 后直接 h3)
- 用标题控制字体大小
- 一个页面多个 h1
3.3 地标元素(Landmark)
<header>
<nav aria-label="主导航">...</nav>
</header>
<main>
<article>...</article>
</main>
<aside>...</aside>
<footer>...</footer>屏幕阅读器用户可以通过 landmark 快速跳转到页面不同区域。
四、ARIA:补充语义信息
ARIA(Accessible Rich Internet Applications)用于补充 HTML 无法表达的语义。
4.1 ARIA 三件套
| 类型 | 作用 | 示例 |
|---|---|---|
| 角色(Role) | 定义组件类型 | role="dialog" |
| 状态(State) | 描述当前状态 | aria-expanded="true" |
| 属性(Property) | 提供额外信息 | aria-label="关闭" |
4.2 常见 ARIA 使用场景
<!-- 自定义按钮 -->
<div role="button" tabindex="0" aria-pressed="false">
收藏
</div>
<!-- 开关 -->
<button role="switch" aria-checked="false" aria-label="夜间模式">
<span aria-hidden="true">🌞</span>
</button>
<!-- 提示信息 -->
<div role="alert" aria-live="assertive">
保存失败,请重试
</div>4.3 ARIA 使用原则
- 优先使用原生 HTML:能用
<button>就不用role="button"。 - 不要过度使用 ARIA:错误的 ARIA 比没有 ARIA 更糟糕。
- 确保可访问名称:每个交互元素都应有可访问名称(文本内容、aria-label 或 aria-labelledby)。
- 状态同步:ARIA 状态必须随组件状态实时更新。
4.4 ARIA 深度解析
ARIA 角色分类体系
ARIA 1.2 规范定义了六类角色,每个角色有不同的语义要求和使用场景:
| 角色分类 | 描述 | 示例 |
|---|---|---|
| Widget Roles(控件角色) | 代表用户可交互的 UI 组件 | button, slider, tab, switch |
| Document Structure Roles(文档结构角色) | 描述页面内容的结构关系 | heading, list, table, toolbar |
| Landmark Roles(地标角色) | 标识页面主要功能区域 | navigation, main, banner, contentinfo |
| Live Region Roles(动态区域角色) | 内容动态更新的区域 | alert, log, status, timer |
| Window Roles(窗口角色) | 独立于主窗口的浮动层 | dialog, alertdialog |
| Abstract Roles(抽象角色) | 仅用于继承,不直接使用 | widget, input, range |
控件角色详解
控件角色用于构建自定义交互组件:
<!-- 进度条 -->
<div role="progressbar" aria-valuenow="65" aria-valuemin="0" aria-valuemax="100">
65%
</div>
<!-- 选项卡 -->
<div role="tablist" aria-label="设置选项">
<button role="tab" aria-selected="true" aria-controls="panel-general">通用</button>
<button role="tab" aria-selected="false" aria-controls="panel-security">安全</button>
</div>
<div role="tabpanel" id="panel-general"><!-- 内容 --></div>
<div role="tabpanel" id="panel-security"><!-- 内容 --></div>地标角色最佳实践
每个页面应至少包含 <main> 地标。地标可以嵌套,但同一类型地标应使用 aria-label 区分:
<nav aria-label="主导航">...</nav>
<nav aria-label="页脚导航">...</nav>第一条 ARIA 法则
If you can use a native HTML element with the semantics and behavior you require, don't use ARIA.
这是 WAI-ARIA 的第一条使用法则。原生 HTML 元素内置了语义、键盘交互和焦点管理,无需额外工作。ARIA 只在以下情况使用:
- 使用原生 HTML 无法表达所需语义时(如
role="progressbar") - 构建需要特定角色关系的高级组件时(如
role="tablist"+role="tab") - 因设计约束无法使用语义元素时
ARIA Authoring Practices Guide (APG)
W3C 的 ARIA Authoring Practices Guide 提供了常见 UI 组件的完整无障碍实现方案,包括:
- 推荐的键盘交互
- 焦点管理模式
- 角色、状态和属性的完整设置
- 可运行的示例代码
APG 涵盖的组件模式包括:手风琴(Accordion)、自动补全(Autocomplete)、弹窗(Dialog)、网格(Grid)、列表盒(Listbox)、菜单(Menu)、滑块(Slider)、选项卡(Tabs)、工具栏(Toolbar)等。
// APG 推荐的手风琴键盘交互
function onAccordionKeyDown(event) {
const { key } = event;
switch (key) {
case 'ArrowDown':
moveFocusToNextItem();
break;
case 'ArrowUp':
moveFocusToPreviousItem();
break;
case 'Home':
moveFocusToFirstItem();
break;
case 'End':
moveFocusToLastItem();
break;
}
}五、键盘可访问性
5.1 键盘操作基础
| 按键 | 行为 |
|---|---|
| Tab | 在可聚焦元素间正向移动 |
| Shift + Tab | 反向移动 |
| Enter / Space | 激活按钮、链接 |
| 方向键 | 在列表、菜单、单选组中导航 |
| Esc | 关闭弹窗、菜单 |
| Home / End | 跳到列表首尾 |
5.2 焦点管理
/* ✅ 可见焦点样式 */
:focus-visible {
outline: 3px solid #3b82f6;
outline-offset: 2px;
border-radius: 2px;
}
/* ❌ 不要完全移除焦点样式 */
:focus { outline: none; }5.3 焦点顺序
焦点顺序应与视觉顺序一致。避免使用 tabindex > 0,它会破坏自然顺序。
<!-- ❌ tabindex 大于 0 会改变焦点顺序 -->
<div tabindex="2">第二步</div>
<div tabindex="1">第一步</div>
<!-- ✅ 使用 DOM 顺序控制焦点 -->
<div tabindex="0">第一步</div>
<div tabindex="0">第二步</div>5.4 弹窗与焦点陷阱
打开弹窗时:
- 焦点应移动到弹窗内第一个可聚焦元素。
- Tab 键应在弹窗内循环(焦点陷阱)。
- 关闭弹窗时,焦点应回到触发按钮。
// 打开弹窗
modal.showModal();
modal.querySelector('button').focus();
// 关闭弹窗
triggerButton.focus();
modal.close();5.5 焦点陷阱的高级实现
对于自定义模态弹窗,需要程序化实现焦点循环:
// 焦点陷阱函数
function trapFocus(container) {
const focusable = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first.focus();
}5.6 Roving Tabindex 模式
用于列表、菜单、选项卡等需要方向键导航的组件。核心思想是:整个组件仅有一个元素在 Tab 顺序中(tabindex="0"),其余元素 tabindex="-1",通过方向键动态切换哪个元素为 tabindex="0"。
// Roving Tabindex 实现模式
function rovingTabindex(listContainer, items) {
items.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
item.addEventListener('keydown', (e) => {
let nextIndex;
switch (e.key) {
case 'ArrowDown':
nextIndex = (index + 1) % items.length;
break;
case 'ArrowUp':
nextIndex = (index - 1 + items.length) % items.length;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = items.length - 1;
break;
default:
return;
}
e.preventDefault();
items[index].setAttribute('tabindex', '-1');
items[nextIndex].setAttribute('tabindex', '0');
items[nextIndex].focus();
});
});
}5.7 跳转链接(Skip Links)
跳转链接是页面加载后第一个可聚焦元素,允许键盘用户跳过重复导航直接进入主要内容区域:
<!-- ✅ 标准跳转链接实现 -->
<a href="#main-content" class="skip-link">
跳到主要内容
</a>
<nav><!-- 重复的导航内容 --></nav>
<main id="main-content">
<h1>页面标题</h1>
<!-- 主要内容 -->
</main>/* ✅ 跳转链接样式:只在聚焦时可见 */
.skip-link {
position: absolute;
top: -100%;
left: 8px;
padding: 8px 16px;
background: #3b82f6;
color: white;
z-index: 10000;
}
.skip-link:focus {
top: 8px;
}六、屏幕阅读器测试工作流
屏幕阅读器是测试无障碍最关键的辅助技术工具。每种屏幕阅读器有不同的快捷键和交互模式,掌握它们是有效测试的前提。
6.1 NVDA(Windows,开源)
NVDA 是最广泛使用的 Windows 屏幕阅读器。关键快捷键:
| 快捷键 | 功能 |
|---|---|
| NVDA + 方向键 | 浏览模式下逐字/逐行导航 |
| NVDA + Tab | 获取当前聚焦元素的描述 |
| NVDA + F7 | 显示页面元素列表(标题、链接、地标) |
| NVDA + Space | 在浏览模式和焦点模式之间切换 |
| 方向键(浏览模式) | 在页面内容中逐行阅读 |
| Tab(浏览模式) | 在可聚焦元素间跳转 |
浏览模式(Browse Mode):这是 NVDA 阅读网页的默认模式。用户可以用方向键按文档流阅读,屏幕阅读器会朗读语义信息(标题层级、地标、列表项等)。
焦点模式(Focus Mode):当用户聚焦到表单输入框或具有 role 的自定义交互组件时,NVDA 自动切换到此模式。按键直接传递给页面而非屏幕阅读器。
6.2 VoiceOver(macOS / iOS)
macOS 内置屏幕阅读器,快捷键:
| 快捷键 | 功能 |
|---|---|
| VO + 方向键 | 导航到上一个/下一个元素 |
| VO + Shift + 方向键 | 与元素交互(点击、展开) |
| VO + Command + H | 按标题跳转 |
| VO + Command + J | 跳转到下一个地标 |
| VO + U | 打开转子(Rotor)菜单 |
转子(Rotor):VoiceOver 的核心导航功能,允许用户按标题、链接、表单控件、地标等维度快速跳转。
iOS 上的 VoiceOver 快捷键:
- 单指左右滑动:浏览元素
- 单指双击:激活元素
- 双指轻扫:翻页
- 三指上下滑动:快速滚动
6.3 常见屏幕阅读器测试流程
- 结构验证:使用元素列表(NVDA+F7 或 VO+U)检查标题层级是否合理、地标是否完整。
- 键盘导航:全程仅用 Tab、Shift+Tab 和方向键操作,验证所有功能可达。
- 表单测试:使用 Tab 进入表单字段,验证标签是否正确朗读,错误提示是否关联。
- 动态内容测试:验证 live region 更新是否被朗读,弹窗打开时焦点是否移动到弹窗内。
- 交互组件测试:验证自定义组件(下拉菜单、选项卡、树形控件)的键盘交互是否符合预期。
6.4 不同 ARIA 模式在屏幕阅读器中的表现
| ARIA 模式 | 屏幕阅读器行为 |
|---|---|
aria-live="polite" | 当前朗读完成后通知用户更新(适合非关键更新) |
aria-live="assertive" | 立即中断当前朗读通知用户(适合错误提示) |
role="alert" | 自动触发 assertive live region,无需手动设置 aria-live |
aria-hidden="true" | 元素完全对屏幕阅读器不可见(用于装饰元素) |
aria-expanded="true/false" | 朗读时告知用户当前折叠/展开状态 |
aria-describedby | 在朗读 aria-labelledby 或标签后,附加朗读描述内容 |
七、表单可访问性
7.1 标签关联
<!-- ✅ 显式关联 -->
<label for="email">邮箱</label>
<input id="email" type="email" name="email" />
<!-- ✅ 隐式关联 -->
<label>
邮箱
<input type="email" name="email" />
</label>
<!-- ❌ 无关联 -->
<span>邮箱</span>
<input type="email" name="email" />7.2 错误提示
<input
id="email"
type="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<div id="email-error" class="error">
请输入有效的邮箱地址
</div>7.3 占位符不是标签
<!-- ❌ 占位符消失后用户会忘记要填什么 -->
<input type="text" placeholder="请输入用户名" />
<!-- ✅ 占位符仅作提示 -->
<label for="username">用户名</label>
<input id="username" type="text" placeholder="如:zhangsan" />7.4 Live Regions 与动态验证反馈
对于异步表单验证(如用户名是否可用),使用 live region 通知屏幕阅读器用户验证结果:
<label for="username">用户名</label>
<input
id="username"
type="text"
aria-describedby="username-status"
aria-invalid="false"
/>
<div id="username-status" role="status" aria-live="polite">
<!-- 动态验证结果将在此处更新 -->
</div>// 动态验证反馈
usernameInput.addEventListener('blur', async () => {
const status = document.getElementById('username-status');
try {
const available = await checkUsername(usernameInput.value);
if (available) {
status.textContent = '用户名可用';
usernameInput.setAttribute('aria-invalid', 'false');
} else {
status.textContent = '用户名已被使用';
usernameInput.setAttribute('aria-invalid', 'true');
}
} catch {
status.textContent = '验证服务暂不可用';
}
});7.5 aria-describedby 多错误关联
复杂表单中一个输入框可能需要关联多个错误描述:
<label for="password">设置密码</label>
<input
id="password"
type="password"
aria-describedby="password-rules password-strength password-error"
aria-invalid="true"
/>
<ul id="password-rules">
<li>至少 8 个字符</li>
<li>包含大小写字母</li>
<li>包含数字</li>
</ul>
<div id="password-strength">密码强度:中</div>
<div id="password-error">密码不符合安全要求</div>7.6 复杂表单模式:分步表单
多步骤表单需要管理每一步的焦点和状态:
<!-- ✅ 分步表单的进度指示 -->
<nav aria-label="表单进度">
<ol>
<li aria-current="step">个人信息</li>
<li>联系方式</li>
<li>确认提交</li>
</ol>
</nav>
<!-- ✅ 每一步切换时焦点管理 -->
<form id="multi-step-form">
<fieldset id="step-1">
<legend>个人信息</legend>
<!-- 表单项 -->
</fieldset>
<fieldset id="step-2" hidden>
<legend>联系方式</legend>
<!-- 表单项 -->
</fieldset>
</form>// 分步表单焦点管理
function goToStep(stepIndex) {
// 隐藏所有步骤
document.querySelectorAll('[id^="step-"]').forEach(s => s.hidden = true);
// 显示目标步骤
const currentStep = document.getElementById(`step-${stepIndex}`);
currentStep.hidden = false;
// 焦点移动到步骤标题
currentStep.querySelector('legend').focus();
// 更新 aria-current
document.querySelectorAll('[aria-current]').forEach(el => el.removeAttribute('aria-current'));
document.querySelector(`li:nth-child(${stepIndex})`).setAttribute('aria-current', 'step');
}八、图像、媒体与颜色
8.1 图片替代文本
<!-- ✅ 信息性图片 -->
<img src="chart.png" alt="2024 年 Q1 销售额同比增长 25%" />
<!-- ✅ 装饰性图片 -->
<img src="decoration.png" alt="" />
<!-- ❌ 无意义的 alt -->
<img src="chart.png" alt="图片" />8.2 视频与音频
- 提供字幕(captions)
- 提供音频描述(audio descriptions)
- 提供文字稿(transcript)
8.3 颜色对比
WCAG AA 要求:
- 普通文本:4.5:1
- 大文本(18pt+ 或 14pt+ 粗体):3:1
工具:WebAIM Contrast Checker、Lighthouse、axe DevTools。
8.4 不要只用颜色传达信息
<!-- ❌ 色盲用户无法区分 -->
<span class="status-red">失败</span>
<span class="status-green">成功</span>
<!-- ✅ 配合图标和文字 -->
<span class="status-error"><span aria-hidden="true">✕</span> 失败</span>
<span class="status-success"><span aria-hidden="true">✓</span> 成功</span>8.5 APCA:新一代对比度算法
APCA(Accessible Perceptual Contrast Algorithm)是 WCAG 3.0 草案中提出的对比度评估方法,相比 WCAG 2.x 的简单比率有根本性改进:
| 对比 | WCAG 2.x | APCA |
|---|---|---|
| 计算方式 | 简单亮度比率 | 感知亮度差(考虑空间频率、字体粗细) |
| 文本大小 | 仅区分普通和大文本 | 连续分级的滚动比例 |
| 字体粗细 | 仅考虑粗体 | 精细区分不同字重 |
| 暗色模式 | 无特殊处理 | 考虑了暗色背景的对比感知差异 |
| 目标用户 | 对比度损失 1.5 stops | 对比度损失 3 stops(覆盖更广泛的视觉障碍) |
WCAG 3.0 APCA 建议值(青铜级):
- 正文文本(>= 12px < 18px):APCA 值 >= 75
- 大文本(>= 18px < 36px):APCA 值 >= 60
- 超大文本(>= 36px):APCA 值 >= 45
- 非文本组件:APCA 值 >= 45
8.6 深色模式的无障碍考量
深色模式不仅仅是反转颜色,需要特别注意:
/* ✅ 深色模式下的颜色对比度 */
[data-theme="dark"] {
--text-primary: #e4e4e7; /* 浅色文本 */
--text-secondary: #a1a1aa; /* 次级文本 */
--bg-primary: #18181b; /* 深色背景 */
--bg-secondary: #27272a; /* 次级背景 */
/* 检查每一项对比度是否达标 */
}
/* ❌ 深色模式下饱和度变化 */
[data-theme="dark"] .status-error {
color: #fca5a5; /* 降低红色饱和度,避免刺眼 */
}深色模式关键考量:
- 对比度维持:深色背景上的浅色文本同样需要满足 4.5:1 对比度
- 色相偏移:纯色在深色背景上看起来更亮,需要降低饱和度
- 焦点样式可见:深色背景上的焦点环需要足够的亮度差
- 图片滤镜:深色模式下可对图片添加
filter: brightness(0.8)减少眩光 - 用户偏好检测:使用
prefers-color-scheme媒体查询检测用户系统设置
九、可访问性测试
9.1 自动化测试
| 工具 | 用途 |
|---|---|
| axe DevTools | 浏览器插件,检测 WCAG 违规 |
| Lighthouse | 性能 + 可访问性评分 |
| eslint-plugin-jsx-a11y | React 项目静态检查 |
| @vue/a11y | Vue 项目静态检查 |
| Pa11y | CI 集成可访问性测试 |
| Storybook a11y addon | 组件级可访问性检查 |
9.2 手动测试
- 拔掉鼠标,全程用键盘操作。
- 打开屏幕阅读器(NVDA、JAWS、VoiceOver)。
- 使用高对比度模式或放大页面。
- 用色盲模拟器检查颜色依赖。
9.3 用户测试
邀请真实的障碍用户参与测试,这是发现自动化工具无法捕捉问题的最佳方式。
9.4 axe-core 编程式 API
axe-core 是可访问性测试引擎,可在 Node.js 环境或浏览器中编程调用:
// Node.js 中使用 axe-core API
const axe = require('axe-core');
const { JSDOM } = require('jsdom');
const html = `
<html>
<body>
<button>提交</button>
<img src="photo.jpg" />
</body>
</html>
`;
const { window } = new JSDOM(html);
axe.run(window.document, (err, results) => {
if (err) throw err;
console.log(`违反数量:${results.violations.length}`);
results.violations.forEach(v => {
console.log(`- ${v.id}: ${v.description}`);
});
});9.5 Cypress-axe 集成
在 Cypress 端到端测试中加入可访问性断言:
// cypress/support/commands.js
import 'cypress-axe';
// 测试用例示例
describe('登录页面可访问性', () => {
beforeEach(() => {
cy.visit('/login');
cy.injectAxe();
});
it('不应有严重可访问性违规', () => {
cy.checkA11y(null, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa'],
},
});
});
it('表单错误后应检查可访问性', () => {
cy.get('button[type="submit"]').click();
cy.checkA11y('[role="alert"]');
});
});9.6 Playwright 可访问性断言
Playwright 集成了 axe-core,通过 expect.toPassA11y 进行断言:
// Playwright 测试示例
import { test, expect } from '@playwright/test';
test('首页可访问性检查', async ({ page }) => {
await page.goto('/');
// 执行可访问性扫描
const violations = await page.evaluate(async () => {
const axe = await import('axe-core');
return axe.run(document).then(r => r.violations);
});
// 检查严重违规
const criticalViolations = violations.filter(v => v.impact === 'critical');
expect(criticalViolations.length).toBe(0);
});使用 Playwright 的自带定位器可结合可访问性属性选择元素:
// Playwright 可访问性定位器
await page.getByRole('button', { name: '提交' }).click();
await page.getByLabel('邮箱').fill('user@example.com');
await page.getByPlaceholder('输入搜索关键词').fill('a11y');9.7 CI 集成模式
将可访问性测试融入持续集成流水线:
# GitHub Actions 示例
name: Accessibility Check
on: [pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
# 静态检查
- run: npm run lint:a11y
# 构建并启动预览
- run: npm run build
- run: npm run preview &
# Pa11y CI 扫描
- run: npx pa11y-ci --sitemap http://localhost:3000/sitemap.xml
# 如果使用 Cypress
- run: npx cypress run --spec "cypress/e2e/a11y/**"十、无障碍架构与流程
10.1 融入研发流程
设计评审 → 检查颜色、字体、交互是否可访问
↓
开发 → 使用语义 HTML、ARIA、键盘事件
↓
Code Review → 检查焦点、标签、对比度
↓
自动化测试 → axe、Lighthouse、Pa11y
↓
发布 → 监控真实用户反馈10.2 建立无障碍规范
- 组件库必须提供可访问的默认实现。
- 设计 Token 中包含对比度要求。
- 每个新功能必须通过键盘测试。
- 定期进行可访问性审计。
十一、常见误区与反模式
| 误区 | 说明 | 正确做法 |
|---|---|---|
| "无障碍只服务少数人" | 障碍是普遍体验,每个人都会在某些场景下遇到 | 将无障碍作为默认要求 |
| "ARIA 越多越好" | 错误的 ARIA 会误导辅助技术 | 优先原生 HTML,ARIA 作补充 |
| "只要颜色好看就行" | 颜色对比不足影响可辨识 | 使用对比度检查工具 |
| "测试阶段再补无障碍" | 后期改造成本高 | 从设计和开发阶段融入 |
| "移除 outline 更美观" | 键盘用户无法看到焦点 | 使用 :focus-visible 自定义样式 |
十二、相关领域
- F06 HTML/CSS 工程化:语义化 HTML、CSS 架构
- F03 Browser:渲染流程、焦点管理
- F01 JavaScript:键盘事件、焦点控制
- E05 Design System:组件库可访问性、Design Token
十三、延伸阅读
十四、React 中的无障碍实践
React 生态系统为无障碍提供了多种成熟的解决方案。合理使用这些工具和库,可以大幅提升产品的可访问性标准。
14.1 @react-aria 与 React Aria Components
React Aria 是 Adobe 的 React Spectrum 项目中的可访问性 hooks 库,提供无样式的可访问行为:
import { useButton } from '@react-aria/button';
import { useRef } from 'react';
function AccessibleButton(props) {
const ref = useRef(null);
const { buttonProps } = useButton(props, ref);
return (
<button {...buttonProps} ref={ref} style={/* 样式由开发者控制 */}>
{props.children}
</button>
);
}React Aria 提供的关键 hooks:
useButton— 按钮的完整键盘交互和 ARIA 属性useSwitch— 开关组件的角色、状态和键盘交互useComboBox— 自动补全的全部可访问性逻辑useDialog— 弹窗的焦点管理和角色绑定useMenu/useMenuItem— 菜单的 roving tabindex 和键盘导航useSlider— 滑块的方向键控制和 ARIA 属性
// React Aria 的 Switch 组件
function ThemeSwitch() {
const state = useToggleState({ defaultSelected: false });
const ref = useRef(null);
const { inputProps } = useSwitch(
{ 'aria-label': '深色模式' },
state,
ref
);
return (
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input {...inputProps} ref={ref} />
<span>深色模式</span>
</label>
);
}14.2 Radix UI
Radix UI 提供无样式、可访问的 React 组件原语,每个组件都内置了完整的键盘交互和 ARIA 属性:
import * as Dialog from '@radix-ui/react-dialog';
function ModalExample() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button>编辑资料</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>编辑个人资料</Dialog.Title>
<Dialog.Description>更新您的个人信息</Dialog.Description>
{/* 表单内容 */}
<Dialog.Close asChild>
<button>取消</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Radix UI 自动处理的访问性特性:
- 弹窗打开/关闭时的焦点管理(焦点陷阱 + 返回触发元素)
- 模态遮罩层的 aria-hidden 管理
- 正确绑定
role="dialog"、aria-modal="true"、aria-labelledby和aria-describedby - Esc 键关闭弹窗
- 方向键导航(Tabs、Select、Menu 等组件)
14.3 Headless UI
Tailwind Labs 开发的 Headless UI 同样提供无样式、完全可访问的组件:
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
function SelectExample() {
return (
<Listbox>
<ListboxButton>请选择角色</ListboxButton>
<ListboxOptions>
<ListboxOption value="admin">管理员</ListboxOption>
<ListboxOption value="editor">编辑</ListboxOption>
<ListboxOption value="viewer">只读</ListboxOption>
</ListboxOptions>
</Listbox>
);
}Headless UI 自动处理:aria-expanded、aria-selected、listbox 角色、roving tabindex、方向键导航等。
14.4 React 组件设计中的无障碍原则
在开发自定义 React 组件时,应遵循以下原则:
// 原则一:始终关联标签
function FormField({ label, id, error, children }) {
return (
<div>
<label htmlFor={id}>{label}</label>
{children}
{error && (
<p id={`${id}-error`} role="alert" style={{ color: 'red' }}>
{error}
</p>
)}
</div>
);
}
// 原则二:状态变化同步 ARIA 属性
function AccordionItem({ title, expanded, onToggle, children }) {
return (
<div>
<button
aria-expanded={expanded}
aria-controls={`panel-${title}`}
onClick={onToggle}
>
{title}
</button>
<div
id={`panel-${title}`}
role="region"
aria-labelledby={`btn-${title}`}
hidden={!expanded}
>
{children}
</div>
</div>
);
}
// 原则三:焦点管理
function useFocusTrap(containerRef) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const focusable = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
function handleKeyDown(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
container.addEventListener('keydown', handleKeyDown);
first.focus();
return () => container.removeEventListener('keydown', handleKeyDown);
}, [containerRef]);
}React 无障碍检查清单:
- 每个交互元素都有可访问名称
- 状态变化同步更新 ARIA 属性
- 弹窗/侧边栏等临时 UI 有正确的焦点管理
- 异步区域更新使用 live region 通知
- 组件支持受控和非受控模式(状态同步)
- 样式完全使用 :focus-visible 而非 :focus
- 条件渲染的内容不会破坏焦点顺序
标签:#a11y #wcag #aria #键盘导航 #屏幕阅读器 #无障碍测试
最后更新:2026-06-25