第 22 章

可访问性(a11y)工程实践

第22章:可访问性(a11y)工程实践

a11y 直接影响 SEO 排名、法律合规性,以及所有用户的使用体验——包括在强光下用手机的你。

本章核心问题:a11y 为什么是工程问题而非慈善问题?React 特有的 a11y 挑战有哪些? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

语义化 HTML:第一原则

在 React 里用 div 实现任何交互元素是一个常见的坏习惯:

// 错误:div 没有任何语义,键盘无法聚焦,屏幕阅读器无法识别
<div onClick={handleDelete} className="delete-btn">
  删除
</div>

// 正确:button 天生具备键盘可操作性、ARIA role、焦点管理
<button onClick={handleDelete} type="button">
  删除
</button>

原生 HTML 元素自带了大量 a11y 行为,不需要手动实现:

元素 自带能力
<button> 键盘聚焦、Enter/Space 触发点击、role="button"
<a href> 键盘聚焦、Enter 触发、role="link"、右键菜单
<input> 键盘聚焦、与 <label> 的关联、role 由 type 决定
<nav> role="navigation" 地标
<main> role="main" 地标,页面主内容区域
<header> role="banner"(在顶层时)
<footer> role="contentinfo"(在顶层时)

正确的标题层级

// 错误:标题层级跳跃,为了样式而用大号标题
<h1>仪表盘</h1>
<h3>销售概览</h3>  {/* 跳过了 h2 */}
<h5>今日数据</h5>  {/* 跳过了 h4 */}

// 正确:标题层级反映内容层次结构
<h1>仪表盘</h1>
<h2>销售概览</h2>
<h3>今日数据</h3>

如果样式需要大号字体但语义上是 h3,通过 CSS 调整字体大小,而不是错用标题层级:

<h3 className="text-2xl font-bold">视觉上像 h1 的 h3</h3>

ARIA 属性:何时用,如何用

ARIA(Accessible Rich Internet Applications)是语义化 HTML 的补充,用于描述原生 HTML 无法表达的 UI 模式。ARIA 的第一原则:能用原生 HTML 实现的,不要用 ARIA

aria-label 与 aria-labelledby

当可见文本无法清晰描述元素用途时,使用 aria-label

// 图标按钮没有可见文字,必须加 aria-label
<button aria-label="关闭对话框" onClick={onClose}>
  <XIcon />
</button>

// 搜索输入框有占位文本但没有 label
<input
  type="search"
  aria-label="搜索产品"
  placeholder="输入关键词..."
/>

aria-labelledby 指向页面上已有的文本元素,适合复用已有标签:

<h2 id="section-title">用户设置</h2>
<section aria-labelledby="section-title">
  {/* 这个区域的无障碍名称来自 id="section-title" 的元素 */}
</section>

aria-describedby:补充说明

<div>
  <label htmlFor="password">密码</label>
  <input
    id="password"
    type="password"
    aria-describedby="password-hint password-error"
  />
  <p id="password-hint">密码至少 8 位,包含大小写字母和数字</p>
  {error && <p id="password-error" role="alert">{error}</p>}
</div>

aria-live:动态内容通知

当页面上有动态变化的内容(加载状态、错误提示、计数变化),屏幕阅读器不会自动感知 DOM 变化。aria-live 告诉辅助技术"这块区域有动态内容需要播报":

function StatusMessage({ message, type }: { message: string; type: 'info' | 'error' }) {
  return (
    <div
      role="status"          // 隐含 aria-live="polite"(等当前内容读完再播报)
      aria-live="polite"     // 显式声明,等效
      aria-atomic="true"     // 作为整体播报,而不是增量播报
    >
      {message}
    </div>
  );
}

// 错误信息需要立即打断:用 role="alert"(隐含 aria-live="assertive")
function ErrorAlert({ message }: { message: string }) {
  return (
    <div role="alert">
      {message}
    </div>
  );
}

注意aria-live 区域必须在页面初始化时就存在于 DOM 中,后来插入的 aria-live 区域不一定会被辅助技术识别。正确做法是始终渲染容器,通过内容的变化来触发播报。


Level 2 · 它是怎么运行的(3-5年经验)

键盘导航与焦点管理

键盘可操作性是 a11y 的核心。很多用户完全依赖键盘(或键盘等效设备),无法使用鼠标。

焦点陷阱:模态框的正确实现

当模态框打开时,焦点必须被限制在模态框内,不能逃逸到背后的内容:

import { useEffect, useRef } from 'react';

function Modal({ isOpen, onClose, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 保存打开前的焦点元素
      previousFocusRef.current = document.activeElement as HTMLElement;

      // 将焦点移到模态框
      modalRef.current?.focus();
    } else {
      // 关闭时恢复焦点
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      tabIndex={-1}          // 使 div 可聚焦,但不进入 Tab 顺序
      onKeyDown={(e) => {
        if (e.key === 'Escape') onClose();
      }}
      className="modal"
    >
      <h2 id="modal-title">确认删除</h2>
      {children}
      <button onClick={onClose}>取消</button>
      <button onClick={handleConfirm}>确认删除</button>
    </div>
  );
}

生产环境推荐使用 focus-trap-react 库来处理焦点陷阱的所有边界情况(循环 Tab、初始焦点、还原焦点等)。

