#React#TypeScript

React 组件的可访问性实战

语义 HTML 不够——自定义 React 组件(模态框/Dropdown/Tab/Toast)需要手动处理 a11y:焦点管理、键盘导航、ARIA、屏幕阅读器通知。本文讲清这些常用组件的 a11y 模式,让你写的自定义交互组件也能被所有人用。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月1日·更新于 2026年7月23日

很多前端开发者对可访问性(a11y)有一种错觉:"我用的是语义化 HTML,<button>、<nav>、<main> 都对,那应该就没问题了吧?"

错。一旦你开始写自定义交互组件——模态框、下拉菜单、标签页、Toast——原生 HTML 就没有对应语义了。一个用 <div onClick> 堆出来的"弹窗",在屏幕阅读器眼里就是一堆没名字的方块;键盘用户连"打开后焦点去哪"都不知道。WAI-ARIA Authoring Practices(APG)就是为这些"原生 HTML 表达不了"的场景准备的设计模式库,但很多团队压根没读过。

这篇讲清四类高频自定义组件的 a11y 模式,以及为什么我强烈建议你别自己手写。

一、为什么语义 HTML 还不够

原生 HTML 元素自带三层 a11y:

  • 语义角色: <button> 天然是 role="button",屏幕阅读器会念"按钮"。
  • 键盘行为: 可以 Tab 聚焦、回车/空格触发、disabled 自动跳过。
  • 焦点管理: 浏览器自动画焦点环、维护 tab order。

但 React 里大量组件没有原生等价物:Dialog、Menu、Tabs、Combobox、Toast、Tooltip……你能找到的"最像"的原生元素(dialog 元素除外)往往只是 <div>。一旦用 <div> 承载交互,上面三层全部丢失,你必须手动补:

缺失怎么补
语义角色role="dialog" 等 ARIA 角色
可聚焦tabIndex={0},键盘可触发
键盘行为onKeyDown 处理 Enter/Esc/Arrow
焦点管理打开进、关闭回、陷阱内
屏幕阅读器通知aria-live、aria-modal、aria-expanded

下面四节直接看代码。

二、模态框(Dialog):焦点陷阱与焦点回退

模态框的 a11y 有四条硬要求:

  1. role="dialog" + aria-modal="true",告诉屏幕阅读器这是模态。
  2. 打开时焦点进入模态(通常进到第一个可聚焦元素或标题)。
  3. 焦点陷阱:Tab 在模态内循环,跑不出去(否则用户 Tab 到背景页就迷路了)。
  4. 关闭后焦点回到触发器(焦点回退),Escape 键关闭。
tsx
import { useEffect, useRef } from "react";

interface ModalProps {
  open: boolean;
  onClose: () => void;
  titleId: string;
  children: React.ReactNode;
}

function Modal({ open, onClose, titleId, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!open) return;

    // 记住打开前的焦点,关闭后回到这里
    previousFocus.current = document.activeElement as HTMLElement;

    const dialog = dialogRef.current;
    if (!dialog) return;

    const getFocusable = () =>
      Array.from(
        dialog.querySelectorAll<HTMLElement>(
          'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
        )
      ).filter((el) => el.offsetParent !== null); // 排除隐藏元素

    // 焦点进入模态
    const focusable = getFocusable();
    focusable[0]?.focus();

    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        onClose();
        return;
      }
      if (e.key !== "Tab") return;

      // 焦点陷阱:Tab 在模态内循环
      const items = getFocusable();
      if (items.length === 0) {
        e.preventDefault();
        dialog.focus();
        return;
      }
      const first = items[0];
      const last = items[items.length - 1];
      const active = document.activeElement;

      if (e.shiftKey && active === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && active === last) {
        e.preventDefault();
        first.focus();
      }
    };

    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      // 焦点回退到触发器
      previousFocus.current?.focus();
    };
  }, [open, onClose]);

  if (!open) return null;

  return (
    <div
      ref={dialogRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      tabIndex={-1}
    >
      {children}
    </div>
  );
}

注意 useEffect 清理函数里的 previousFocus.current?.focus()——这是模态关闭后焦点不丢失的关键。漏掉这一行,键盘用户关掉弹窗后焦点会跑到页面顶部,等于把他扔回起点。

三、Dropdown/Menu:aria-expanded 与方向键

Dropdown 触发器和菜单之间必须用 ARIA 关联起来:

