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 22:19:51 +08:00

6.6 KiB
Raw Blame History

tags, create time
tags create time
React
A11y
Accessibility
Frontend
2026-04-29 22:16

可访问性

概述

可访问性(Accessibility,简称 a11y)确保残障用户也能正常使用应用。这不仅是道德责任,在许多国家和地区也是法律要求。本文档梳理 React 中实现无障碍的关键实践。

WCAG 核心原则

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(最重要的一条)

// ❌ 滥用 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 属性体系

// 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 管理

// 默认 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(模态框焦点陷阱)

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. 深色模式 ≠ 反转全部颜色 — 需单独测试对比度
/* 确保有焦点指示器 */
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 特定点

// 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 顺序是否符合直觉——这些需要人工测试

关联笔记