对于键盘用户,每次加载新页面都要 Tab 经过导航栏才能到达主内容,这是巨大的摩擦。跳过链接允许用户直接跳到主内容:

// 放在 <body> 的最开始
function SkipLink() {
  return (
    <a
      href="#main-content"
      className="skip-link"
    >
      跳转到主内容
    </a>
  );
}

// CSS:默认不可见,聚焦时显示
// .skip-link {
//   position: absolute;
//   top: -100%;
//   left: 0;
// }
// .skip-link:focus {
//   top: 0;
// }

// 主内容区域
<main id="main-content" tabIndex={-1}>
  <PageContent />
</main>

React Router 中的焦点管理

SPA 路由切换时,屏幕阅读器不会自动感知"页面已经变了",焦点可能停在一个已消失的元素上:

import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

function RouteAnnouncer() {
  const location = useLocation();
  const announceRef = useRef<HTMLDivElement>(null);
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    // 路由变化时,将焦点移到页面标题
    headingRef.current?.focus();
  }, [location.pathname]);

  return (
    <>
      {/* 隐藏的播报区域,通知屏幕阅读器页面已变化 */}
      <div ref={announceRef} aria-live="polite" className="sr-only">
        已导航到:{document.title}
      </div>
    </>
  );
}

Level 3 · 规范怎么定义的(资深)

屏幕阅读器测试

自动化工具能发现约 30-40% 的 a11y 问题,其余问题只能通过真实的辅助技术测试发现。

macOS/iOS:VoiceOver

Windows:NVDA(免费)

测试核实清单

  1. 所有交互元素能用 Tab 到达
  2. 焦点可见(有明显的焦点环,不要 outline: none
  3. 模态框打开时焦点进入,关闭时焦点还原
  4. 表单元素都有关联的 label
  5. 错误信息被 role="alert"aria-live 播报
  6. 图标按钮都有 aria-label
  7. 路由切换后焦点位置合理

自动化检查工具

@axe-core/react(开发环境)

npm install -D @axe-core/react
// src/main.tsx(仅开发环境)
if (import.meta.env.DEV) {
  import('@axe-core/react').then(({ default: axe }) => {
    axe(React, ReactDOM, 1000); // 每秒检查一次,结果输出到控制台
  });
}

axe-core 会在浏览器控制台输出 a11y 违规,包含违规的 DOM 节点、规则说明和修复建议。

eslint-plugin-jsx-a11y(编码时)

npm install -D eslint-plugin-jsx-a11y
// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default [
  jsxA11y.flatConfigs.recommended,
  {
    rules: {
      // 根据项目实际情况调整
      'jsx-a11y/click-events-have-key-events': 'error',
      'jsx-a11y/no-noninteractive-element-interactions': 'error',
      'jsx-a11y/alt-text': 'error',
      'jsx-a11y/label-has-associated-control': 'error',
    },
  },
];

这个插件能在编写代码时捕获大量常见错误:缺少 alt 的图片、没有关联控件的 label、onClick 没有对应的 onKeyDown 等。


Level 4 · 边界与陷阱(所有人)

为什么 a11y 是工程问题,不是慈善问题

法律层面:美国 ADA(《美国残障人士法》)、欧盟 EAA(欧洲无障碍法案,2025 年全面生效),以及越来越多国家的本地法规要求数字产品符合 WCAG 标准。违规可能面临诉讼,2023 年仅美国就有超过 4000 起 a11y 相关诉讼。

SEO 层面:Google 的爬虫在很多方面类似屏幕阅读器——它依赖语义化 HTML、正确的标题层级、alt 文本来理解页面内容。a11y 好的页面在 SEO 上通常也表现更好。

工程质量层面:强制写语义化 HTML、合理的 ARIA 标签、清晰的焦点管理,实际上逼迫你写出结构更清晰、逻辑更分明的组件。a11y 是代码质量的外显指标之一。

React 特有的 a11y 问题

问题一:key 变化导致焦点丢失

当列表项的 key 在数据更新后改变,React 会卸载再挂载元素,焦点丢失。使用稳定的 ID(通常是数据的 id 字段)作为 key,而非数组索引。

问题二:条件渲染与焦点

// 错误:isEditing 切换时,焦点跳回 body
{isEditing ? <input autoFocus /> : <p>{value}</p>}

// 改进:使用 autoFocus 让输入框在出现时自动聚焦(但要确保这符合用户期望)

问题三:Portal 与焦点陷阱

Modal 通常通过 ReactDOM.createPortal 渲染到 body,使其脱离原始 DOM 层级。焦点陷阱仍然有效(焦点管理是视觉层面的),但 Escape 键的事件冒泡路径需要注意。

问题四:动态路由与标题

每次路由变化,document.title 应当更新为新页面的标题。这对屏幕阅读器用户尤其重要,他们无法从视觉布局判断当前在哪个页面:

function usePageTitle(title: string) {
  useEffect(() => {
    const prevTitle = document.title;
    document.title = `${title} | YiteAI`;
    return () => {
      document.title = prevTitle;
    };
  }, [title]);
}

a11y 不应该是项目上线前的最后一步检查,而应该从第一行代码开始就融入工程实践。使用语义化 HTML、配置 eslint-plugin-jsx-a11y、定期用真实辅助技术测试——这些习惯成本很低,但能显著提升产品的可访问性和整体质量。

本章评分
4.5  / 5  (7 评分)

💬 留言讨论