React 基础体系 · 第 17/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 可访问性:语义、键盘、焦点、ARIA、Portal 和动态内容

可访问性(Accessibility,通常缩写为 A11y)不是给页面额外添加几个 aria-* 属性,而是让不同输入方式、不同感知能力和不同辅助技术的用户,都能完成与产品要求相同的任务。

对 React 应用而言,可访问性问题通常来自五个机制之间的不一致:

  1. HTML 元素表达的语义不正确;
  2. 鼠标交互没有对应的键盘路径;
  3. 焦点没有随着界面状态正确移动或恢复;
  4. ARIA 属性描述的状态与真实 DOM 不一致;
  5. Portal、异步渲染和动态内容改变了 DOM 位置或更新时机。

可以用一个简单模型理解可访问性交互:

可访问任务完成=可感知名称+可到达+可操作+状态可理解+结果可感知\text{可访问任务完成} = \text{可感知名称} + \text{可到达} + \text{可操作} + \text{状态可理解} + \text{结果可感知}

其中:

  • 可感知名称:用户和辅助技术知道控件是什么;
  • 可到达:能通过键盘或其他输入方式找到控件;
  • 可操作:操作方式不依赖鼠标;
  • 状态可理解:展开、选中、禁用、加载等状态可被表达;
  • 结果可感知:操作完成或失败后,用户能知道发生了什么。

本文使用 React 19、TypeScript 和现代浏览器能力。React 组件可以运行在服务端或客户端,但涉及 documentwindow、焦点和 Portal 的代码只能在浏览器中执行。


语义:先让 HTML 表达真实的控件含义

语义元素是什么

语义元素是浏览器能够识别用途的 HTML 元素。例如:

  • button 表示一个可触发操作的按钮;
  • a href="..." 表示一个链接;
  • nav 表示导航区域;
  • main 表示页面主内容;
  • dialog 表示对话框;
  • ulolli 表示列表结构;
  • label 表示表单控件的标签;
  • h1h6 表示标题层级。

浏览器会根据这些元素建立可访问性树。屏幕阅读器等辅助技术通常不是直接读取 React 组件,而是读取浏览器基于 DOM、HTML 语义和 ARIA 属性计算出的结果。

因此,以下代码虽然视觉上可能一样,但交互语义不同:

// 原生按钮:默认可聚焦,支持 Enter 和 Space,带 button 语义
<button type="button" onClick={onOpen}>
  打开设置
</button>

// 非语义元素:默认不可聚焦,也没有按钮语义
<div className="button" onClick={onOpen}>
  打开设置
</div>

第二种写法的问题不只是“键盘不能点击”。它还缺少:

  • 浏览器默认的按钮角色;
  • 默认的键盘行为;
  • 默认的禁用模型;
  • 辅助技术对“这是一个按钮”的识别;
  • 一些浏览器和操作系统级别的交互一致性。

优先使用原生元素通常比“给 div 补 ARIA”更可靠。

button、链接和可点击区域不能混用

按钮和链接表达的是不同的意图:

  • 按钮:改变当前页面状态、提交表单、打开菜单、展开面板;
  • 链接:导航到一个 URL,通常可以在新标签页打开、复制链接地址或使用浏览器历史。
// 改变当前页面状态
<button type="button" onClick={() => setOpen(true)}>
  编辑资料
</button>

// 导航
<a href="/profile/edit">编辑资料</a>

把所有可点击内容都写成 button 会破坏导航语义;把所有可点击内容都写成 a href="#" 会引入无意义的 URL、默认跳转和历史记录问题。

如果确实需要自定义交互元素,应先判断是否能使用原生元素。只有原生元素无法表达时,才考虑 role、键盘事件和焦点管理。

表单标签不是视觉装饰

表单控件必须有可计算的名称(accessible name)。最常见的方式是使用 label

<label htmlFor="email">邮箱地址</label>
<input id="email" name="email" type="email" autoComplete="email" />

htmlFor 必须对应控件的 id。这建立了以下关系:

label ──for──> input#email

用户点击标签时,浏览器会将焦点交给输入框;屏幕阅读器读取输入框时,也能读出“邮箱地址”。

对于视觉上隐藏但仍需被辅助技术读取的标签,可以使用 CSS 的视觉隐藏类,而不是 display: none

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
<label htmlFor="search" className="sr-only">
  搜索文章
</label>
<input id="search" type="search" />

display: nonehiddenaria-hidden="true" 通常会让内容从辅助技术可访问树中消失,因此不能用它们实现“只对屏幕阅读器可见”。

标题层级表达内容结构

标题不是单纯的字号样式。用户可能通过标题列表快速浏览页面,因此标题层级应反映内容结构:

<main>
  <h1>账户设置</h1>

  <section aria-labelledby="security-title">
    <h2 id="security-title">安全设置</h2>
    {/* ... */}
  </section>

  <section aria-labelledby="notification-title">
    <h2 id="notification-title">通知设置</h2>
    {/* ... */}
  </section>
</main>

不要为了改变字号而跳过标题层级。视觉大小应由 CSS 控制,结构应由 h1h6 表达。