tsx
function Dropdown() {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const menuId = useId();

  return (
    <>
      <button
        ref={triggerRef}
        aria-haspopup="true"
        aria-expanded={open}
        aria-controls={menuId}
        onClick={() => setOpen((v) => !v)}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown") {
            e.preventDefault();
            setOpen(true);
          }
        }}
      >
        菜单
      </button>

      {open && (
        <ul role="menu" id={menuId}>
          <li
            role="menuitem"
            tabIndex={-1}
            onKeyDown={(e) => {
              if (e.key === "Escape") {
                setOpen(false);
                triggerRef.current?.focus(); // 关闭后回触发器
              }
            }}
          >
            编辑
          </li>
          {/* Arrow Up/Down 在 menuitem 间移动、Home/End 跳首尾 */}
        </ul>
      )}
    </>
  );
}

要点:

  • aria-expanded 是屏幕阅读器区分"按钮"和"已展开的按钮"的唯一信号。漏了它,盲人用户根本不知道菜单开了没。
  • aria-haspopup="true"(或 "menu")告诉辅助技术这是菜单类弹出。
  • 键盘模型:Enter/Space 打开,Arrow 上下移动,Escape 关闭并回到触发器,Tab 通常不参与菜单内导航(APG 推荐)。
  • 滚动/点击外部关闭要监听 click 和 scroll 事件,记得在 effect 里 cleanup。

老实说,Dropdown 的完整 APG 模式(包括 roving tabindex、type-ahead 字符跳转)极其繁琐。这种组件我从不手写——见第七节。

四、Tab 标签页:ARIA 完整模式

Tab 是 ARIA 用得最密集的组件之一,三件套 role="tablist" / tab / tabpanel 必须配对,且 aria-selected、aria-controls、aria-labelledby、tabIndex 的 roving 模式一个都不能少。

tsx
import { useId, useRef, useState } from "react";

interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
}

