无障碍访问A11y
What — 是什么
无障碍访问(Accessibility,简称 A11y)是确保 Web 内容对所有用户可用,包括视觉、听觉、运动和认知障碍人群。
核心概念:
- ARIA:
role、aria-label、aria-expanded等属性,为辅助技术提供语义 - 键盘导航:Tab 顺序、焦点管理、快捷键
- 语义化 HTML:正确使用
<button>、<nav>、<main>等标签 - 颜色对比度:文本与背景 WCAG AA 标准 ≥ 4.5:1
- 屏幕阅读器:NVDA、VoiceOver、JAWS 等辅助技术
WCAG 四原则(POUR):
- Perceivable(可感知):内容可被所有感官感知
- Operable(可操作):界面可用多种方式操作
- Understandable(可理解):内容和操作可被理解
- Robust(健壮):兼容各种辅助技术
关键特性:
- 语义化 HTML 是最好的无障碍,ARIA 是补充而非替代
- 键盘可达是一切交互的基础
- A11y 不是额外功能,是 Web 的基本要求
Why — 为什么
适用场景:
- 所有面向公众的网站(法律要求,如 ADA、EN 301 549)
- 企业级产品(政府、金融、医疗强制要求)
- 提升所有用户体验(A11y 改善受益所有人)
影响数据:
- 全球 15% 人口有某种形式的残疾
- 键盘可达性同时提升开发者的调试效率
- 高对比度模式同时帮助强光环境下的用户
优缺点:
- ✅ 优点:
- 扩大用户覆盖范围
- SEO 友好(语义化 HTML)
- 法律合规
- 提升代码质量
- ❌ 缺点:
- 需要额外学习和开发时间
- 动态组件(Modal、拖拽)适配复杂
How — 怎么用
语义化 HTML
<!-- ❌ 无语义 -->
<div onclick="submit()">提交</div>
<div class="nav">...</div>
<div class="main">...</div>
<!-- ✅ 语义化 -->
<button type="submit">提交</button>
<nav aria-label="主导航">...</nav>
<main>...</main>
常用语义标签:
| 标签 | 用途 | 辅助技术行为 |
|---|---|---|
<button> | 可交互按钮 | 可聚焦、可回车/空格触发 |
<a href> | 链接 | 可聚焦、可回车跳转 |
<nav> | 导航区域 | 自动识别为导航 |
<main> | 主内容 | 跳转到主内容 |
<aside> | 侧边栏 | 识别为补充内容 |
<form> | 表单 | 识别为表单区域 |
<table> | 数据表 | 识别行列关系 |
<dialog> | 对话框 | 自动管理焦点陷阱 |
ARIA 属性
<!-- aria-label:无可见文本时提供标签 -->
<button aria-label="关闭" onclick="close()">
<svg>✕</svg>
</button>
<!-- aria-labelledby:用其他元素的文本作标签 -->
<div id="dialog-title">确认删除</div>
<div role="dialog" aria-labelledby="dialog-title">
<p>此操作不可撤销</p>
</div>
<!-- aria-expanded:折叠/展开状态 -->
<button aria-expanded="false" aria-controls="panel1">
更多选项
</button>
<div id="panel1" hidden>展开的内容</div>
<!-- aria-live:动态内容通知 -->
<div aria-live="polite">已添加到购物车</div>
<div aria-live="assertive">表单验证失败</div>
<!-- role:自定义语义 -->
<div role="tablist">
<button role="tab" aria-selected="true">详情</button>
<button role="tab" aria-selected="false">评价</button>
</div>
<div role="tabpanel">详情内容</div>
键盘导航
<!-- 焦点顺序:tabindex -->
<!-- 0:可聚焦,按文档顺序 -->
<!-- -1:编程可聚焦,不在 Tab 序列中 -->
<!-- 正数:不推荐,打乱自然顺序 -->
<div tabindex="0" role="button" onclick="handleClick()"
onkeydown="if(event.key==='Enter')handleClick()">
自定义按钮
</div>
<!-- 跳过导航链接 -->
<a href="#main-content" class="skip-link">跳到主内容</a>
<nav>...</nav>
<main id="main-content">...</main>
<style>
.skip-link {
position: absolute;
top: -100%;
}
.skip-link:focus {
top: 0;
}
</style>
焦点管理:
// Modal 打开时聚焦到内部
function openModal(dialog: HTMLDialogElement) {
dialog.showModal();
const firstFocusable = dialog.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
(firstFocusable as HTMLElement)?.focus();
}
// Modal 关闭时焦点回到触发元素
function closeModal(dialog: HTMLDialogElement, trigger: HTMLElement) {
dialog.close();
trigger.focus(); // 焦点回到触发按钮
}
表单无障碍
<form>
<!-- label 关联 -->
<label for="email">邮箱</label>
<input id="email" type="email" required
aria-describedby="email-hint"
aria-invalid="false">
<span id="email-hint">请输入公司邮箱</span>
<!-- 错误状态 -->
<label for="password">密码</label>
<input id="password" type="password"
aria-describedby="password-error"
aria-invalid="true">
<span id="password-error" role="alert">密码至少8位</span>
<!-- 必填标识(不用 *,用 aria-required) -->
<label for="name">姓名</label>
<input id="name" aria-required="true">
<!-- 分组 -->
<fieldset>
<legend>联系方式</legend>
<label for="phone">电话</label>
<input id="phone" type="tel">
</fieldset>
</form>
React 无障碍
// 原生 <dialog> 实现 Modal
function ConfirmDialog({ open, onClose, onConfirm, title, children }) {
const ref = useRef<HTMLDialogElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement as HTMLElement;
ref.current?.showModal();
} else {
ref.current?.close();
triggerRef.current?.focus();
}
}, [open]);
return (
<dialog ref={ref} onClose={onClose} aria-labelledby="dialog-title">
<h2 id="dialog-title">{title}</h2>
<p>{children}</p>
<button onClick={onConfirm}>确认</button>
<button onClick={onClose}>取消</button>
</dialog>
);
}
// eslint-plugin-jsx-a11y 检查常见问题
// <img> 必须有 alt
// <button> 必须有可见文本或 aria-label
// onclick 元素需要 onkeydown 或 role
Vue 无障碍
<template>
<!-- 自定义组件暴露 ARIA 属性 -->
<div
class="accordion"
role="region"
:aria-labelledby="`header-${id}`"
>
<button
:id="`header-${id}`"
:aria-expanded="isOpen"
:aria-controls="`panel-${id}`"
@click="isOpen = !isOpen"
>
{{ title }}
</button>
<div
:id="`panel-${id}`"
role="region"
:hidden="!isOpen"
>
<slot />
</div>
</div>
</template>
自动化检测
# Lighthouse CLI
npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json
# axe-core(可集成到测试)
npm install @axe-core/react
// React 开发环境接入 axe-core
import axe from '@axe-core/react';
if (import.meta.env.DEV) {
axe(React, ReactDOM, 1000);
}
常见问题与踩坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| div 当按钮用 | 屏幕阅读器不识别 | 用 <button> 或加 role="button" + 键盘事件 |
| 图片无 alt | 辅助技术无法理解图片 | 装饰图 alt="",内容图描述性 alt |
| Modal 焦点逃逸 | Tab 可跳出对话框 | 实现焦点陷阱(<dialog> 自动处理) |
| 颜色对比度不够 | 品牌色太浅 | 用对比度检测工具,WCAG AA ≥ 4.5:1 |
| 动态内容无通知 | aria-live 未设置 | 用 aria-live="polite" 通知状态变化 |
最佳实践
- 优先语义化 HTML,ARIA 是补充不是替代
- 所有交互元素可键盘操作(Tab/Enter/Space/Escape)
- 图片必须有 alt(装饰图
alt="") - 表单 label 关联 + 错误提示
aria-describedby - 接入 Lighthouse + axe-core 自动化检测
- 用屏幕阅读器实际测试(macOS VoiceOver / Windows NVDA)
面试题
Q1: ARIA的使用原则是什么?
核心原则:“No ARIA is better than bad ARIA”。优先使用原生语义化HTML标签(button/nav/main等),ARIA仅作补充。不要给语义标签添加多余的role(如
<button role="button">是冗余的),不要用ARIA”修复”可访问性而不改变视觉行为。
Q2: 如何确保组件的键盘可达性?
所有交互元素必须可通过Tab聚焦、Enter/Space激活、Escape关闭。原生button/a自带键盘支持;自定义元素需添加tabindex=“0”、role和键盘事件监听。Modal需实现焦点陷阱(Tab不跳出对话框),关闭后焦点回到触发元素。避免使用正数tabindex打乱自然顺序。
Q3: img标签的alt属性应该怎么写?
内容图片:alt描述图片内容(如
alt="团队合影");装饰图片:alt=""(空字符串,屏幕阅读器跳过);功能图片:alt描述功能(如alt="搜索")。永远不要省略alt属性,省略时屏幕阅读器会朗读文件名。不要用alt="图片"等无意义文本。
Q4: aria-live的polite和assertive有什么区别?
polite表示变化不紧急,屏幕阅读器会在当前朗读完成后播报更新(适合购物车数量、通知消息);assertive表示变化紧急,屏幕阅读器会立即中断当前朗读播报更新(适合表单验证错误、系统警告)。优先使用polite,避免过度使用assertive造成干扰。
相关链接:
- [[HTML5语义化]]
- DOM与事件机制
- CSS选择器与优先级