键盘:定义可操作控件的完整输入模型

键盘可访问性不是“按 Tab 能找到”

键盘可访问性至少包括:

  1. 能通过 Tab 到达主要交互控件;
  2. 能使用控件规定的按键操作;
  3. 焦点顺序符合视觉和任务顺序;
  4. 弹出层、菜单和对话框中的焦点不会丢失;
  5. 用户能退出当前模式并回到合理位置。

对于原生按钮,浏览器已经提供了合适的行为:

  • Tab 将焦点移动到按钮;
  • Enter 触发按钮;
  • Space 触发按钮;
  • disabled 阻止交互并表达禁用状态。

因此,下面的实现通常不需要自己监听键盘:

<button type="button" onClick={save}>
  保存
</button>

自定义按钮必须补齐行为和状态

如果必须使用非原生元素,至少需要:

  • role="button"
  • tabIndex={0}
  • EnterSpace 行为;
  • 禁用状态;
  • 视觉焦点样式;
  • 可能还需要处理触摸、指针和表单语义。
type FauxButtonProps = {
  disabled?: boolean;
  onPress: () => void;
  children: React.ReactNode;
};

function FauxButton({
  disabled = false,
  onPress,
  children,
}: FauxButtonProps) {
  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    if (disabled) return;

    if (event.key === "Enter") {
      event.preventDefault();
      onPress();
    }

    if (event.key === " ") {
      // 阻止 Space 导致页面滚动,并在 keyup 时触发操作
      event.preventDefault();
    }
  }

  function onKeyUp(event: React.KeyboardEvent<HTMLDivElement>) {
    if (disabled) return;

    if (event.key === " ") {
      event.preventDefault();
      onPress();
    }
  }

  return (
    <div
      role="button"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled || undefined}
      onClick={disabled ? undefined : onPress}
      onKeyDown={onKeyDown}
      onKeyUp={onKeyUp}
      className={disabled ? "button is-disabled" : "button"}
    >
      {children}
    </div>
  );
}

这里的 Space 使用 keydown 阻止滚动、keyup 触发操作,是为了接近原生按钮的行为。如果在 keydownkeyup 都触发,会导致一次按键触发两次操作。

但是,这段代码仍然不如原生 button 完整。它没有自然继承浏览器的按钮行为,也可能需要额外处理辅助输入设备、拖拽、表单提交和测试工具。因此自定义按钮应当是边界情况,而不是组件库的默认实现。

不要随意使用正数 tabIndex

浏览器默认会按照 DOM 顺序管理焦点。tabIndex={0} 将元素加入自然 Tab 顺序,tabIndex={-1} 允许脚本聚焦但不加入 Tab 顺序,正数 tabIndex 会创建一套独立于 DOM 的焦点顺序。

// 适合作为脚本控制的目标
<div tabIndex={-1} ref={headingRef} />

// 通常不建议
<div tabIndex={3} />

正数 tabIndex 的问题是:随着页面新增控件,需要重新维护一套全局排序,很容易出现键盘焦点跳跃。更可靠的做法是让 DOM 顺序接近任务顺序,使用 tabIndex={-1} 作为命令式聚焦目标。

复合控件需要明确键盘模型

菜单、列表框、树、标签页、网格等组件不是“多个按钮加起来”这么简单。常见的焦点模型有两种。

roving tabIndex

只有当前项目具有 tabIndex={0},其他项目为 -1

function Tabs() {
  const [active, setActive] = useState(0);

  return (
    <div role="tablist" aria-label="账户设置">
      {["资料", "安全", "通知"].map((label, index) => (
        <button
          key={label}
          type="button"
          role="tab"
          tabIndex={active === index ? 0 : -1}
          aria-selected={active === index}
          onClick={() => setActive(index)}
        >
          {label}
        </button>
      ))}
    </div>
  );
}

实际组件还需要在方向键、Home、End 后移动 DOM 焦点:

function moveTab(
  event: React.KeyboardEvent<HTMLButtonElement>,
  index: number,
  count: number,
  focusAt: (index: number) => void,
) {
  let next = index;

  if (event.key === "ArrowRight") next = (index + 1) % count;
  if (event.key === "ArrowLeft") next = (index - 1 + count) % count;
  if (event.key === "Home") next = 0;
  if (event.key === "End") next = count - 1;

  if (next !== index) {
    event.preventDefault();
    focusAt(next);
  }
}

aria-activedescendant

容器本身保持 DOM 焦点,当前项目通过 aria-activedescendant 表达:

<div
  role="listbox"
  tabIndex={0}
  aria-activedescendant={`option-${activeIndex}`}
>
  {options.map((option, index) => (
    <div
      id={`option-${index}`}
      key={option.id}
      role="option"
      aria-selected={activeIndex === index}
    >
      {option.label}
    </div>
  ))}
</div>

这种模型要求:

  • aria-activedescendant 指向真实存在的后代或符合规范关系的元素;
  • 当前元素始终保持焦点;
  • 自己实现方向键、Home、End 等行为;
  • 视觉高亮与 aria-activedescendant 同步。

