This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/REACT/5. 工程实践篇/17-可访问性.md
T
2026-04-29 23:36:32 +08:00

490 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-性能优化]] — 无障碍组件的性能考量