490 lines
17 KiB
Markdown
490 lines
17 KiB
Markdown
---
|
||
tags: [React, A11y, Accessibility, Frontend]
|
||
create time: 2026-04-29 22:16
|
||
---
|
||
|
||
# 可访问性
|
||
|
||
## 概述
|
||
|
||
可访问性(Accessibility,简称 a11y)确保残障用户也能正常使用应用。这不仅是道德责任,在许多国家和地区也是法律要求。本文档梳理 React 中实现无障碍的关键实践。
|
||
|
||
## WCAG 核心原则
|
||
|
||
```mermaid
|
||
graph TB
|
||
A["WCAG 2.1 四大原则"] --> B["Perceivable<br/>可感知"]
|
||
A --> C["Operable<br/>可操作"]
|
||
A --> D["Understandable<br/>可理解"]
|
||
A --> E["Robust<br/>鲁棒性"]
|
||
|
||
B --> B1["文本替代"]
|
||
B --> B2["颜色对比度 ≥ 4.5:1"]
|
||
|
||
C --> C1["键盘可达"]
|
||
C --> C2["足够的时间"]
|
||
|
||
D --> D1["可读的文本"]
|
||
D --> D2["一致导航"]
|
||
|
||
E --> E1["兼容辅助技术"]
|
||
|
||
style A fill:#F5A87D,color:#000
|
||
style B fill:#4FC08D,color:#fff
|
||
style C fill:#61DAFB,color:#000
|
||
style D fill:#A0AEC0,color:#000
|
||
style E fill:#ED8936,color:#000
|
||
```
|
||
|
||
## 语义化 HTML(最重要的一条)
|
||
|
||
> [!tip] 黄金法则
|
||
> **能用原生元素,绝不用 div + onClick。** 原生 HTML 元素天生具备键盘交互、屏幕阅读器支持和 SEO 友好能力。这是可访问性的基石,比任何 ARIA 属性都重要。
|
||
|
||
思考:为什么一个 `<div>` 加上 `onClick` 事件后,仍然不能代替 `<button>`?
|
||
|
||
答案涉及三个层面:
|
||
1. **键盘** — div 不在 Tab 焦点流中,Enter/Space 不会触发点击
|
||
2. **屏幕阅读器** — 读作"按钮,未绑定", 用户不知道它是干什么的
|
||
3. **搜索引擎** — 无法识别为可交互元素
|
||
|
||
```tsx
|
||
// ❌ 滥用 div + onClick——视觉上是按钮,实质上什么都不是
|
||
<div className="btn" onClick={() => navigate("/home")}>Home</div>
|
||
<div className="link" onClick={() => goTo("/about")}>About</div>
|
||
|
||
// ✅ 使用原生元素——天生支持键盘、屏幕阅读器、SEO
|
||
<button type="button">按钮</button>
|
||
<a href="/home">首页</a>
|
||
<a href="/about">关于</a>
|
||
```
|
||
|
||
### 常用语义标签对照表
|
||
|
||
| 功能 | 错误写法 | 正确写法 | 原因 |
|
||
|------|---------|---------|------|
|
||
| 按钮行为 | `<div onClick>` | `<button>` | 原生键盘交互 |
|
||
| 链接跳转 | `<span onClick=navigate>` | `<a href>` | 右键打开新标签、Ctrl+点击 |
|
||
| 表单输入 | `<div contentEditable>` | `<input>`/`<textarea>` | 输入法兼容、自动填充 |
|
||
| 弹窗 | `<div className="modal">` | `<dialog>` 或 role="dialog" | 焦点管理、Escape 关闭 |
|
||
| 导航区 | `<div class="nav">` | `<nav>` | 跳过导航快捷入口 |
|
||
| 侧边栏 | `<aside class="sidebar">` | `<aside>` | 页面结构角色识别 |
|
||
| 主内容 | `<main className="content">` | `<main>` | 直达主内容快捷键 |
|
||
|
||
## ARIA 属性体系
|
||
|
||
> [!warning] ARIA 铁律(ARIA Gods)
|
||
> 1. **不要使用 ARIA** —— 用原生语义化元素解决一切
|
||
> 2. **不要将原生交互改为 `role="presentation"`** —— 会破坏已有无障碍支持
|
||
> 3. **所有 ARIA 可用状态和属性必须有对应原生支持** —— 不要用 ARIA 模拟原生行为
|
||
|
||
ARIA 是最后一道防线,用于当原生 HTML 无法满足需求时。核心原则:**优先使用原生语义,ARIA 作为补充**。
|
||
|
||
### aria-label:为无声元素赋予名称
|
||
|
||
给没有可见文本的图标、装饰性按钮添加屏幕阅读器可读的描述。
|
||
|
||
```tsx
|
||
// 关闭按钮——只有图标,需要 label 告诉用户功能
|
||
<button aria-label="关闭对话框">
|
||
<CloseIcon />
|
||
</button>
|
||
|
||
// 搜索图标按钮
|
||
<button aria-label="搜索商品">
|
||
<SearchIcon />
|
||
</button>
|
||
```
|
||
|
||
> **注意**:`aria-label` 会完全替代元素的可见文本(如果有),屏幕阅读器只朗读 label 内容。如果已有清晰可见文本,优先用 `aria-labelledby` 关联。
|
||
|
||
### aria-describedby:提供补充说明
|
||
|
||
当字段需要额外解释文字时使用,屏幕阅读器会在朗读输入框名称后继续朗读描述。
|
||
|
||
```tsx
|
||
<input
|
||
aria-label="邮箱地址"
|
||
aria-describedby="email-hint"
|
||
/>
|
||
<p id="email-hint">请使用注册时使用的邮箱</p>
|
||
|
||
{/* 效果:屏幕阅读器会读 "邮箱地址,请使用注册时使用的邮箱" */}
|
||
```
|
||
|
||
### aria-live:动态内容变化通知
|
||
|
||
让屏幕阅读器自动感知并朗读动态变化的内容区域。适用于 Toast 消息、表单验证错误、实时搜索结果等场景。
|
||
|
||
```tsx
|
||
<div aria-live="polite">
|
||
{formErrors.length > 0 && (
|
||
<ul>
|
||
{formErrors.map((err, i) => <li key={i}>{err}</li>)}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
```
|
||
|
||
> [!note] polite vs assertive
|
||
> - **polite**(默认)— 等待用户当前操作空闲后再朗读,不打断用户
|
||
> - **assertive** — 立即中断当前朗读,优先播报紧急信息
|
||
>
|
||
> 非紧急提示(如"保存成功")用 polite;安全警告、支付确认用 assertive。
|
||
|
||
### aria-expanded:折叠面板展开状态
|
||
|
||
对可展开/收起的元素(手风琴、下拉菜单、折叠面板),标记当前展开状态。用户打开或关闭时,屏幕阅读器会自动读出 "已展开" 或 "已折叠"。
|
||
|
||
```tsx
|
||
<button
|
||
aria-expanded={isExpanded}
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
aria-controls="panel-content"
|
||
>
|
||
{isExpanded ? "收起" : "展开"}
|
||
<ChevronIcon rotated={isExpanded ? 180 : 0} />
|
||
</button>
|
||
<div id="panel-content" hidden={!isExpanded}>
|
||
{/* 面板内容 */}
|
||
</div>
|
||
```
|
||
|
||
> **技巧**:配合 `aria-controls` 指向被控制的内容区域 ID,帮助屏幕阅读器建立两者之间的关系。
|
||
|
||
### aria-hidden:隐藏装饰性元素
|
||
|
||
将纯装饰性的 SVG 图标或对视觉重要但屏幕阅读器不应朗读的图片隐藏。
|
||
|
||
```tsx
|
||
/* 装饰性背景图——不想让屏幕阅读器朗读文件名 */
|
||
<img src="decorative-pattern.png" alt="" aria-hidden="true" />
|
||
|
||
/* SVG 图标中,textContent 已经通过 aria-label 表达了 */
|
||
<svg aria-hidden="true" focusable="false">
|
||
<path d="M..." />
|
||
</svg>
|
||
```
|
||
|
||
> [!question] 思考:什么时候应该用 `aria-hidden="true"`,什么时候应该用 `alt=""`?
|
||
> - `aria-hidden`:告诉辅助技术"忽略这个元素"(用于重复信息,如文本旁已标注功能的图标)
|
||
> - `alt=""`(空 alt):告诉屏幕阅读器"这是一张装饰图片"(用于图片本身是装饰性的)
|
||
> - 两者有时可以配合使用
|
||
|
||
## 键盘导航
|
||
|
||
> [!warning] 核心原则
|
||
> **默认 Tab 顺序 = DOM 顺序。** 绝大多数情况下,不要通过 JavaScript 改变自然的页面阅读顺序。手动设置 `tabIndex` 是例外而非常规。
|
||
|
||
### Tab Order 管理
|
||
|
||
```tsx
|
||
// ✅ 在 Tab 流中(正常用户应该能用 Tab 到达的元素)
|
||
<NavButton tabIndex={0} />
|
||
|
||
// ✅ 仅程序化聚焦(浮动按钮、Toast 等不需要 Tab 的元素)
|
||
<FloatingActionButton tabIndex={-1} />
|
||
|
||
// ❌ 永远不要全局禁用键盘——这剥夺了所有用户的操作能力
|
||
<div tabIndex={-1} onKeyDown={handler}>...</div>
|
||
```
|
||
|
||
### 自定义快捷键 Hook
|
||
|
||
```tsx
|
||
function useKeyboardShortcut(key: string, handler: () => void) {
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
// 避免在输入框、文本域等中触发快捷键
|
||
if (e.key === key && !isInputFocused()) {
|
||
handler();
|
||
}
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [key, handler]);
|
||
}
|
||
```
|
||
|
||
> **解释**:这段代码的关键在于 `!isInputFocused()` 检查。如果不加这个判断,用户在输入密码或搜索词时按下快捷键,会导致意外行为。例如用户在搜索框中输入 "s",如果同时触发了某个搜索提交快捷键,会打断用户的正常输入。
|
||
|
||
### Focus Trap(模态框焦点陷阱)
|
||
|
||
打开模态对话框时,必须确保 Tab 键的焦点不会跑到背景内容上。焦点应该在弹窗内的可交互元素之间循环。
|
||
|
||
> [!note] 为什么需要 Focus Trap?
|
||
> 想象你打开了一个确认删除弹窗,此时按 Tab 焦点跑到了背景的菜单链接上——用户完全不知道自己在操作什么。Focus Trap 将焦点锁在弹窗内部形成闭环:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["Tab 在弹窗内"] --> B["到达最后元素"]
|
||
B -->|Shift+Tab| C["回到第一个元素"]
|
||
B -->|Tab| D["回到第一个元素\n(焦点循环)"]
|
||
C -->|"Tab"| A
|
||
style A fill:#4FC08D,color:#000
|
||
style D fill:#F5A87D,color:#000
|
||
style C fill:#61DAFB,color:#000
|
||
```
|
||
|
||
实现要点:
|
||
|
||
```tsx
|
||
import { useRef, useEffect } from "react";
|
||
|
||
function Modal({ isOpen, onClose, children }: Props) {
|
||
const modalRef = useRef<HTMLDialogElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
// 收集弹窗内所有可聚焦元素
|
||
const focusableElements = modalRef.current?.querySelectorAll(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
const firstEl = focusableElements?.[0] as HTMLElement;
|
||
const lastEl = focusableElements?.[focusableElements.length - 1] as HTMLElement;
|
||
|
||
// Tab 键循环逻辑
|
||
const handleTab = (e: KeyboardEvent) => {
|
||
if (e.key !== "Tab") return;
|
||
|
||
if (e.shiftKey) {
|
||
// Shift+Tab:从第一个元素回到最后一个
|
||
if (document.activeElement === firstEl) {
|
||
e.preventDefault();
|
||
lastEl.focus();
|
||
}
|
||
} else {
|
||
// Tab:从最后一个元素回到第一个
|
||
if (document.activeElement === lastEl) {
|
||
e.preventDefault();
|
||
firstEl.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
document.addEventListener("keydown", handleTab);
|
||
firstEl?.focus();
|
||
|
||
return () => document.removeEventListener("keydown", handleTab);
|
||
}, [isOpen]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<dialog ref={modalRef} open onClose={onClose}>
|
||
{children}
|
||
</dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
> **解释**:这个 Hook 做了三件事:
|
||
> 1. **自动聚焦首个元素** —— 打开弹窗后立即将焦点放到第一个可交互元素,让用户无需按 Tab 即可操作
|
||
> 2. **Tab 循环** —— Shift+Tab 从第一个回到最后一个,Tab 从最后一个回到第一个
|
||
> 3. **清理监听器** —— 关闭弹窗时移除事件监听,防止内存泄漏
|
||
>
|
||
> 实际项目中推荐直接使用 [`@radix-ui/react-dialog`](https://www.radix-ui.com/primitives/docs/dialog/dialog) 或 MUI/Chakra 等组件库,它们已经内置了 Focus Trap 和大量 edge case 处理。
|
||
|
||
## 颜色与视觉
|
||
|
||
> [!tip] 设计检查清单
|
||
> 1. **Contrast Ratio** — 正文文本对比度 ≥ 4.5:1,大文本 ≥ 3:1
|
||
> 2. **不依赖颜色传达信息** — 错误提示除了红色还要加图标和文字
|
||
> 3. **缩放 200% 下仍可用** — 不支持水平滚动查看内容
|
||
> 4. **深色模式 ≠ 反转全部颜色** — 需单独测试对比度
|
||
|
||
```css
|
||
/* 确保有焦点指示器 */
|
||
button:focus-visible {
|
||
outline: 2px solid #0066ff;
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* 保留系统偏好: prefers-reduced-motion / prefers-contrast */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
* { animation-duration: 0s !important; transition-duration: 0s !important; }
|
||
}
|
||
```
|
||
|
||
## React 中的关键注意点
|
||
|
||
### 表单字段关联
|
||
|
||
屏幕阅读器依赖 `<label>` 元素来告知用户当前输入框的作用。这是最基础的无障碍要求,但也是最容易被忽视的。
|
||
|
||
```tsx
|
||
// ✅ 显式关联:通过 htmlFor / id 配对(适合复杂布局)
|
||
<label htmlFor="username">用户名:</label>
|
||
<input id="username" name="username" />
|
||
|
||
// ✅ 隐式关联:input 嵌套在 label 内部(最简单可靠)
|
||
<label>
|
||
用户名:
|
||
<input name="username" />
|
||
</label>
|
||
|
||
// ❌ 缺少 label——屏幕阅读器只会读 "编辑文本,未命名"
|
||
<input name="username" placeholder="请输入用户名" />
|
||
|
||
{/* placeholder ≠ label!placeholder 消失后用户不知道该填什么 */}
|
||
```
|
||
|
||
> **解释**:`placeholder` 属性仅作为提示文字使用,当用户开始输入时它就会消失。而 `label` 始终可见且与控件绑定,是屏幕阅读器的主要信息来源。两者可以配合使用,但不能互相替代。
|
||
|
||
### 路由切换后的焦点管理
|
||
|
||
SPA 路由切换时,页面内容虽然变了,但 URL 也变了——屏幕阅读器不会自动将焦点移到新页面的顶部。需要手动处理:
|
||
|
||
```tsx
|
||
import { useEffect } from "react";
|
||
import { useLocation } from "react-router-dom";
|
||
|
||
function FocusManager() {
|
||
const location = useLocation();
|
||
|
||
useEffect(() => {
|
||
// 回到顶部 + 将焦点移到主内容区域
|
||
window.scrollTo(0, 0);
|
||
document.getElementById("main-content")?.focus();
|
||
}, [location.pathname]);
|
||
|
||
return null; // 这是一个纯逻辑组件,不渲染任何 UI
|
||
}
|
||
|
||
// HTML 中设置锚点
|
||
<main id="main-content" tabIndex={-1}>
|
||
{/* 路由内容会在这里替换 */}
|
||
</main>
|
||
```
|
||
|
||
> **解释**:这个模式对 SPA 项目尤为重要。当用户在侧边栏点击链接跳转到新页面时,屏幕阅读器用户可能仍在听旧页面的内容——因为他们不知道焦点实际上还留在原地。将焦点主动移到 `<main>` 元素上,等于告诉屏幕阅读器 "页面已经切换了,请开始朗读新内容"。
|
||
>
|
||
> Next.js 等框架内置了此行为,无需手动实现。
|
||
|
||
### Suspense fallback 可读性
|
||
|
||
```tsx
|
||
<Suspense fallback={<p>Loading profile...</p>}>
|
||
<Profile />
|
||
</Suspense>
|
||
|
||
{/* ✅ 有意义的加载文案,而非空白的 spinner */}
|
||
<Suspense fallback={<p>Loading user profile data...</p>}>
|
||
<Profile />
|
||
</Suspense>
|
||
|
||
/* ❌ 无意义的 loading —— 无法帮助等待中的用户理解发生了什么 */
|
||
<Suspense fallback={<div className="spinner" />} >
|
||
<Profile />
|
||
</Suspense>
|
||
```
|
||
|
||
### 图标按钮的可访问性
|
||
|
||
所有只有图标的按钮都必须提供描述性文本。视觉用户看到图标就明白功能,但屏幕阅读器用户只能听到一串字符。
|
||
|
||
```tsx
|
||
<Button aria-label="删除这条消息">
|
||
<TrashIcon />
|
||
</Button>
|
||
|
||
{/* 如果同时有可见文本,不需要 aria-label */}
|
||
<Button onClick={() => deleteMessage(id)}>
|
||
<TrashIcon />
|
||
删除
|
||
</Button>
|
||
```
|
||
|
||
### 跳过导航链接
|
||
|
||
长页面或复杂的导航结构应该提供一个"跳过到主内容"的快捷入口。这是 WCAG 2.4.1 的要求。
|
||
|
||
```tsx
|
||
// 放在页面最顶部,通常用 CSS 隐藏,获得焦点时才显示
|
||
<a
|
||
href="#main-content"
|
||
className="skip-link"
|
||
style={{
|
||
position: "absolute",
|
||
top: "-40px",
|
||
left: "0",
|
||
background: "#000",
|
||
color: "#fff",
|
||
padding: "8px 16px",
|
||
zIndex: 9999,
|
||
}}
|
||
onFocus={(e) => (e.currentTarget.style.top = "0")}
|
||
onBlur={(e) => (e.currentTarget.style.top = "-40px")}
|
||
>
|
||
跳到主内容
|
||
</a>
|
||
|
||
<main id="main-content" tabIndex={-1}>
|
||
{/* 页面内容 */}
|
||
</main>
|
||
```
|
||
|
||
## 测试辅助技术兼容性
|
||
|
||
> [!tip] 分层测试策略
|
||
> 自动化测试只是第一道防线——快速捕捉低 hanging fruit。但要真正保证无障碍体验,需要三层组合:
|
||
|
||
```mermaid
|
||
graph LR
|
||
A["🛡️ 自动化 lint 规则\n(eslint-plugin-jsx-a11y)"] -->|覆盖 ~30%| B["🤖 自动化测试\n(wAI11y / axe-core)"]
|
||
B -->|覆盖 ~40%| C["⌨️ 键盘 + 屏幕阅读器\n手动测试"]
|
||
C -->|发现剩余 ~30%| D["👥 真实残障用户测试"]
|
||
style A fill:#A0AEC0,color:#000
|
||
style B fill:#61DAFB,color:#000
|
||
style C fill:#4FC08D,color:#000
|
||
style D fill:#ED8936,color:#fff
|
||
```
|
||
|
||
### 自动化检测工具
|
||
|
||
| 工具 | 用途 | 集成方式 |
|
||
|------|------|---------|
|
||
| axe DevTools | Chrome/Firefox 扩展,手动审计页面 | 浏览器安装 |
|
||
| Lighthouse | CI 中生成 a11y 评分报告 | `npx lighthouse --view` |
|
||
| eslint-plugin-jsx-a11y | 编码时实时报错 | ESLint 插件 |
|
||
| wAI11y | Jest/Vitest 中的无障碍断言库 | npm 包 |
|
||
|
||
### wAI11y 基础用法
|
||
|
||
```tsx
|
||
// 单元测试示例
|
||
import { render, screen } from "@testing-library/react";
|
||
import { accessibilityChecks } from "wai-aria";
|
||
|
||
it("关闭按钮应有关闭描述", () => {
|
||
render(<Modal onClose={() => {}} isOpen />);
|
||
const closeBtn = screen.getByRole("button", { name: /关闭/i });
|
||
|
||
// 检查元素是否有正确的 aria 角色和状态
|
||
expect(closeBtn).toHaveAttribute("aria-label", "关闭对话框");
|
||
expect(closeBtn).toHaveAccessibleName();
|
||
});
|
||
```
|
||
|
||
### 手动测试清单
|
||
|
||
在发布前,务必用以下组合手动验证:
|
||
|
||
> [!example] 人工测试步骤
|
||
> 1. **纯键盘遍历** — 关掉鼠标,只用 Tab / Shift+Tab / Arrow Keys / Enter / Escape 操作完整流程
|
||
> 2. **屏幕阅读器体验** — 开启 NVDA(Windows)或 VoiceOver(Mac),浏览核心页面
|
||
> 3. **缩放测试** — 浏览器缩放至 200%,确认无内容被遮挡或水平滚动
|
||
> 4. **对比度检查** — 使用 axe DevTools 验证所有文本对比度达标
|
||
> 5. **深色模式** — 切换系统深色模式后重新走一遍流程
|
||
|
||
> [!question] 思考:为什么自动化工具只能覆盖约 30% 的可访问性问题?
|
||
> - 自动化能检测缺少 alt、颜色对比不足等技术问题
|
||
> - 但无法判断交互逻辑是否合理、ARIA 含义是否正确表达、Tab 顺序是否符合直觉——这些需要人工测试
|
||
|
||
## 关联笔记
|
||
|
||
- [[3. 生态工具篇/08-路由管理]] — 路由切换时的焦点管理策略
|
||
- [[5. 工程实践篇/16-测试]] — 使用 wAI11y 编写可访问性断言测试
|
||
- [[5. 工程实践篇/15-性能优化]] — 无障碍组件的性能考量
|