两种模型都可以成立,但不能只加 role="tab"role="listbox",却不实现对应的键盘和状态语义。


焦点:把它当作界面状态,而不是 DOM 副作用

焦点的三个阶段

复杂交互中的焦点管理通常包含三个阶段:

触发点获得焦点
      │
      ▼
打开覆盖层,焦点进入覆盖层
      │
      ▼
关闭覆盖层,焦点返回触发点

如果只实现“显示弹窗”,而不处理焦点,常见结果是:

  • 弹窗打开后焦点仍停留在背景按钮;
  • 用户按 Tab 后进入背景页面;
  • 弹窗关闭后焦点回到页面顶部;
  • React 重渲染后当前输入框失去焦点;
  • 弹窗中的内容异步加载后,用户不知道焦点在哪里。

useRef 和命令式焦点

焦点是浏览器 DOM 的命令式状态,不适合完全用 React state 表达。useRef 可以保存 DOM 节点:

import { useRef } from "react";

function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null);

  function focusSearch() {
    inputRef.current?.focus();
  }

  return (
    <>
      <button type="button" onClick={focusSearch}>
        聚焦搜索框
      </button>
      <input ref={inputRef} type="search" aria-label="搜索" />
    </>
  );
}

useRef 的特点是:

  • ref.current 的变化不会触发重新渲染;
  • 组件挂载后由 React 写入 DOM 节点;
  • 卸载时通常恢复为 null
  • 不能在服务端直接调用 .focus()
  • 不应在渲染阶段读取或修改 DOM。

React 19 支持函数组件直接接收 ref 属性:

type TextInputProps = React.ComponentProps<"input">;

function TextInput({ ref, ...props }: TextInputProps) {
  return <input ref={ref} {...props} />;
}

传统的 forwardRef 仍然是现有代码和跨版本组件库中的常见方式:

const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
  function TextInput(props, ref) {
    return <input ref={ref} {...props} />;
  },
);

在 React 19 项目中可以优先采用 ref 作为 prop;如果组件库需要兼容旧版本 React,仍需依据支持范围保留 forwardRef。这里的核心边界不变:组件应只暴露必要的命令式能力,例如 focus() 或滚动,而不是把内部 DOM 结构完全暴露给调用方。

useEffectuseLayoutEffect

当界面打开后必须立即聚焦某个元素,通常使用 useLayoutEffect,因为它在浏览器绘制前运行,可以减少“先显示未聚焦状态、再跳转焦点”的闪烁:

useLayoutEffect(() => {
  if (!open) return;
  initialFocusRef.current?.focus();
}, [open]);

但是 useLayoutEffect 在服务端没有 DOM。服务端渲染项目通常需要客户端边界或同构安全封装:

import { useEffect, useLayoutEffect } from "react";

const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? useEffect : useLayoutEffect;

这段代码只能在构建工具允许服务端和客户端共同执行模块时使用;如果框架提供 "use client" 等客户端组件边界,应把依赖 DOM 的交互组件放在客户端边界内。无论采用哪种框架,规则都是:服务端生成 HTML,客户端挂载后才能访问 DOM、读取 document.activeElement 或调用 .focus()

焦点恢复必须保存真实触发点

弹窗打开时保存触发按钮,关闭时恢复:

const triggerRef = useRef<HTMLButtonElement>(null);

function closeDialog() {
  setOpen(false);

  // 不能立即假定 DOM 已经卸载完成;
  // 关闭动画或条件渲染时应在关闭完成时恢复焦点。
  requestAnimationFrame(() => {
    triggerRef.current?.focus();
  });
}

更严谨的做法是让对话框组件接收 onCloseComplete,在退出动画完成、内容真正卸载后恢复焦点。因为如果关闭过程仍在动画中,立即调用 .focus() 可能被后续卸载或焦点管理逻辑覆盖。

还要处理触发点已不存在的情况。例如用户打开对话框后切换了路由,原按钮已经卸载,此时应选择合理的备用目标:

function restoreFocus(
  trigger: HTMLElement | null,
  fallback: HTMLElement | null,
) {
  if (trigger && document.contains(trigger)) {
    trigger.focus();
  } else {
    fallback?.focus();
  }
}

ARIA:描述语义和状态,但不能替代正确 HTML

ARIA 的作用

ARIA(Accessible Rich Internet Applications)是一组属性和角色,用来向辅助技术描述:

  • 控件是什么:role="dialog"role="tab"
  • 控件当前状态:aria-expandedaria-selectedaria-checked
  • 控件与其他节点的关系:aria-controlsaria-labelledby
  • 控件名称和说明:aria-labelaria-labelledbyaria-describedby
  • 内容更新:aria-livearia-busy

ARIA 不会自动实现交互。下面的代码只声明了“这是一个按钮”,并不会让 div 自动支持键盘:

<div role="button">删除</div>

它仍然需要焦点、键盘操作、禁用状态和视觉反馈。可以概括为:

ARIA 声明交互实现\text{ARIA 声明} \neq \text{交互实现}

可访问名称与描述

