前端无障碍访问 (a11y) 实践
大约 17 分钟约 5189 字
前端无障碍访问 (a11y) 实践
简介
Web 无障碍访问(Accessibility,缩写 a11y,因为首尾字母 a 和 y 之间有 11 个字母)是指确保网站和 Web 应用能被所有人使用,包括视觉、听觉、运动或认知能力有障碍的人群。根据世界卫生组织的数据,全球有超过 10 亿人(约 15%)患有某种形式的残疾。忽略无障碍不仅是道德和商业上的缺失,在很多国家和地区还违反法律法规。
本文将系统讲解前端无障碍访问的核心原则、技术实现、测试方法和最佳实践,帮助开发者构建真正包容所有人的 Web 应用。
特点
- 感知性(Perceivable):信息和界面组件必须以用户可感知的方式呈现
- 可操作性(Operable):界面组件和导航必须可操作
- 可理解性(Understandable):信息和操作必须可理解
- 健壮性(Robust):内容必须能被各种用户代理(包括辅助技术)可靠地解析
以上四项原则被称为 POUR 原则,是 WCAG 2.1 的核心框架。
核心技术实现
一、语义化 HTML
语义化 HTML 是无障碍的基石。使用正确的 HTML 元素比任何 ARIA 属性都重要。
<!-- ============ 常见语义化标签对照 ============ -->
<!-- 不好的写法 -->
<div class="header">
<div class="nav">
<div class="nav-item"><span class="link">首页</span></div>
<div class="nav-item"><span class="link">关于</span></div>
</div>
</div>
<div class="main">
<div class="article">
<div class="title">文章标题</div>
<div class="content">文章内容</div>
</div>
</div>
<div class="footer">版权信息</div>
<!-- 好的写法:使用语义化标签 -->
<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>
</main>
<footer>
<p>版权信息</p>
</footer><!-- ============ 表单语义化 ============ -->
<!-- 不好的写法 -->
<div class="form-group">
<div class="label">用户名</div>
<input type="text" class="input" />
<div class="error">用户名不能为空</div>
</div>
<!-- 好的写法 -->
<div class="form-group">
<label for="username">用户名</label>
<input
type="text"
id="username"
name="username"
required
aria-describedby="username-error"
aria-invalid="true"
autocomplete="username"
/>
<span id="username-error" role="alert" class="error">
用户名不能为空
</span>
</div>
<!-- ============ 按钮语义化 ============ -->
<!-- 不好的写法:div 作为按钮 -->
<div class="btn" onclick="submit()">提交</div>
<!-- 好的写法:使用原生 button -->
<button type="submit" class="btn">提交</button>
<!-- 如果必须使用 div,添加完整的 ARIA 和交互支持 -->
<div
role="button"
tabindex="0"
class="btn"
onclick="submit()"
onkeydown="if(event.key==='Enter'||event.key===' '){submit();event.preventDefault();}"
>
提交
</div><!-- ============ 列表语义化 ============ -->
<!-- 导航面包屑 -->
<nav aria-label="面包屑">
<ol class="breadcrumb">
<li><a href="/">首页</a></li>
<li><a href="/products">产品</a></li>
<li><a href="/products/electronics" aria-current="page">电子产品</a></li>
</ol>
</nav>
<!-- 标签页 -->
<div class="tabs">
<div role="tablist" aria-label="账户设置">
<button
role="tab"
id="tab-profile"
aria-selected="true"
aria-controls="panel-profile"
>
个人信息
</button>
<button
role="tab"
id="tab-security"
aria-selected="false"
aria-controls="panel-security"
tabindex="-1"
>
安全设置
</button>
<button
role="tab"
id="tab-notifications"
aria-selected="false"
aria-controls="panel-notifications"
tabindex="-1"
>
通知设置
</button>
</div>
<div
role="tabpanel"
id="panel-profile"
aria-labelledby="tab-profile"
tabindex="0"
>
<h2>个人信息</h2>
<!-- 表单内容 -->
</div>
<div
role="tabpanel"
id="panel-security"
aria-labelledby="tab-security"
tabindex="0"
hidden
>
<h2>安全设置</h2>
<!-- 表单内容 -->
</div>
<div
role="tabpanel"
id="panel-notifications"
aria-labelledby="tab-notifications"
tabindex="0"
hidden
>
<h2>通知设置</h2>
<!-- 表单内容 -->
</div>
</div>二、ARIA 属性详解
<!-- ============ ARIA 角色和属性 ============ -->
<!-- live region:动态内容通知 -->
<!-- aria-live="polite" 等当前朗读完成后再通知 -->
<div aria-live="polite" aria-atomic="true">
购物车已更新,共 3 件商品
</div>
<!-- aria-live="assertive" 立即中断当前朗读 -->
<div role="alert" aria-live="assertive">
表单提交失败,请检查输入
</div>
<!-- 加载状态 -->
<div role="status" aria-live="polite">
<span class="sr-only">正在加载更多数据...</span>
</div>
<!-- ============ 对话框(Modal) ============ -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
class="modal-overlay"
>
<div class="modal-content">
<h2 id="dialog-title">确认删除</h2>
<p id="dialog-description">
确定要删除这篇文章吗?此操作不可撤销。
</p>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeDialog()">取消</button>
<button class="btn-danger" onclick="confirmDelete()">确认删除</button>
</div>
</div>
</div>
<!-- ============ 下拉菜单 ============ -->
<div class="dropdown">
<button
aria-haspopup="true"
aria-expanded="false"
aria-controls="dropdown-menu"
id="dropdown-trigger"
>
更多操作
<span aria-hidden="true">▾</span>
</button>
<ul
role="menu"
id="dropdown-menu"
aria-labelledby="dropdown-trigger"
hidden
>
<li role="menuitem">
<button>编辑</button>
</li>
<li role="menuitem">
<button>复制</button>
</li>
<li role="separator"></li>
<li role="menuitem">
<button>删除</button>
</li>
</ul>
</div>
<!-- ============ 手风琴组件 ============ -->
<div class="accordion">
<h3>
<button
aria-expanded="true"
aria-controls="accordion-panel-1"
id="accordion-header-1"
>
什么是无障碍访问?
<span aria-hidden="true" class="accordion-icon"></span>
</button>
</h3>
<div
role="region"
aria-labelledby="accordion-header-1"
id="accordion-panel-1"
>
<p>
无障碍访问是指确保网站能被所有人使用,
包括有视觉、听觉、运动或认知障碍的人群。
</p>
</div>
<h3>
<button
aria-expanded="false"
aria-controls="accordion-panel-2"
id="accordion-header-2"
>
为什么无障碍很重要?
<span aria-hidden="true" class="accordion-icon"></span>
</button>
</h3>
<div
role="region"
aria-labelledby="accordion-header-2"
id="accordion-panel-2"
hidden
>
<p>
无障碍不仅影响残障人士,也影响所有临时性受限的用户,
比如在强光下看不清屏幕、单手操作手机等场景。
</p>
</div>
</div>
<!-- ============ 图片和媒体 ============ -->
<!-- 装饰性图片 -->
<img src="divider.png" alt="" role="presentation" />
<!-- 信息性图片 -->
<img
src="chart-sales.png"
alt="2025年第一季度销售额柱状图:1月120万,2月150万,3月180万,呈上升趋势"
/>
<!-- 复杂图表的详细描述 -->
<figure>
<img src="chart-sales.png" alt="季度销售趋势图" />
<figcaption>
<details>
<summary>查看图表详细数据</summary>
<table>
<caption>2025年第一季度销售数据</caption>
<thead>
<tr>
<th scope="col">月份</th>
<th scope="col">销售额(万元)</th>
<th scope="col">环比增长</th>
</tr>
</thead>
<tbody>
<tr><td>1月</td><td>120</td><td>-</td></tr>
<tr><td>2月</td><td>150</td><td>25%</td></tr>
<tr><td>3月</td><td>180</td><td>20%</td></tr>
</tbody>
</table>
</details>
</figcaption>
</figure>
<!-- 视频:提供字幕和音频描述 -->
<video controls>
<source src="tutorial.mp4" type="video/mp4" />
<track kind="captions" src="tutorial-zh.vtt" srclang="zh" label="中文字幕" default />
<track kind="subtitles" src="tutorial-en.vtt" srclang="en" label="English Subtitles" />
<track kind="descriptions" src="tutorial-desc.vtt" srclang="zh" label="音频描述" />
</video>三、键盘导航
// ============ 键盘导航管理器 ============
class KeyboardNavigationManager {
private focusableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
'details > summary',
'audio[controls]',
'video[controls]',
].join(', ');
/**
* 获取容器内所有可聚焦元素
*/
getFocusableElements(container: HTMLElement): HTMLElement[] {
const elements = Array.from(
container.querySelectorAll<HTMLElement>(this.focusableSelector)
);
return elements.filter((el) => {
// 排除不可见元素
return el.offsetParent !== null && !el.hasAttribute('hidden');
});
}
/**
* 焦点陷阱(用于 Modal 对话框)
*/
trapFocus(container: HTMLElement): () => void {
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
const focusable = this.getFocusableElements(container);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey) {
if (document.activeElement === first) {
event.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
};
container.addEventListener('keydown', handler);
return () => container.removeEventListener('keydown', handler);
}
/**
* 网格键盘导航
*/
setupGridNavigation(grid: HTMLElement): void {
grid.addEventListener('keydown', (event) => {
const cells = Array.from(
grid.querySelectorAll<HTMLElement>('[role="gridcell"], td')
);
const currentIndex = cells.indexOf(document.activeElement as HTMLElement);
if (currentIndex === -1) return;
const cols = grid.querySelector('tr')?.children.length ?? 1;
let nextIndex = -1;
switch (event.key) {
case 'ArrowRight':
nextIndex = Math.min(currentIndex + 1, cells.length - 1);
break;
case 'ArrowLeft':
nextIndex = Math.max(currentIndex - 1, 0);
break;
case 'ArrowDown':
nextIndex = Math.min(currentIndex + cols, cells.length - 1);
break;
case 'ArrowUp':
nextIndex = Math.max(currentIndex - cols, 0);
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = cells.length - 1;
break;
}
if (nextIndex >= 0 && nextIndex !== currentIndex) {
event.preventDefault();
cells[nextIndex].focus();
}
});
}
/**
* 跳过导航链接(Skip Link)
*/
setupSkipLink(): void {
const skipLink = document.createElement('a');
skipLink.href = '#main-content';
skipLink.className = 'skip-link';
skipLink.textContent = '跳到主要内容';
document.body.prepend(skipLink);
// 确保目标元素有正确的 tabindex
const mainContent = document.getElementById('main-content');
if (mainContent && !mainContent.hasAttribute('tabindex')) {
mainContent.setAttribute('tabindex', '-1');
}
}
}/* ============ 无障碍相关 CSS ============ */
/* Skip Link 样式 */
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
background: #000;
color: #fff;
text-decoration: none;
z-index: 10000;
font-size: 16px;
border-radius: 0 0 4px 4px;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
}
/* 仅屏幕阅读器可见 */
.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;
}
/* 聚焦样式(高对比度) */
*:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
/* 不推荐:移除焦点样式 */
/*
*:focus {
outline: none; <-- 永远不要这样做!
}
*/
/* 减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 高对比度模式 */
@media (forced-colors: active) {
.btn-primary {
border: 2px solid ButtonText;
}
.btn-secondary {
border: 2px solid LinkText;
}
}
/* 深色模式 */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a2e;
--text-primary: #e0e0e0;
--link-color: #64b5f6;
--focus-ring: #90caf9;
}
}四、表单无障碍
<!-- ============ 完整的无障碍表单示例 ============ -->
<form aria-label="用户注册" novalidate>
<fieldset>
<legend>基本信息</legend>
<!-- 必填字段 -->
<div class="form-group">
<label for="email">
电子邮箱
<span aria-hidden="true" class="required">*</span>
<span class="sr-only">(必填)</span>
</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
aria-required="true"
aria-describedby="email-hint email-error"
aria-invalid="false"
placeholder="your@email.com"
/>
<small id="email-hint" class="hint">请使用有效的邮箱地址</small>
<div id="email-error" role="alert" aria-live="polite" class="error"></div>
</div>
<!-- 密码字段 -->
<div class="form-group">
<label for="password">
密码
<span aria-hidden="true" class="required">*</span>
<span class="sr-only">(必填)</span>
</label>
<div class="password-input-wrapper">
<input
type="password"
id="password"
name="password"
required
minlength="8"
autocomplete="new-password"
aria-required="true"
aria-describedby="password-requirements password-strength"
aria-invalid="false"
/>
<button
type="button"
aria-label="显示密码"
aria-pressed="false"
onclick="togglePasswordVisibility(this)"
class="toggle-password"
>
<span class="icon-eye" aria-hidden="true"></span>
</button>
</div>
<ul id="password-requirements" class="requirements">
<li id="req-length" class="unmet">至少 8 个字符</li>
<li id="req-upper" class="unmet">包含大写字母</li>
<li id="req-lower" class="unmet">包含小写字母</li>
<li id="req-number" class="unmet">包含数字</li>
<li id="req-special" class="unmet">包含特殊字符</li>
</ul>
<div id="password-strength" aria-live="polite" role="status">
<!-- 动态更新密码强度 -->
</div>
</div>
<!-- 单选按钮组 -->
<fieldset>
<legend>性别(可选)</legend>
<div class="radio-group">
<input type="radio" id="gender-male" name="gender" value="male" />
<label for="gender-male">男</label>
<input type="radio" id="gender-female" name="gender" value="female" />
<label for="gender-female">女</label>
<input type="radio" id="gender-other" name="gender" value="other" />
<label for="gender-other">其他</label>
<input type="radio" id="gender-prefer-not" name="gender" value="" checked />
<label for="gender-prefer-not">不愿透露</label>
</div>
</fieldset>
<!-- 复选框 -->
<div class="form-group">
<input type="checkbox" id="agree-terms" required aria-required="true" />
<label for="agree-terms">
我已阅读并同意
<a href="/terms" target="_blank">服务条款</a>
和
<a href="/privacy" target="_blank">隐私政策</a>
</label>
</div>
</fieldset>
<button type="submit" class="btn-primary" aria-busy="false">
<span class="btn-text">注册</span>
<span class="btn-loading sr-only">注册中...</span>
</button>
</form>// ============ 表单验证与 ARIA 状态同步 ============
class AccessibleFormValidator {
private form: HTMLFormElement;
constructor(formSelector: string) {
this.form = document.querySelector(formSelector)!;
this.setupValidation();
}
private setupValidation(): void {
// 实时验证
this.form.querySelectorAll('input, textarea, select').forEach((field) => {
field.addEventListener('blur', () => this.validateField(field as HTMLInputElement));
field.addEventListener('input', () => {
if ((field as HTMLInputElement).ariaInvalid === 'true') {
this.validateField(field as HTMLInputElement);
}
});
});
// 提交验证
this.form.addEventListener('submit', (event) => {
event.preventDefault();
const isValid = this.validateAll();
if (isValid) {
this.form.submit();
} else {
this.focusFirstError();
}
});
}
private validateField(field: HTMLInputElement): boolean {
const errorElement = document.getElementById(
field.getAttribute('aria-describedby')?.split(' ').pop() ?? ''
);
if (!field.validity.valid) {
field.setAttribute('aria-invalid', 'true');
if (errorElement) {
errorElement.textContent = this.getErrorMessage(field);
errorElement.removeAttribute('hidden');
}
return false;
}
field.setAttribute('aria-invalid', 'false');
if (errorElement) {
errorElement.textContent = '';
errorElement.setAttribute('hidden', '');
}
return true;
}
private getErrorMessage(field: HTMLInputElement): string {
const validity = field.validity;
const label = this.getFieldLabel(field);
if (validity.valueMissing) return `${label}不能为空`;
if (validity.typeMismatch) return `请输入有效的${label}`;
if (validity.tooShort) return `${label}至少需要 ${field.minLength} 个字符`;
if (validity.tooLong) return `${label}不能超过 ${field.maxLength} 个字符`;
if (validity.patternMismatch) return `${label}格式不正确`;
return `${label}输入有误`;
}
private getFieldLabel(field: HTMLInputElement): string {
if (field.labels && field.labels.length > 0) {
return field.labels[0].textContent?.trim().replace(/[*(必填)]/g, '') ?? '';
}
return field.name;
}
private validateAll(): boolean {
let allValid = true;
this.form.querySelectorAll('input[required]').forEach((field) => {
if (!this.validateField(field as HTMLInputElement)) {
allValid = false;
}
});
return allValid;
}
private focusFirstError(): void {
const firstError = this.form.querySelector('[aria-invalid="true"]');
if (firstError instanceof HTMLElement) {
firstError.focus();
}
}
}五、动态内容通知
// ============ 实时更新通知 ============
class LiveRegionAnnouncer {
private static politeRegion: HTMLElement;
private static assertiveRegion: HTMLElement;
static init(): void {
// 创建全局 live region
this.politeRegion = document.createElement('div');
this.politeRegion.setAttribute('aria-live', 'polite');
this.politeRegion.setAttribute('aria-atomic', 'true');
this.politeRegion.className = 'sr-only';
document.body.appendChild(this.politeRegion);
this.assertiveRegion = document.createElement('div');
this.assertiveRegion.setAttribute('role', 'alert');
this.assertiveRegion.setAttribute('aria-live', 'assertive');
this.assertiveRegion.setAttribute('aria-atomic', 'true');
this.assertiveRegion.className = 'sr-only';
document.body.appendChild(this.assertiveRegion);
}
/** 礼貌通知(等当前朗读完成后再通知) */
static announcePolite(message: string): void {
this.politeRegion.textContent = '';
requestAnimationFrame(() => {
this.politeRegion.textContent = message;
});
}
/** 紧急通知(立即中断) */
static announceAssertive(message: string): void {
this.assertiveRegion.textContent = '';
requestAnimationFrame(() => {
this.assertiveRegion.textContent = message;
});
}
/** 通知加载状态 */
static announceLoading(isLoading: boolean): void {
if (isLoading) {
this.announcePolite('正在加载,请稍候...');
} else {
this.announcePolite('加载完成');
}
}
/** 通知列表变化 */
static announceListChange(action: string, itemName: string, total: number): void {
this.announcePolite(`已${action}"${itemName}",共 ${total} 项`);
}
/** 通知表单结果 */
static announceFormResult(success: boolean, message: string): void {
if (success) {
this.announcePolite(`操作成功:${message}`);
} else {
this.announceAssertive(`操作失败:${message}`);
}
}
}六、颜色对比度
// ============ 颜色对比度检查工具 ============
class ColorContrastChecker {
/**
* 计算 WCAG 对比度比率
* AA 标准:正常文字 >= 4.5:1,大文字 >= 3:1
* AAA 标准:正常文字 >= 7:1,大文字 >= 4.5:1
*/
static getContrastRatio(color1: string, color2: string): number {
const luminance1 = this.getRelativeLuminance(color1);
const luminance2 = this.getRelativeLuminance(color2);
const lighter = Math.max(luminance1, luminance2);
const darker = Math.min(luminance1, luminance2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* 检查是否满足 WCAG AA 标准
*/
static meetsAA(
foreground: string,
background: string,
isLargeText: boolean = false
): { passes: boolean; ratio: number; level: string } {
const ratio = this.getContrastRatio(foreground, background);
const threshold = isLargeText ? 3 : 4.5;
return {
passes: ratio >= threshold,
ratio: Math.round(ratio * 100) / 100,
level: isLargeText ? 'AA (大文字)' : 'AA (正常文字)',
};
}
/**
* 获取相对亮度
*/
private static getRelativeLuminance(hex: string): number {
const rgb = this.hexToRgb(hex);
if (!rgb) return 0;
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((c) => {
const srgb = c / 255;
return srgb <= 0.03928
? srgb / 12.92
: Math.pow((srgb + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private static hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
}
// 使用示例
const result = ColorContrastChecker.meetsAA('#767676', '#ffffff');
console.log(`对比度: ${result.ratio}:1, ${result.passes ? '通过' : '未通过'} ${result.level}`);
// 对比度: 4.54:1, 通过 AA (正常文字)七、测试工具与自动化
// ============ axe-core 自动化测试 ============
// 安装: npm install axe-core
import axe from 'axe-core';
interface A11yTestResult {
violations: axe.Result[];
passes: axe.Result[];
summary: string;
}
class A11yTester {
/**
* 测试页面或组件的无障碍性
*/
static async test(
selector: string = 'body',
options?: axe.RunOptions
): Promise<A11yTestResult> {
const context = document.querySelector(selector) ?? document;
const results = await axe.run(context, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
},
...options,
});
return {
violations: results.violations,
passes: results.passes,
summary: this.formatSummary(results),
};
}
private static formatSummary(results: axe.AxeResults): string {
const { violations, passes, incomplete } = results;
return [
`违规项: ${violations.length}`,
`通过项: ${passes.length}`,
`待检查: ${incomplete.length}`,
violations.length > 0
? '\n违规详情:\n' +
violations
.map(
(v) =>
` - [${v.impact}] ${v.description} (${v.nodes.length} 处)\n` +
v.nodes
.slice(0, 3)
.map((n) => ` 选择器: ${n.target.join(', ')}`)
.join('\n')
)
.join('\n')
: '',
].join('\n');
}
}
// ============ React 组件无障碍测试 ============
/*
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Button 组件', () => {
it('不应有无障碍违规', async () => {
const { container } = render(
<button type="button" aria-label="关闭对话框">
X
</button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
*/// ============ 持续集成中的 a11y 检查 ============
// Cypress 集成示例
/*
describe('无障碍测试', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('首页应满足 WCAG 2.1 AA 标准', () => {
cy.checkA11y(undefined, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
},
});
});
it('登录表单应满足无障碍标准', () => {
cy.get('#login-form').within(() => {
cy.checkA11y();
});
});
it('暗色模式下的对比度', () => {
cy.get('html').invoke('attr', 'data-theme', 'dark');
cy.checkA11y(undefined, {
rules: {
'color-contrast': { enabled: true },
},
});
});
});
*/八、无障碍组件封装
// ============ React 无障碍组件示例 ============
// 无障碍按钮组件
interface AccessibleButtonProps {
children: React.ReactNode;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
isLoading?: boolean;
loadingText?: string;
icon?: React.ReactNode;
disabled?: boolean;
ariaLabel?: string;
}
function AccessibleButton({
children,
onClick,
variant = 'primary',
isLoading = false,
loadingText = '加载中...',
icon,
disabled = false,
ariaLabel,
}: AccessibleButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled || isLoading}
className={`btn btn-${variant}`}
aria-busy={isLoading}
aria-label={ariaLabel}
aria-disabled={disabled || isLoading}
>
{isLoading ? (
<>
<span className="spinner" aria-hidden="true" />
<span className="sr-only">{loadingText}</span>
<span aria-hidden="true">{children}</span>
</>
) : (
<>
{icon && <span aria-hidden="true">{icon}</span>}
{children}
</>
)}
</button>
);
}
// 无障碍 Toast 通知组件
interface ToastProps {
message: string;
type: 'success' | 'error' | 'warning' | 'info';
duration?: number;
onClose: () => void;
}
function AccessibleToast({ message, type, duration = 5000, onClose }: ToastProps) {
const toastRef = React.useRef<HTMLDivElement>(null);
const role = type === 'error' ? 'alert' : 'status';
const ariaLive = type === 'error' ? 'assertive' : 'polite';
React.useEffect(() => {
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
return (
<div
ref={toastRef}
role={role}
aria-live={ariaLive}
aria-atomic="true"
className={`toast toast-${type}`}
>
<span className="toast-message">{message}</span>
<button
onClick={onClose}
aria-label="关闭通知"
className="toast-close"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}优点
- 用户覆盖面更广:让所有用户都能使用产品,包括老年人和临时受限人群
- SEO 友好:语义化 HTML 和良好的结构有助于搜索引擎索引
- 代码质量更高:无障碍实践推动更好的 HTML 结构和交互设计
- 法律合规:满足 ADA、Section 508、EN 301 549 等法规要求
缺点
- 开发成本增加:初期需要额外 15-20% 的开发时间
- 设计约束:颜色对比度、交互方式等有明确限制
- 测试复杂:需要使用多种工具和辅助技术进行测试
- 动态内容:SPA 应用中的无障碍处理比传统页面更复杂
性能注意事项
- ARIA 不是越多越好:不必要的 ARIA 属性反而会干扰屏幕阅读器
- DOM 节点数:过多的 live region 会增加辅助技术的解析负担
- 图片 alt 文本:过长的 alt 文本影响屏幕阅读器体验,控制在 150 字以内
- 焦点管理:动态内容变化后及时管理焦点,避免焦点丢失
总结
前端无障碍不是"锦上添花",而是 Web 开发的基本要求。从语义化 HTML 到 ARIA 属性,从键盘导航到颜色对比度,每个环节都需要开发者的关注。核心原则是:优先使用原生 HTML 元素和属性,ARIA 仅在原生方案不足时使用。自动化测试工具能帮助发现大部分问题,但手动测试(特别是使用屏幕阅读器)仍然不可替代。
关键知识点
| 知识点 | 要点 |
|---|---|
| WCAG 2.1 | 四大原则:感知、可操作、可理解、健壮 |
| 语义化 HTML | 无障碍的基础,优先于 ARIA |
| ARIA 角色/属性 | 仅在 HTML 原生能力不足时使用 |
| 键盘导航 | 所有功能必须可通过键盘操作 |
| 颜色对比度 | AA 标准 4.5:1(正常文字) |
| Live Region | 动态内容变化的辅助技术通知机制 |
| 焦点管理 | Modal、SPA 页面切换时的焦点处理 |
| Skip Link | 键盘用户快速跳过重复内容 |
常见误区
误区:添加了 ARIA 就等于无障碍
- 事实:ARIA 使用不当反而比不用更糟。"No ARIA is better than bad ARIA."
误区:无障碍只影响视障用户
- 事实:影响所有残障类型,也包括临时受限(如单手操作)和环境受限(如强光)
误区:无障碍会影响视觉设计
- 事实:良好的设计可以在满足无障碍要求的同时保持美观
误区:自动化工具能发现所有无障碍问题
- 事实:自动化工具通常只能发现约 30% 的问题,手动测试必不可少
误区:alt 文本就是描述图片内容
- 事实:alt 文本取决于图片的用途。装饰性图片用空 alt,功能性图片描述功能
误区:满足 WCAG AA 就万事大吉
- 事实:WCAG 是最低标准,真实的用户体验测试更重要
进阶路线
- Inclusive Design(包容性设计):从设计阶段就考虑多样性
- AOM(Accessibility Object Model):正在标准化的 JavaScript 无障碍 API
- 移动端无障碍:iOS VoiceOver、Android TalkBack 适配
- EPUB 无障碍:数字出版物的无障碍标准
- AI 辅助:利用 AI 自动生成 alt 文本和音频描述
适用场景
| 场景 | 关键措施 |
|---|---|
| 电商网站 | 表单无障碍、键盘购买流程、图片 alt |
| 内容平台 | 语义化标题、链接文本、视频字幕 |
| 后台管理 | 键盘导航、表格无障碍、错误提示 |
| 金融应用 | 表单验证、数字可读性、安全超时 |
| 在线教育 | 视频字幕、键盘操作、进度可感知 |
落地建议
- 第一步:基础设施。添加 skip link、全局 live region、统一的焦点样式
- 第二步:语义化改造。审查所有页面,替换 div 滥用为正确的语义化标签
- 第三步:键盘支持。确保所有交互元素可通过键盘访问,添加焦点陷阱
- 第四步:ARIA 补充。为自定义组件添加必要的 ARIA 角色和属性
- 第五步:颜色审查。检查所有文字/背景颜色对比度,确保满足 AA 标准
- 第六步:自动化测试。集成 axe-core 到 CI/CD 流程
- 第七步:手动测试。使用屏幕阅读器(NVDA/JAWS/VoiceOver)进行端到端测试
排错清单
复盘问题
- 上次进行无障碍审计是什么时候?发现了多少问题?
- 自动化测试的覆盖率是多少?有多少问题只能通过手动测试发现?
- 是否有用屏幕阅读器测试过核心业务流程?
- 最近是否有收到关于无障碍问题的用户反馈?
- 团队中是否有成员接受过无障碍开发的培训?