function Tabs({ tabs }: { tabs: TabItem[] }) {
  const [active, setActive] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const onKeyDown = (e: React.KeyboardEvent, index: number) => {
    const count = tabs.length;
    const focusTab = (next: number) => {
      setActive(next);
      requestAnimationFrame(() => tabRefs.current[next]?.focus());
    };
    switch (e.key) {
      case "ArrowRight":
        e.preventDefault();
        focusTab((index + 1) % count);
        break;
      case "ArrowLeft":
        e.preventDefault();
        focusTab((index - 1 + count) % count);
        break;
      case "Home":
        e.preventDefault();
        focusTab(0);
        break;
      case "End":
        e.preventDefault();
        focusTab(count - 1);
        break;
    }
  };

  return (
    <div>
      <div role="tablist" aria-label="设置">
        {tabs.map((tab, i) => (
          <button
            key={tab.id}
            ref={(el) => {
              tabRefs.current[i] = el;
            }}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={active === i}
            aria-controls={`panel-${tab.id}`}
            tabIndex={active === i ? 0 : -1} // roving tabindex
            onClick={() => setActive(i)}
            onKeyDown={(e) => onKeyDown(e, i)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, i) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          tabIndex={0}
          hidden={active !== i}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

几个容易翻车的点:

  • tabIndex 用 roving 模式:只有当前 tab 是 0,其它是 -1。Tab 键跳到 tablist 后只停一次,再用方向键切换。如果所有 tab 都是 0,用户得按 N 次 Tab 才能穿过 tablist,体验灾难。
  • aria-selected 而不是 aria-current:这俩不通用,Tab 必须用 aria-selected。
  • tabpanel 要 hidden:非激活的 panel 必须用 hidden 属性隐藏,而不是仅靠 CSS。这样屏幕阅读器不会读到隐藏内容。
  • 方向键切换必须同步移动焦点:光 setActive 不够,用户按方向键后期望焦点跟着走(requestAnimationFrame 等渲染完再 focus)。

五、Toast/通知:aria-live 让屏幕阅读器自动朗读

Toast 的难点不是样式,而是异步出现的 DOM 怎么通知屏幕阅读器。直接往页面塞一个 <div>,盲人用户什么都听不到——因为他们的虚拟缓冲区不知道这里有新内容。

解法是 aria-live 区域:

tsx
// 断言型错误:立刻打断,适合严重错误
<div role="alert" aria-live="assertive">
  保存失败,请重试
</div>

// 礼貌型通知:等屏幕阅读器空闲再念,适合 Toast
<div role="status" aria-live="polite">
  已保存到草稿
</div>

实战建议:

  • 优先用 role="status"(等价 aria-live="polite" + aria-atomic="true"),只在真正的错误告警用 role="alert"。assertive 会强行打断用户当前操作,滥用很烦。
  • 容器一开始就要在 DOM 里(可以是空),不要等到要发通知时才挂载——某些屏幕阅读器对后插入的 live region 不敏感。
  • 动态文字要稳定:频繁变更内容会让 NVDA 反复朗读,加一点 debounce。

六、焦点管理的三条铁律

把上面的零碎点抽出来,自定义组件的焦点管理就三条:

  1. 焦点陷阱(focus trap):模态、抽屉这类"霸占式"容器,Tab 必须在内部循环,出不去。否则背景页的可聚焦元素会"漏"进来。
  2. 焦点回退(focus restore):任何"打开后抢走焦点"的组件(模态、菜单、Command Palette),关闭时都必须把焦点还给触发它的元素。否则键盘用户直接被传送到页面顶部。
  3. 可见的焦点环(visible focus):outline: none 是 a11y 重灾区。如果你确实嫌默认 outline 丑,必须自己写一套 :focus-visible 样式替代,绝对不能裸奔。Tailwind 用户记得别在全局 reset 里把 outline 干掉。

七、别手写:用 Radix UI 或 react-aria

说了这么多,我的真实建议是:生产环境别自己手写这些组件。

  • Radix UI:无样式(可选 Tailwind 主题)、uncontrolled 友好、API 设计非常 React 风格,焦点管理和 ARIA 全部内置。@radix-ui/react-dialog、react-dropdown-menu、react-tabs 直接拿来用。
  • react-aria(Adobe):更底层、hook 形式,提供完整 a11y 行为但完全不管样式,适合要深度自定义的设计系统团队。

手写一个完全符合 APG 的 Dropdown 可能要 300 行代码,覆盖键盘导航、roving tabindex、type-ahead、焦点回退、外部点击关闭、Esc 关闭、SSR 安全……漏一个就是 bug。Radix 把这些全做了,而且被几十万项目验证过。工程师的价值不在于重新发明模态框,而在于把精力花在业务侧真正复杂的交互上。

自己写唯一的合理场景是:学习 a11y、或组件库团队没有第三方依赖预算。即便如此,也应该拿 APG 的官方示例逐条对齐测试。

八、测试:键盘走一遍,屏幕阅读器听一遍

代码写完不算完,必须测。两道关:

第一关:键盘测试。 把鼠标拔了,只用键盘:

  • Tab/Shift+Tab 能否到达所有可交互元素?
  • 焦点顺序是否符合视觉顺序?
  • 模态打开后焦点是否被困在模态内?关闭后是否回到触发器?
  • 所有操作能否只用键盘完成?

第二关:屏幕阅读器测试。 至少跑一个:

  • macOS 内置 VoiceOver(Cmd+F5 开启,免费且足够)。
  • Windows 用 NVDA(免费)。
  • 监听它实际朗读的内容:角色、名称、状态、值是否都对?有没有听到"button"、听到展开状态、听到 Toast 内容?

很多团队上线前只过视觉走查,结果 a11y 一塌糊涂。这两关各五分钟,几乎能抓出 80% 的 a11y 问题。

小结

  • 语义 HTML 只覆盖原生组件;模态、菜单、Tab、Toast 等自定义交互组件必须手动补 ARIA 和键盘行为。
  • 模态的 a11y 四件套:role="dialog" + aria-modal + 焦点陷阱 + 焦点回退(外加 Esc 关闭)。
  • Tab 必须用 roving tabindex,且 tab/tabpanel 三件套配齐 aria-selected / aria-controls / aria-labelledby,非激活 panel 用 hidden。
  • Toast 用 role="status"(polite)或 role="alert"(assertive),容器常驻 DOM。
  • 焦点管理三条铁律:陷阱、回退、可见焦点环——别 outline: none 裸奔。
  • 生产环境别手写,用 Radix UI 或 react-aria,把精力留给业务侧复杂交互。

可访问性不是给少数人的"额外福利",它是工程质量的一部分——一个键盘用户都过不去的组件,本质上就是没写完。

Related

相关推荐