辅助技术需要为控件计算名称。常见优先顺序是:

  1. 可见文本;
  2. aria-labelledby 指向的文本;
  3. aria-label
  4. HTML 原生标签关系。

例如图标按钮必须有名称:

<button type="button" aria-label="关闭">
  <CloseIcon aria-hidden="true" />
</button>

装饰性图标应从辅助技术树中隐藏。若图标本身包含重要文字,就不能简单加 aria-hidden="true"

对于复杂说明,使用 aria-describedby

<label htmlFor="password">密码</label>
<p id="password-help">至少包含 12 个字符。</p>
<input
  id="password"
  type="password"
  aria-describedby="password-help"
/>

aria-describedby 提供补充说明,不应替代输入框的主要标签。

状态必须和 React 状态保持一致

ARIA 属性是 DOM 输出的一部分,因此必须由同一个状态源驱动:

const [expanded, setExpanded] = useState(false);

<button
  type="button"
  aria-expanded={expanded}
  aria-controls="advanced-options"
  onClick={() => setExpanded(value => !value)}
>
  高级选项
</button>

<div id="advanced-options" hidden={!expanded}>
  {/* 内容 */}
</div>

这里有三个必须同时成立的事实:

  1. aria-expanded 表示面板是否展开;
  2. aria-controls 指向面板的真实 id
  3. hidden 控制面板是否参与布局和可访问性树。

如果只更新视觉 CSS,不更新 aria-expanded,辅助技术看到的状态就会过时。

aria-hidden 的危险边界

aria-hidden="true" 会让节点及其后代从辅助技术可访问树中隐藏,但不一定让它从视觉布局和键盘顺序中消失。这样会产生严重错误:

<div aria-hidden="true">
  <button type="button">仍然可以 Tab 到</button>
</div>

视觉上可见、键盘可聚焦、但辅助技术无法读取名称的控件,称为“焦点落入隐藏树”一类的问题。隐藏整个区域时,应优先使用:

  • hidden
  • 条件渲染;
  • display: none
  • 合适的 inert

inert 会让一个子树不能被聚焦或交互,并且通常不会进入可访问性树。现代浏览器已支持原生 inert,但如果需要兼容旧环境,应确认框架目标和 polyfill 策略。


对话框:语义、焦点和背景隔离必须一起完成

一个模态对话框至少要解决:

  1. 用户知道焦点已进入对话框;
  2. 对话框有名称;
  3. Escape 可以关闭;
  4. Tab 不会绕到背景;
  5. 关闭后焦点回到触发点;
  6. 背景内容不能被继续操作;
  7. 关闭状态不会遗留在 DOM 中。

下面给出一个简化但可运行的 React 19 示例。它使用 createPortal 将内容放到 document.body,同时通过 inert 隔离应用背景。

import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { createPortal } from "react-dom";

function useIsomorphicLayoutEffect(
  effect: React.EffectCallback,
  deps: React.DependencyList,
) {
  const hook =
    typeof window === "undefined" ? useEffect : useLayoutEffect;

  hook(effect, deps);
}

function getTabbable(container: HTMLElement): HTMLElement[] {
  return Array.from(
    container.querySelectorAll<HTMLElement>(
      [
        "a[href]",
        "button:not([disabled])",
        "input:not([disabled])",
        "select:not([disabled])",
        "textarea:not([disabled])",
        "[tabindex]:not([tabindex='-1'])",
      ].join(","),
    ),
  ).filter(element => {
    const style = window.getComputedStyle(element);
    return (
      style.display !== "none" &&
      style.visibility !== "hidden" &&
      !element.hasAttribute("hidden")
    );
  });
}

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

function Modal({ open, title, onClose, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const titleId = "settings-dialog-title";

  useIsomorphicLayoutEffect(() => {
    if (!open || !dialogRef.current) return;

    const dialog = dialogRef.current;
    const previousActive = document.activeElement as HTMLElement | null;

    const tabbables = getTabbable(dialog);
    (tabbables[0] ?? dialog).focus();

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        event.preventDefault();
        onClose();
        return;
      }

      if (event.key !== "Tab") return;

      const current = getTabbable(dialog);

      if (current.length === 0) {
        event.preventDefault();
        dialog.focus();
        return;
      }

      const first = current[0];
      const last = current[current.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    dialog.addEventListener("keydown", onKeyDown);

    return () => {
      dialog.removeEventListener("keydown", onKeyDown);

      if (previousActive && document.contains(previousActive)) {
        previousActive.focus();
      }
    };
  }, [open, onClose]);

  if (!open || typeof document === "undefined") {
    return null;
  }

  return createPortal(
    <div className="modal-layer">
      <div
        className="modal-backdrop"
        onMouseDown={event => {
          if (event.target === event.currentTarget) {
            onClose();
          }
        }}
      />

      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        tabIndex={-1}
        className="modal"
      >
        <h2 id={titleId}>{title}</h2>

        <div className="modal-content">{children}</div>

        <button type="button" onClick={onClose}>
          关闭
        </button>
      </div>
    </div>,
    document.body,
  );
}

function SettingsPage() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    const app = document.getElementById("app");
    if (!app) return;

    if (open) {
      app.setAttribute("inert", "");
    } else {
      app.removeAttribute("inert");
    }

    return () => {
      app.removeAttribute("inert");
    };
  }, [open]);

  return (
    <>
      <div id="app">
        <main>
          <h1>账户设置</h1>
          <button type="button" onClick={() => setOpen(true)}>
            打开设置
          </button>
        </main>
      </div>

      <Modal
        open={open}
        title="账户设置"
        onClose={() => setOpen(false)}
      >
        <label htmlFor="display-name">显示名称</label>
        <input id="display-name" defaultValue="Ada" />
      </Modal>
    </>
  );
}

