vault backup: 2026-04-29 22:19:51
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
---
|
||||
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 顺序是否符合直觉——这些需要人工测试
|
||||
|
||||
## 关联笔记
|
||||
Reference in New Issue
Block a user