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

239 lines
6.6 KiB
Markdown
Raw Normal View History

2026-04-29 22:19:51 +08:00
---
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(最重要的一条)
```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>` |
| 表单输入 | `<div contentEditable>` | `<input>`/`<textarea>` |
| 弹窗 | `<div className="modal">` | `<dialog>` 或 role="dialog" |
| 导航区 | `<div class="nav">` | `<nav>` |
| 侧边栏 | `<aside class="sidebar">` | `<aside>` |
## aria 属性体系
```tsx
// 1. aria-label —— 给无文本内容的图标添加描述
<button aria-label="关闭对话框">
<CloseIcon />
</button>
// 2. aria-describedby —— 关联说明文字
<input
aria-label="邮箱地址"
aria-describedby="email-hint"
/>
<p id="email-hint">请使用注册时使用的邮箱</p>
// 3. aria-live —— 动态内容变化通知屏幕阅读器
<div aria-live="polite">
{formErrors.length > 0 && (
<p>{formErrors.join("、")}</p>
)}
</div>
{/* polite = 等待空闲再朗读;assertive = 立即打断 */}
// 4. aria-expanded —— 折叠面板展开状态
<button aria-expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? "收起" : "展开"}
<ChevronIcon rotated={isExpanded ? 180 : 0} />
</button>
// 5. aria-hidden — 装饰性元素隐藏给屏幕阅读器
<img src="decorative-pattern.png" alt="" aria-hidden="true" />
```
## 键盘导航
### Tab Order 管理
```tsx
// 默认 Tab 顺序 = DOM 顺序,不要随意改变!
// 需要自定义时:
// ✅ 用 tabIndex 控制焦点位置(仅必要场景)
<NavButton tabIndex={0} /> {/* 在 Tab 流中 */}
<FloatingActionButton tabIndex={-1} /> {/* 仅程序化聚焦 */}
// ❌ 不要设置负 tabIndex 阻止所有键盘操作
// 自定义键盘快捷键
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]);
}
```
### Focus Trap(模态框焦点陷阱)
```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;
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
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>
);
}
```
## 颜色与视觉
> [!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 特定点
```tsx
// 1. Suspense fallback 要提供有意义的 loading 文案
<Suspense fallback={<p>Loading profile...</p>}>
<Profile />
</Suspense>
// 2. Form 字段必须有 label
<label htmlFor="username">用户名:</label>
<input id="username" name="username" />
{/* 或隐式关联 */}
<label>
用户名:
<input name="username" />
</label>
// 3. 图标按钮必须有 aria-label
<Button aria-label="删除这条消息">
<TrashIcon />
</Button>
// 4. 路由切换后,屏幕阅读器应知道页面变了
// Next.js Router 自动处理;SPA 项目中可手动聚焦顶部
useEffect(() => {
window.scrollTo(0, 0);
document.getElementById("main-content")?.focus();
}, [location.pathname]);
```
## 测试辅助技术兼容性
| 工具 | 用途 |
|------|------|
| axe DevTools | Chrome/Firefox 扩展,自动检测 a11y 问题 |
| Lighthouse | 生成 a11y 评分报告 |
| NVDA / VoiceOver | 免费屏幕阅读器(Win / Mac) |
| wAI11y | Jest/Vitest 断言库 |
> [!question] 思考:为什么自动化工具只能覆盖约 30% 的可访问性问题?
> - 自动化能检测缺少 alt、颜色对比不足等技术问题
> - 但无法判断交互逻辑是否合理、ARIA 含义是否正确表达、Tab 顺序是否符合直觉——这些需要人工测试
## 关联笔记