这个示例的执行顺序

假设用户聚焦“打开设置”并按下 Enter:

  1. setOpen(true) 更新 React 状态;
  2. React 渲染 Modal
  3. createPortal 把模态节点插入 document.body
  4. useIsomorphicLayoutEffect 在客户端执行;
  5. 保存打开前的 document.activeElement
  6. 查找对话框内可聚焦元素;
  7. 将焦点移动到输入框,若没有输入框则移动到对话框容器;
  8. 安装 EscapeTab 处理器;
  9. 设置应用背景的 inert
  10. 用户关闭对话框;
  11. React 卸载模态;
  12. effect 清理函数将焦点恢复到打开按钮。

getTabbable 只是一个教学示例,不是完整的可聚焦元素算法。生产组件还需要考虑:

  • contenteditable
  • audiovideo 控件;
  • disabled fieldset;
  • visibility、遮挡和动画;
  • Shadow DOM;
  • iframe;
  • 组件内嵌套的复合控件;
  • 浏览器和辅助技术差异。

生产组件库通常会使用经过验证的对话框实现,或者严格依据 WAI-ARIA Authoring Practices 实现并进行真实辅助技术测试。

dialog 元素与 role="dialog"

现代浏览器提供原生 <dialog>showModal() 会建立浏览器级模态行为,包含焦点和背景交互处理的一部分;但不同浏览器版本、关闭事件、样式和 SSR 使用方式仍需要验证。

<div role="dialog" aria-modal="true"> 是 ARIA 方式。aria-modal="true" 的含义是:辅助技术应将对话框视为模态区域。它不会自动阻止鼠标点击、Tab 或脚本访问背景,所以仍要实现背景隔离和焦点管理。

不能同时假定 aria-modal 已经完成了 inert、焦点陷阱和关闭逻辑。


Portal:DOM 位置改变,React 树关系不变

Portal 的定义

createPortal(children, domNode) 将 React 子树渲染到指定 DOM 节点,而不是当前父节点的 DOM 位置:

createPortal(<Tooltip />, document.body)

这形成两棵不同的树:

React 树:
Page
└── Modal

DOM 树:
body
├── #app
└── .modal-layer

Portal 中的组件仍然保留 React 上下文和 React 事件传播关系:

  • Context 仍从 React 父树继承;
  • React 事件可能沿 React 父子关系冒泡;
  • Provider、错误边界和状态关系仍在原 React 树中。

但它不再继承原 DOM 位置的很多效果:

  • CSS 的祖先选择器可能失效;
  • overflow: hiddentransformz-index 的层叠关系改变;
  • DOM 事件委托和原生 event.target 关系需要重新检查;
  • 屏幕阅读器看到的 DOM 顺序可能发生变化;
  • aria-labelledbyaria-describedby 的引用必须指向真实存在的唯一 id

Portal 不是自动可访问的

Portal 解决的是渲染位置问题,不解决:

  • 对话框语义;
  • 焦点进入和恢复;
  • 背景隔离;
  • Esc 关闭;
  • Tab 循环;
  • 名称和描述;
  • 动态内容播报。

一个被 Portal 到 bodydiv 仍然只是一个 div

createPortal(
  <div className="dialog">删除账户?</div>,
  document.body,
)

它不会自动成为对话框。至少需要正确的角色、名称、焦点和生命周期。

SSR 与 Portal 的边界

服务端没有 document,所以以下代码不能在服务端渲染阶段执行:

// 错误:模块执行或渲染时直接访问 document
const modalRoot = document.getElementById("modal-root");

更安全的方式是在客户端渲染阶段访问:

function ClientPortal({ children }: { children: React.ReactNode }) {
  const [container, setContainer] = useState<HTMLElement | null>(null);

  useEffect(() => {
    setContainer(document.body);
  }, []);

  if (!container) return null;

  return createPortal(children, container);
}

代价是:模态内容会在客户端 effect 后才出现。如果首屏必须包含该内容,应由框架提供稳定的客户端 Portal 容器,并确保服务端和客户端初始结构一致,避免 hydration mismatch。

客户端交互组件也应避免在渲染期间根据 window.innerWidthdocument.activeElement 等浏览器信息决定不同的初始 HTML。服务端和客户端第一次渲染结果不一致,会导致水合警告或事件绑定异常。


动态内容:更新不等于用户已经知道

React 更新 DOM 后,视觉用户可能立即看到变化,但屏幕阅读器不一定会主动读取变化。动态内容需要使用合适的 live region。

aria-live 的工作方式

aria-live 声明某个区域发生变化时是否需要通知辅助技术:

<div aria-live="polite" aria-atomic="true">
  {message}
</div>

常见等级:

  • polite:等当前朗读完成后再通知;
  • assertive:更快打断当前朗读,应谨慎使用;
  • off:默认不主动通知。

role="status" 通常适合普通状态变化,例如“已保存”;role="alert" 适合错误或需要立即注意的内容,但不应把所有提示都做成 alert。

function SaveStatus({ status }: { status: "idle" | "saving" | "saved" | "error" }) {
  const text = {
    idle: "",
    saving: "正在保存",
    saved: "已保存",
    error: "保存失败",
  }[status];

  return (
    <p role={status === "error" ? "alert" : "status"} aria-atomic="true">
      {text}
    </p>
  );
}

live region 应稳定存在

以下实现可能无法可靠播报,因为节点只在消息存在时才挂载:

// 不够稳定
return message ? <div role="status">{message}</div> : null;

更稳定的是让 live region 始终存在,只更新其文本:

function Announcement({ message }: { message: string }) {
  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

如果连续两次更新内容完全相同,例如两次都是“保存失败”,辅助技术可能不会将其视为新变化。可以在语义上增加上下文,或在确有必要时清空后再设置,但不应为了强制播报而制造重复或干扰性的通知。

加载状态要表达范围

aria-busy="true" 表示某个区域正在更新:

<section aria-labelledby="results-title" aria-busy={loading}>
  <h2 id="results-title">搜索结果</h2>

  {loading ? <p>正在加载结果</p> : <Results items={items} />}
</section>

aria-busy 不会自动显示加载动画,也不会自动阻止交互。它只是告诉辅助技术:区域内容尚未稳定。

按钮的异步提交还应表达禁用或忙碌状态:

<button
  type="submit"
  disabled={saving}
  aria-busy={saving}
>
  {saving ? "保存中…" : "保存"}
</button>

需要区分:

  • disabled:不能操作;
  • aria-busy:正在处理;
  • 文本状态:让用户知道当前阶段;
  • live region:让已经在页面其他位置的用户收到结果。

表单错误:关联字段、错误信息和提交结果

表单错误必须同时提供视觉和程序关联:

type EmailFieldProps = {
  value: string;
  error?: string;
  onChange: (value: string) => void;
};

function EmailField({ value, error, onChange }: EmailFieldProps) {
  const errorId = "email-error";

  return (
    <div>
      <label htmlFor="email">邮箱地址</label>

      <input
        id="email"
        name="email"
        type="email"
        value={value}
        onChange={event => onChange(event.target.value)}
        aria-invalid={Boolean(error)}
        aria-describedby={error ? errorId : undefined}
      />

      {error ? (
        <p id={errorId} role="alert">
          {error}
        </p>
      ) : null}
    </div>
  );
}

状态变化如下:

无错误:
aria-invalid = false 或省略
aria-describedby = 无

有错误:
aria-invalid = true
aria-describedby = email-error
email-error 节点存在并包含错误文本

只把输入框边框改红是不够的,因为颜色不能成为唯一错误信号,也不能被所有用户感知。

提交失败时,还需要决定焦点策略:

  • 错误字段较少:聚焦第一个错误字段;
  • 错误字段较多:聚焦错误摘要,再允许用户进入字段;
  • 页面跳转或服务端验证后:确保焦点进入新页面主标题或错误摘要。

聚焦错误摘要时,标题或容器通常需要 tabIndex={-1}

const summaryRef = useRef<HTMLDivElement>(null);

function showErrors() {
  setHasSubmitted(true);

  requestAnimationFrame(() => {
    summaryRef.current?.focus();
  });
}

<div
  ref={summaryRef}
  tabIndex={-1}
  role="alert"
  aria-labelledby="error-summary-title"
>
  <h2 id="error-summary-title">表单存在错误</h2>
  <p>请检查邮箱地址。</p>
</div>

不要在每次输入时强制聚焦错误消息,否则用户无法继续编辑字段。


动画、隐藏和内容卸载

CSS 隐藏方式会影响可访问性:

方式 是否参与布局 通常是否可聚焦 通常是否在可访问性树
display: none
hidden
visibility: hidden 否或占据布局视 CSS
opacity: 0 可能 可能
aria-hidden="true" 可能
inert 通常否

因此,不能仅用 opacity: 0 隐藏一个弹窗。它可能仍然出现在 Tab 顺序中,也可能继续被辅助技术识别。

有退出动画时,应区分三个状态:

closed       不在 DOM,不可访问
opening      在 DOM,焦点进入
open         在 DOM,可交互
closing      仍在 DOM,但不应允许新交互
unmounted    动画结束后移除

如果在 closing 阶段还允许 Tab 进入内部控件,焦点管理会出现竞态。常见解决方案是:

  • 在关闭开始时立即移除交互能力;
  • 在动画结束后再卸载;
  • 焦点恢复绑定到关闭完成事件;
  • 不让 React 状态、CSS 动画和 Portal 清理分别管理同一个生命周期。

用户开启“减少动态效果”时,应尊重 prefers-reduced-motion

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

React 并发和可访问性状态的一致性

React 可能将状态更新批处理或推迟提交。可访问性属性、视觉状态和事件处理必须来自同一份状态,而不能各自维护副本。

不可靠的结构:

const [open, setOpen] = useState(false);
const [ariaExpanded, setAriaExpanded] = useState(false);

<button
  aria-expanded={ariaExpanded}
  onClick={() => {
    setOpen(value => !value);
    setAriaExpanded(value => !value);
  }}
>
  更多
</button>

{open && <Panel />}

两个 state 可能因不同事件、异常路径或后续重构而不同步。

可靠的结构:

const [open, setOpen] = useState(false);

<button
  type="button"
  aria-expanded={open}
  aria-controls="more-panel"
  onClick={() => setOpen(value => !value)}
>
  更多
</button>

<div id="more-panel" hidden={!open}>
  <Panel />
</div>

这里 open 同时决定:

  • 视觉展开;
  • aria-expanded
  • hidden
  • 后续焦点逻辑的触发条件。

使用 startTransition 更新非关键内容时,也应保证关键反馈先发生。例如点击保存后,按钮的 disabled 和“保存中”状态属于操作反馈,不应依赖一个可能延迟的非紧急更新;搜索结果列表则可以作为非关键内容更新。

另外,React Strict Mode 在开发环境可能重复执行 effect 的安装和清理,用来暴露副作用不对称问题。焦点监听、键盘监听、Portal 容器创建和 inert 修改都必须可重复安装和清理:

useEffect(() => {
  document.addEventListener("keydown", onKeyDown);

  return () => {
    document.removeEventListener("keydown", onKeyDown);
  };
}, [onKeyDown]);

如果 effect 添加了监听却没有精确移除,开发环境可能表现为一次按键触发多次关闭,生产中则可能形成泄漏。


唯一 ID、SSR 和 useId

ARIA 关系依赖唯一且稳定的 ID:

<h2 id={titleId}>设置</h2>
<div role="dialog" aria-labelledby={titleId} />

在列表或多个组件实例中,手写固定 ID 会产生重复:

// 多个实例会生成相同的 id
const titleId = "dialog-title";

React 提供 useId 生成适合同构渲染的稳定 ID:

import { useId } from "react";

function DialogTitle() {
  const id = useId();

  return (
    <>
      <h2 id={`${id}-title`}>设置</h2>
      <div role="dialog" aria-labelledby={`${id}-title`} />
    </>
  );
}

useId 适合用于标签关联、描述关联和组件内部 ID。它不适合:

  • 业务实体 ID;
  • 数据库主键;
  • 需要跨请求持久化的 ID;
  • 依赖随机值的缓存键。

如果服务端和客户端渲染路径不同,例如条件分支导致 useId 调用顺序不同,就仍然可能出现 hydration 问题。Hooks 调用顺序和初始树结构必须保持一致。


组件库与设计系统中的可访问性边界

可访问性不能只靠业务页面补救。组件 API 应当把语义和状态作为接口的一部分。

组件应暴露语义,而不是只暴露样式

一个 Button 组件不应只接受 classNameonClick,还应保留原生按钮属性:

type ButtonProps = React.ComponentProps<"button"> & {
  variant?: "primary" | "secondary";
};

function Button({
  variant = "primary",
  className,
  ...props
}: ButtonProps) {
  return (
    <button
      type="button"
      className={`button button-${variant} ${className ?? ""}`}
      {...props}
    />
  );
}

组件库若把所有东西都渲染成 div,业务层很难恢复正确的默认行为。

组合 API 要定义责任边界

例如一个 Dialog 组件可以内部负责:

  • role="dialog"
  • aria-labelledby
  • 焦点进入和恢复;
  • Escape
  • Tab 循环;
  • 背景 inert
  • Portal。

调用方负责:

  • 标题内容;
  • 业务关闭条件;
  • 表单校验;
  • 异步保存状态。

如果调用方还要手动设置 aria-modal、自己监听 Esc、自己恢复焦点,就容易出现重复逻辑和竞态。

Token 不能牺牲焦点可见性

设计 Token 应包含焦点环的颜色、宽度、偏移,而不是把 outline 全局清除:

:focus-visible {
  outline: 3px solid var(--color-focus-ring);
  outline-offset: 3px;
}

:focus-visible 允许浏览器根据输入方式决定何时显示强焦点指示。键盘用户必须始终能看到当前焦点;不能用下面的全局规则替代:

/* 错误倾向:隐藏所有焦点 */
*:focus {
  outline: none;
}

如果组件库改变主题,应验证焦点环、错误颜色、禁用颜色和背景对比度,而不是只验证普通文本。


常见失败表现与诊断路径

看起来能用,但辅助技术读不懂

表现:

  • 图标按钮被读成“按钮”但没有名称;
  • 展开面板视觉打开,仍被读成关闭;
  • 输入框报错但屏幕阅读器没有读取错误;
  • 对话框打开后没有标题。

诊断步骤:

  1. 用浏览器开发者工具查看 Accessibility Tree;
  2. 检查控件的 role、name、description、state;
  3. 确认 aria-labelledbyaria-describedby 的目标 ID 存在且唯一;
  4. 检查实际 DOM 是否与 React state 一致;
  5. 使用至少一种屏幕阅读器验证真实朗读结果。

Tab 顺序跳到不可见区域

表现:

  • 弹窗关闭后仍能 Tab 到其中的按钮;
  • opacity: 0 的菜单继续获取焦点;
  • aria-hidden="true" 的区域含有可聚焦控件。

诊断步骤:

  1. 连续按 Tab,记录每次 document.activeElement
  2. 对每个焦点元素检查祖先是否隐藏或 inert
  3. 查看 CSS 是否只改变透明度;
  4. 在关闭路径中确认节点是否卸载或移除交互能力。

可以在开发环境临时记录:

document.addEventListener("focusin", event => {
  const target = event.target;
  if (target instanceof HTMLElement) {
    console.log("focus:", target.tagName, target.id, target.textContent);
  }
});

Portal 中点击背景导致事件误判

React 事件沿 React 树传播,而 DOM 节点已经位于 body。如果页面父组件监听 onClick,Portal 内的点击可能仍然触发该 React 父组件的处理逻辑。

因此,判断“点击是否发生在遮罩层”时,不要只依赖 React 父级事件冒泡,应直接比较目标:

function onBackdropMouseDown(
  event: React.MouseEvent<HTMLDivElement>,
) {
  if (event.target === event.currentTarget) {
    close();
  }
}

event.target 是真正点击的节点,event.currentTarget 是当前处理器绑定的遮罩节点。只有二者相等,才表示点击了遮罩空白本身,而不是对话框内部内容。

自动化测试通过,但键盘仍不可用

axe、eslint 插件和组件快照可以发现一部分问题,例如缺少 label、重复 ID、错误 ARIA 关系,但不能完全验证:

  • Tab 顺序是否符合任务;
  • Escape 是否关闭并恢复焦点;
  • 屏幕阅读器是否按预期朗读动态内容;
  • 复合控件的方向键模型;
  • 动画结束时焦点是否丢失。

最低限度的手动路径应包括:

加载页面
→ 只用 Tab 找到入口
→ 用 Enter/Space 操作
→ 验证焦点位置
→ 验证 Escape、Shift+Tab、方向键
→ 关闭后验证焦点恢复
→ 制造错误和加载状态
→ 验证状态和结果可感知

自动化测试可以检查关键 DOM 结果:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("打开对话框后焦点进入并可关闭", async () => {
  const user = userEvent.setup();

  render(<SettingsPage />);

  const trigger = screen.getByRole("button", { name: "打开设置" });
  await user.click(trigger);

  expect(
    screen.getByRole("dialog", { name: "账户设置" }),
  ).toBeVisible();

  expect(screen.getByLabelText("显示名称")).toHaveFocus();

  await user.keyboard("{Escape}");

  expect(trigger).toHaveFocus();
});

这个测试验证的是 DOM 焦点和角色名称,不代表已经完成真实屏幕阅读器测试。两者的覆盖范围不同,不能互相替代。


规范保证、浏览器实现与工程建议的区别

以下内容应明确区分:

  • 规范或平台语义:原生 button 的默认键盘行为、label 与表单控件的关联、DOM 顺序影响 Tab 顺序;
  • 常见浏览器实现inert 的支持、dialog 的具体行为、屏幕阅读器对 live region 的播报时机;
  • 工程建议:使用 useLayoutEffect 减少焦点闪烁、采用稳定的 Portal 容器、在组件库统一管理 Dialog 焦点。

尤其是 ARIA 播报,浏览器、操作系统和屏幕阅读器共同决定最终体验。aria-live="polite" 表示通知策略,不是对所有辅助技术都能保证完全相同的朗读时机。

同样,自动化工具报告“没有可访问性违规”只能说明其规则集未发现已覆盖的问题,不能推出应用对所有用户都可用。


最终的因果链

一个可访问的 React 交互组件,应能沿着以下因果链解释:

真实任务
  ↓
选择正确 HTML 语义
  ↓
浏览器提供基础焦点和键盘行为
  ↓
React state 驱动视觉状态与 ARIA 状态
  ↓
useRef/useEffect 管理必要的 DOM 命令
  ↓
Portal 改变 DOM 位置时补齐焦点和背景隔离
  ↓
异步更新通过 live region、busy 和错误关联传达
  ↓
键盘、Accessibility Tree、屏幕阅读器共同验证

如果一个组件需要大量 onKeyDownroletabIndex 和手动焦点代码,首先应重新检查它是否本来可以由原生 HTML 完成。原生语义是最可靠的基础;ARIA 用来补充复杂关系和状态;useRef 负责不可避免的命令式焦点边界;Portal 只负责渲染位置;动态内容则需要显式设计结果通知。把这些职责分开并保持状态一致,React 应用的可访问性才不会依赖偶然的视觉表现。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。