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

React Portal:事件、焦点、层级、可访问性和弹窗架构

createPortal 经常被用来实现 Modal、Tooltip、Popover、下拉菜单和全局通知。但 Portal 只解决了一个问题:把一段 React 子树渲染到另一个 DOM 容器。它不会自动解决事件边界、键盘焦点、z-index、屏幕阅读器语义、服务端渲染或弹窗状态管理。

如果把 Portal 简化成“把 DOM 挪到 body”,很多问题都会被错误归因。更准确的模型是:

  • React 树中的父子关系保持不变
  • 真实 DOM 树中的位置发生改变
  • React 事件、Context 和组件生命周期主要依赖前者;
  • CSS、原生 DOM 事件、焦点和辅助技术主要依赖后者。

后文所有结论都建立在这两个树同时存在的事实之上。


1. Portal 到底改变了什么

1.1 最小 API

React DOM 提供的 API 是:

import { createPortal } from 'react-dom';

createPortal(children, domNode, optionalKey?)

它返回一个可以放进 JSX 的 React 节点:

function Modal({ open }: { open: boolean }) {
  if (!open) {
    return null;
  }

  const host = document.getElementById('modal-root');

  if (!host) {
    throw new Error('Missing #modal-root');
  }

  return createPortal(
    <div role="dialog">
      Dialog content
    </div>,
    host,
  );
}

这里的 children 仍然是 Modal 返回结果的一部分。改变的是 children 最终插入的 DOM 容器,而不是它在 React 组件树中的逻辑父节点。

典型 HTML 结构如下:

<body>
  <div id="root"></div>
  <div id="modal-root"></div>
</body>

React 组件树可能是:

App
└── Page
    └── Modal
        └── DialogContent

真实 DOM 树却可能是:

body
├── #root
│   └── App 页面内容
└── #modal-root
    └── DialogContent

因此,Portal 不是 React 组件树的“断开”,而是 React 子树与 DOM 父节点之间的重新连接。

1.2 Portal 不等于新 React 根

下面两种代码的语义不同:

// Portal:仍属于当前 React 树
createPortal(<Dialog />, document.body);
// 新建一个独立 React 根
createRoot(document.body).render(<Dialog />);

Portal 仍然继承当前 React 树中的:

  • Context;
  • 状态和 props 数据流;
  • Error Boundary 关系;
  • 事件传播关系;
  • 组件挂载、更新和卸载生命周期。

独立 createRoot 则是另一棵 React 树。它不会自动继承原树的 Context,也不能依赖原树的 React 事件传播。


2. 事件:DOM 位置改变了,React 冒泡路径没有按 DOM 重新计算

2.1 React 事件的逻辑路径

考虑以下组件:

import { createPortal } from 'react-dom';

function Parent() {
  return (
    <div
      onClick={() => {
        console.log('parent click');
      }}
    >
      <ModalButton />
    </div>
  );
}

function ModalButton() {
  return createPortal(
    <button
      onClick={() => {
        console.log('button click');
      }}
    >
      Confirm
    </button>,
    document.getElementById('modal-root')!,
  );
}

点击按钮时,常见输出是:

button click
parent click

虽然按钮实际位于 #modal-root,不在 Parent 的 DOM 子树中,但它仍是 Parent 逻辑子树中的后代。React 的合成事件会沿 React 树向上处理。

可以把点击路径写成:

真实 DOM 路径:
button → #modal-root → body → document

React 逻辑路径:
button → ModalButton → Parent

这解释了一个容易被忽略的现象:Portal 内部的点击可能触发 Portal 外部组件上的 React onClick

2.2 stopPropagation 应该在哪里调用

如果 Modal 内部点击不应触发页面背后的点击处理,可以在合适的 React 节点停止传播:

function DialogSurface() {
  return (
    <div
      role="dialog"
      aria-modal="true"
      onClick={(event) => {
        event.stopPropagation();
      }}
    >
      Dialog content
    </div>
  );
}

常见的遮罩结构是:

function ModalView({ onClose }: { onClose: () => void }) {
  return (
    <div
      className="backdrop"
      onMouseDown={(event) => {
        if (event.target === event.currentTarget) {
          onClose();
        }
      }}
    >
      <div
        className="surface"
        role="dialog"
        aria-modal="true"
        onMouseDown={(event) => {
          event.stopPropagation();
        }}
      >
        Content
      </div>
    </div>
  );
}

这里使用 event.target === event.currentTarget,而不是只判断“发生了 mousedown”,原因是:

  • target 是实际被点击的元素;
  • currentTarget 是当前处理器挂载的遮罩;
  • 两者相等,才表示点击的是遮罩本身,而不是对话框内容的后代。

2.3 React 事件与原生事件不是同一条传播模型

Portal 只保证 React 树层面的逻辑关系。原生 DOM 事件仍按真实 DOM 树传播。

例如:

document.addEventListener('click', (event) => {
  console.log(event.target);
});

这个监听器看到的是 button → #modal-root → body 的真实 DOM 路径,而不是 React 组件路径。

因此以下情况不能简单套用“React 会冒泡到父组件”的结论:

  1. 监听器使用 document.addEventListener,而不是 React onClick
  2. Portal 内容位于另一个 React root;
  3. 内容位于 iframe 内;
  4. 事件在原生监听器中被 stopPropagation()
  5. 使用 Shadow DOM,事件还涉及 composed 路径。

React 事件委托的具体实现细节随 React 版本演进,不应依赖某个内部监听节点;可依赖的公共语义是:同一 React 树中的 Portal 子节点,其 React 事件可以沿逻辑父树传播。

2.4 事件关闭和状态关闭是两个步骤

点击关闭按钮时,通常会发生:

用户点击
→ React 处理事件
→ 调用 onClose
→ 父组件更新 open=false
→ Modal 卸载

onClose 不应直接修改 Modal 内部无法控制的状态,通常由拥有 open 的父组件决定:

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

  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>

      <Modal
        open={open}
        onClose={() => {
          setOpen(false);
        }}
      />
    </>
  );
}

这样数据流是单向的:

父状态 open
   ↓
Modal 是否渲染
   ↓
用户操作产生 close reason
   ↓
onClose(reason)
   ↓
父组件更新 open

如果 Modal 内部同时维护一个 isOpen,就可能出现父状态为 false、内部状态仍为 true 的双重事实来源。


3. 焦点:Portal 不会自动管理键盘用户的位置

3.1 焦点为什么是弹窗的核心

浏览器焦点由 document.activeElement 表示。打开弹窗前,用户可能正在输入框、表格行或按钮中:

打开前:
activeElement = “删除”按钮

打开后:
焦点应进入对话框内部

关闭后:
焦点应回到原来的“删除”按钮

Portal 只把 DOM 节点插入目标容器,不会自动完成这三步。以下代码虽然能显示弹窗,但焦点可能仍停留在背景页面:

return createPortal(
  <div role="dialog">
    <button>Close</button>
  </div>,
  modalRoot,
);

结果可能是:

  • 键盘用户无法直接操作弹窗;
  • 屏幕阅读器仍处于背景内容;
  • Tab 后焦点跑到弹窗外;
  • 关闭弹窗后焦点落到 body,用户失去上下文。

3.2 打开时保存焦点,关闭时恢复焦点

焦点恢复需要保存打开前的元素,而不是在关闭时临时猜测。

import {
  useEffect,
  useRef,
  useState,
} from 'react';

type CloseReason = 'escape' | 'backdrop' | 'close-button';

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

function usePreviousActiveElement(open: boolean) {
  const previousRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (open) {
      const active = document.activeElement;
      previousRef.current =
        active instanceof HTMLElement ? active : null;
    }
  }, [open]);

  return previousRef;
}

但上面的效果在 open=true 之后才运行,焦点保存时机可能已经晚于某些同步操作。更稳定的做法是让打开动作发生在父组件中时保存引用,或者在 Modal 从关闭到打开的布局阶段保存。一个简单、实用的 Modal 实现可以在第一次打开时记录当前焦点,并在关闭时恢复:

import {
  useEffect,
  useLayoutEffect,
  useRef,
} from 'react';

const useIsoLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

function useFocusRestore(open: boolean) {
  const previousFocusedRef = useRef<HTMLElement | null>(null);

  useIsoLayoutEffect(() => {
    if (open) {
      const active = document.activeElement;
      previousFocusedRef.current =
        active instanceof HTMLElement ? active : null;

      return;
    }

    const previous = previousFocusedRef.current;

    if (previous && previous.isConnected) {
      previous.focus();
    }

    previousFocusedRef.current = null;
  }, [open]);
}

这里有两个边界:

  • isConnected 防止原元素已经被卸载后调用 focus()
  • 如果关闭是由原元素自身触发的,父组件可能会同时卸载该元素,此时不能强行恢复到已不存在的节点。

更复杂的应用还应处理嵌套弹窗。嵌套 Modal 需要一个焦点栈,而不是单个 previousFocusedRef

打开 A:保存触发器 A
打开 B:保存触发器 B
关闭 B:恢复到触发器 B
关闭 A:恢复到触发器 A

3.3 初始焦点应进入对话框

初始焦点不一定总是关闭按钮。通常需要根据内容选择:

  • 简单确认框:聚焦主要操作或取消按钮;
  • 长文本说明:聚焦标题或可聚焦容器,让屏幕阅读器先读上下文;
  • 表单:聚焦第一个错误字段;
  • 危险操作:避免默认聚焦“确认删除”。

一种通用实现是给对话框容器设置 tabIndex={-1},然后在布局效果中聚焦它:

function DialogSurface({
  title,
  children,
}: {
  title: string;
  children: React.ReactNode;
}) {
  const surfaceRef = useRef<HTMLDivElement>(null);

  useIsoLayoutEffect(() => {
    surfaceRef.current?.focus();
  }, []);

  return (
    <div
      ref={surfaceRef}
      role="dialog"
      tabIndex={-1}
      aria-labelledby="dialog-title"
    >
      <h2 id="dialog-title">{title}</h2>
      {children}
    </div>
  );
}

useLayoutEffect 的原因是:它在浏览器绘制前运行,减少“先看到背景焦点、再跳到弹窗”的闪烁。服务端渲染环境没有布局阶段,所以通过 useIsoLayoutEffect 在服务端退化为 useEffect,避免服务端警告。

3.4 焦点陷阱的形式化要求

模态对话框打开时,设可聚焦元素集合为:

F=[f1,f2,,fn]F = [f_1, f_2, \ldots, f_n]

当前焦点为 fif_i。键盘 Tab 的期望状态转移是:

T(fi,Tab)={fi+1,i<nf1,i=nT(f_i, Tab) = \begin{cases} f_{i+1}, & i < n \\ f_1, & i = n \end{cases}

Shift+Tab 则是:

T(fi,ShiftTab)={fi1,i>1fn,i=1T(f_i, ShiftTab) = \begin{cases} f_{i-1}, & i > 1 \\ f_n, & i = 1 \end{cases}

如果不实现这个环绕,焦点就可能离开 Modal,进入被遮罩的背景内容。

一个小型焦点陷阱可以这样实现:

const FOCUSABLE_SELECTOR = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

function useFocusTrap(
  open: boolean,
  containerRef: React.RefObject<HTMLElement | null>,
) {
  useEffect(() => {
    if (!open) {
      return;
    }

    function onKeyDown(event: KeyboardEvent) {
      if (event.key !== 'Tab') {
        return;
      }

      const container = containerRef.current;
      if (!container) {
        return;
      }

      const items = Array.from(
        container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
      ).filter((element) => {
        return !element.hasAttribute('disabled') &&
          element.getClientRects().length > 0;
      });

      if (items.length === 0) {
        event.preventDefault();
        container.focus();
        return;
      }

      const first = items[0];
      const last = items[items.length - 1];
      const active = document.activeElement;

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

    document.addEventListener('keydown', onKeyDown);

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

这段实现用于说明机制,但不是完整的无障碍库。生产场景还要考虑:

  • contenteditable
  • iframe
  • 动态出现和消失的控件;
  • aria-disabled 与原生 disabled 的区别;
  • 多层弹窗;
  • 弹窗打开期间焦点被外部脚本强行移走;
  • Shadow DOM 的可聚焦元素;
  • 浏览器和辅助技术组合差异。

3.5 Escape、遮罩点击和关闭策略

关闭行为应有明确的来源:

type CloseReason =
  | 'escape'
  | 'backdrop'
  | 'close-button'
  | 'submit'
  | 'programmatic';

原因不能只为了日志,它影响业务决策。例如:

  • 用户按 Escape,通常保留表单草稿;
  • 点击“提交”后关闭,可能清理草稿;
  • 程序错误关闭,可能需要显示错误;
  • 点击遮罩关闭,危险操作可能应被禁止。

键盘监听必须在弹窗打开时注册,并在关闭时清理:

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

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

  document.addEventListener('keydown', onKeyDown);

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

嵌套弹窗时,不能让所有打开的 Modal 都响应同一个 Escape。只有最顶层 Modal 应处理它。常见做法是通过层管理器维护栈:

Modal A
Modal B  ← 只有 B 响应 Escape

4. 层级:Portal 改变 DOM 位置,但不自动获得最高层

4.1 z-index 只在层叠上下文中比较

CSS 的 z-index 不是全局数字。元素首先受层叠上下文约束,然后才在同一上下文中比较 z-index

例如:

.page {
  transform: translateZ(0);
  z-index: 1;
}

.page-tooltip {
  position: absolute;
  z-index: 999999;
}

.modal {
  position: fixed;
  z-index: 1000;
}

如果 .page 创建了一个层叠上下文,那么 .page-tooltip999999 不能越过外部的 .page 层叠上下文去压过另一个兄弟上下文。这里:

999999 不一定大于 1000

因为比较的不是两个元素的裸数字,而是它们在各自层叠上下文中的位置。

常见会创建层叠上下文或影响定位行为的属性包括:

  • transformnone
  • opacity < 1
  • filter
  • isolation: isolate
  • 某些 contain 设置;
  • 定位元素配合 z-index
  • will-change 指定相关属性。

4.2 为什么 Portal 常把内容挂到 body

把弹窗挂到靠近 body 的专用容器,可以避开页面内容祖先上的:

  • overflow: hidden 裁剪;
  • transformposition: fixed 的影响;
  • 局部 z-index 层叠上下文;
  • 页面布局容器的 clip-pathcontain

例如:

#modal-root {
  position: relative;
  z-index: 1000;
}

.backdrop {
  position: fixed;
  inset: 0;
  background: rgb(0 0 0 / 0.5);
}

.surface {
  position: relative;
  max-width: 32rem;
  margin: 10vh auto 0;
  background: white;
  color: black;
}

但这只是降低冲突概率,不是“永远在最上层”的保证。以下边界仍然存在:

  • 浏览器原生 Top Layer 中的元素可以高于普通文档层;
  • <iframe> 内部的内容不能越过 iframe 边界;
  • 浏览器扩展、视频全屏、系统级窗口可能不受普通 CSS 控制;
  • 其他组件也可能使用更高的层级策略。

4.3 原生 Top Layer 与 Portal 的区别

现代浏览器提供了原生 Top Layer,典型入口包括:

  • <dialog>showModal()
  • popover 相关能力。

Top Layer 不是普通文档流中的层叠上下文,它由浏览器管理,通常能避免很多 z-index 竞争。原生 <dialog> 还提供部分模态行为和语义。

React 仍可以管理 <dialog> 的内容和状态:

function NativeDialog({
  open,
  onClose,
}: {
  open: boolean;
  onClose: () => void;
}) {
  const ref = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) {
      return;
    }

    if (open && !dialog.open) {
      dialog.showModal();
    } else if (!open && dialog.open) {
      dialog.close();
    }
  }, [open]);

  return (
    <dialog
      ref={ref}
      onClose={onClose}
    >
      <p>Native dialog</p>
      <button onClick={() => ref.current?.close()}>
        Close
      </button>
    </dialog>
  );
}

这个方案不必然取代 Portal:

  • Portal 适合统一渲染到应用层容器、处理跨页面布局和自定义层管理;
  • 原生 <dialog> 适合接受浏览器提供的模态语义和 Top Layer;
  • 两者都需要验证键盘、焦点、关闭和辅助技术行为;
  • 不应把 showModal()createPortal() 当成同一个机制。

5. 可访问性:视觉遮罩不是模态语义

5.1 role="dialog"aria-modal

视觉上显示一个白色面板,并不等于辅助技术知道这是对话框。通常需要:

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="settings-title"
>
  <h2 id="settings-title">Settings</h2>
</div>

各属性含义不同:

  • role="dialog":声明这个区域是一个对话框;
  • aria-modal="true":声明对话框打开时,外部内容不应作为当前交互范围;
  • aria-labelledby:指定可访问名称的来源;
  • aria-describedby:可选,指定描述文本。

aria-modal="true" 不是 CSS,也不会自动阻止鼠标点击或键盘焦点。组件必须同时实现相应行为,否则会出现“声明是模态,实际仍可操作背景”的不一致。

5.2 aria-hiddeninert 和背景内容

弹窗打开时,背景页面应当不能被交互。现代浏览器可使用 inert

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

  const appRoot = document.getElementById('root');

  if (!appRoot) {
    return;
  }

  appRoot.inert = true;

  return () => {
    appRoot.inert = false;
  };
}, [open]);

inert 的作用包括阻止用户与元素交互,并让相关内容从焦点顺序中排除。它比单独设置 aria-hidden="true" 更接近“背景不可交互”的需求。

aria-hidden="true" 主要影响辅助技术暴露,不是通用的交互阻止机制。错误示例:

<div aria-hidden="true">
  <button>Background button</button>
</div>

如果这个按钮仍能获得焦点,就可能产生“辅助技术看不见、键盘却能聚焦”的不一致。

使用 aria-hidden 时还要保证被隐藏的节点内部没有当前焦点,也不能把包含 Modal 的祖先节点隐藏:

错误:
#root
└── Modal Portal 的逻辑组件

虽然 Portal 的真实 DOM 节点可能不在 #root 内,但如果代码按组件结构误把整个逻辑区域标记为隐藏,结果会破坏弹窗本身的可访问性。

5.3 标题、描述和关闭按钮

一个可用的对话框至少应有:

function DialogHeader({
  title,
  description,
  onClose,
}: {
  title: string;
  description?: string;
  onClose: () => void;
}) {
  const titleId = useId();
  const descriptionId = useId();

  return (
    <>
      <h2 id={titleId}>{title}</h2>

      {description && (
        <p id={descriptionId}>{description}</p>
      )}

      <button
        type="button"
        aria-label="关闭对话框"
        onClick={onClose}
      >
        ×
      </button>
    </>
  );
}

实际对话框应把生成的 ID 连接到外层:

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby={titleId}
  aria-describedby={description ? descriptionId : undefined}
>
  ...
</div>

useId 适合生成服务端和客户端一致的关联 ID,不能把它当成业务数据 ID,也不应通过随机数替代它,否则可能造成 hydration 不一致。


6. 一个可运行的 Modal 结构

下面的实现覆盖:

  • Portal 容器;
  • open 受控状态;
  • Escape 关闭;
  • 点击遮罩关闭;
  • 初始焦点;
  • 焦点恢复;
  • 基本焦点环;
  • inert 背景;
  • 标题和描述关联。

6.1 Portal 容器 Hook

import {
  useEffect,
  useState,
} from 'react';
import { createPortal } from 'react-dom';

function usePortalHost(id: string) {
  const [host, setHost] = useState<HTMLElement | null>(null);

  useEffect(() => {
    let element = document.getElementById(id);
    let owned = false;

    if (!element) {
      element = document.createElement('div');
      element.id = id;
      document.body.appendChild(element);
      owned = true;
    }

    setHost(element);

    return () => {
      if (owned && element?.isConnected) {
        element.remove();
      }
    };
  }, [id]);

  return host;
}

这个 Hook 有几个重要性质:

  1. document 只在 useEffect 中访问,因此模块可以被服务端加载;
  2. 服务端首次渲染返回 null,不会假定浏览器存在 document.body
  3. 如果页面已经提供 #modal-root,组件复用它;
  4. 如果组件自己创建了容器,卸载时只删除自己拥有的容器;
  5. 在 React Strict Mode 的开发行为下,副作用可能被重复执行,清理逻辑必须可重复。

生产应用通常更适合在 HTML 模板中固定提供:

<div id="root"></div>
<div id="modal-root"></div>

这样可以让层级结构显式化,并减少多个 Modal 实例各自创建容器的问题。

6.2 Modal 组件

import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
} from 'react';
import { createPortal } from 'react-dom';

type CloseReason =
  | 'escape'
  | 'backdrop'
  | 'close-button'
  | 'programmatic';

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

const useIsoLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

const FOCUSABLE_SELECTOR = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

function Modal({
  open,
  title,
  description,
  children,
  onClose,
}: ModalProps) {
  const host = usePortalHost('modal-root');
  const surfaceRef = useRef<HTMLDivElement>(null);
  const previousFocusedRef = useRef<HTMLElement | null>(null);
  const titleId = useId();
  const descriptionId = useId();

  useIsoLayoutEffect(() => {
    if (!open) {
      return;
    }

    const active = document.activeElement;
    previousFocusedRef.current =
      active instanceof HTMLElement ? active : null;

    surfaceRef.current?.focus();

    return () => {
      const previous = previousFocusedRef.current;

      if (previous?.isConnected) {
        previous.focus();
      }

      previousFocusedRef.current = null;
    };
  }, [open]);

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

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

      if (event.key !== 'Tab') {
        return;
      }

      const surface = surfaceRef.current;
      if (!surface) {
        return;
      }

      const focusable = Array.from(
        surface.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
      ).filter((element) => {
        return element.getClientRects().length > 0;
      });

      if (focusable.length === 0) {
        event.preventDefault();
        surface.focus();
        return;
      }

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

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

    document.addEventListener('keydown', onKeyDown);

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

  useEffect(() => {
    const appRoot = document.getElementById('root');

    if (!open || !appRoot) {
      return;
    }

    appRoot.inert = true;

    return () => {
      appRoot.inert = false;
    };
  }, [open]);

  if (!open || !host) {
    return null;
  }

  return createPortal(
    <div
      className="backdrop"
      onMouseDown={(event) => {
        if (event.target === event.currentTarget) {
          onClose('backdrop');
        }
      }}
    >
      <div
        ref={surfaceRef}
        className="surface"
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={
          description ? descriptionId : undefined
        }
        tabIndex={-1}
        onMouseDown={(event) => {
          event.stopPropagation();
        }}
      >
        <header>
          <h2 id={titleId}>{title}</h2>

          <button
            type="button"
            aria-label="关闭对话框"
            onClick={() => onClose('close-button')}
          >
            ×
          </button>
        </header>

        {description && (
          <p id={descriptionId}>{description}</p>
        )}

        {children}
      </div>
    </div>,
    host,
  );
}

使用方式:

import { useState } from 'react';

function DeleteUserPage() {
  const [open, setOpen] = useState(false);
  const [lastReason, setLastReason] = useState<string | null>(null);

  function close(reason: CloseReason) {
    setLastReason(reason);
    setOpen(false);
  }

  return (
    <main>
      <button type="button" onClick={() => setOpen(true)}>
        Delete user
      </button>

      {lastReason && <output>Closed by: {lastReason}</output>}

      <Modal
        open={open}
        title="Delete user"
        description="This action cannot be undone."
        onClose={close}
      >
        <form
          onSubmit={(event) => {
            event.preventDefault();
            close('programmatic');
          }}
        >
          <p>Are you sure?</p>
          <button type="button" onClick={() => close('close-button')}>
            Cancel
          </button>
          <button type="submit">
            Delete
          </button>
        </form>
      </Modal>
    </main>
  );
}

对应样式:

#modal-root {
  position: relative;
  z-index: 1000;
}

.backdrop {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: start center;
  padding: 10vh 1rem 1rem;
  background: rgb(0 0 0 / 0.5);
}

.surface {
  width: min(100%, 32rem);
  max-height: 80vh;
  overflow: auto;
  padding: 1.25rem;
  border-radius: 0.5rem;
  background: white;
  color: #111;
  box-shadow: 0 1rem 4rem rgb(0 0 0 / 0.3);
}

.surface:focus {
  outline: 3px solid Highlight;
  outline-offset: 3px;
}

运行时的状态过程是:

1. 用户聚焦 Delete user
2. 点击按钮,父组件设置 open=true
3. Modal 记录 activeElement
4. Modal 创建或找到 #modal-root
5. Portal 插入 backdrop 和 surface
6. surface 获得焦点
7. Tab 在弹窗内部循环
8. Escape、遮罩或按钮产生 close reason
9. 父组件设置 open=false
10. Portal 卸载
11. 焦点恢复到原来的 Delete user

6.3 这个实现仍然有明确边界

上述代码适合展示核心机制,但生产组件还需要根据产品需求补充:

  • 动画退出时,不能在收到 open=false 的瞬间卸载,否则退出动画无法播放;
  • 如果关闭触发器已经被卸载,应选择合理的替代焦点;
  • 如果存在多个 Modal,背景 inert 只能由最外层或层管理器统一控制;
  • 监听 document 的每个 Modal 都处理 Escape,会造成嵌套弹窗同时关闭;
  • 焦点选择器不是完整的可聚焦元素规范;
  • inert 需要确认目标浏览器和应用支持范围;
  • 弹窗内容发生异步加载时,初始焦点可能需要在内容就绪后重新计算。

7. 服务端渲染和客户端边界

7.1 为什么不能在渲染阶段直接访问 document

下面的代码在服务端渲染中会失败:

function BrokenModal() {
  const host = document.getElementById('modal-root');

  return createPortal(<div>Dialog</div>, host!);
}

服务端没有浏览器的 document。此外,Portal 目标是一个具体的 DOM 节点,而服务端渲染阶段并不存在可插入的浏览器节点。

因此应把“寻找或创建 Portal 容器”的操作放到客户端效果中,或者由框架把整个交互组件标记为客户端组件。

7.2 SSR 应明确选择渲染策略

有三种常见策略。

策略一:服务端不输出弹窗

初始 HTML 只输出页面主体:

if (!mounted || !open) {
  return null;
}

客户端挂载后再创建 Portal。这最容易避免 hydration 不匹配,但打开状态不能在首屏服务端直接提供可见弹窗。

策略二:服务端输出固定结构,客户端复用容器

HTML 模板预先提供:

<div id="root"></div>
<div id="modal-root"></div>

客户端组件只在挂载后查找 #modal-root。这能让 DOM 层级稳定,但仍不能在服务端阶段执行依赖 document 的代码。

策略三:使用框架提供的客户端边界

在支持 Server Components 的框架中,交互式 Modal 通常应位于客户端边界内。例如组件需要:

  • useState
  • useEffect
  • document
  • 键盘事件;
  • 焦点操作;
  • createPortal

这些都属于浏览器交互逻辑。服务端组件可以负责传入初始数据,但不应在服务端执行焦点和 DOM 操作。

7.3 Hydration 失败的典型原因

以下逻辑会产生服务端与客户端不同的初始输出:

const host = document.getElementById('modal-root');

return open && host
  ? createPortal(<Dialog />, host)
  : null;

如果服务端无法计算 host 而返回 null,客户端第一次渲染却直接返回对话框,React 可能报告 hydration 不匹配。将“挂载完成”纳入状态:

function ClientOnlyModal(props: ModalProps) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return null;
  }

  return <Modal {...props} />;
}

这里的代价是 Modal 不会出现在首个服务端 HTML 中。它是可验证的取舍,不应把它描述成无成本方案。


8. 弹窗架构:把视图、状态和层管理分开

8.1 单个弹窗应由谁拥有状态

最简单且可预测的架构是受控组件:

type ConfirmDialogProps = {
  open: boolean;
  onConfirm: () => void;
  onCancel: (reason: 'cancel' | 'escape' | 'backdrop') => void;
};

父组件拥有:

是否打开
业务数据
确认动作
取消动作

Modal 拥有:

Portal 渲染
焦点进入和恢复
键盘处理
遮罩行为
无障碍属性

这样 UI 层不会偷偷决定业务状态。

8.2 全局 Modal 管理器的数据结构

如果应用需要从不同页面打开全局弹窗,可以维护一个栈:

type ModalRequest =
  | {
      type: 'confirm-delete';
      userId: string;
    }
  | {
      type: 'alert';
      message: string;
    };

type ModalEntry = {
  id: string;
  request: ModalRequest;
  resolve: (result: unknown) => void;
};

状态转移:

[] 
  └── push(confirm-delete)
      → [confirm-delete]

[confirm-delete]
  └── push(alert)
      → [confirm-delete, alert]

[confirm-delete, alert]
  └── close top
      → [confirm-delete]

[confirm-delete]
  └── resolve + pop
      → []

只有栈顶元素应:

  • 响应 Escape
  • 获得焦点;
  • 使其下方页面和弹窗不可交互;
  • 控制最外层遮罩策略。

如果每个 Modal 都独立给 #root 设置和清理 inert,嵌套场景可能发生:

A 打开:root.inert = true
B 打开:root.inert = true
B 关闭:root.inert = false  ← A 仍然打开,但背景被错误恢复

解决方法是由层管理器根据栈长度统一计算:

const hasModal = entries.length > 0;
appRoot.inert = hasModal;

而不是让每一个实例直接覆盖全局副作用。

8.3 关闭动画需要两个状态

如果希望关闭时播放动画,open 不应直接等于“DOM 是否存在”:

open=true
mounted=true
→ 显示

open=false
mounted=true
→ 播放退出动画

动画结束
→ mounted=false

可以拆成:

const [present, setPresent] = useState(open);

useEffect(() => {
  if (open) {
    setPresent(true);
  }
}, [open]);

function handleAnimationEnd() {
  if (!open) {
    setPresent(false);
  }
}

Portal 的存在条件使用 present,视觉状态使用 open。否则 open=false 会立即卸载节点,浏览器没有机会执行退出动画,也无法在动画期间继续维持焦点和遮罩语义。

8.4 并发渲染下的副作用边界

React 可以在渲染阶段准备、暂停或丢弃工作。不能在渲染函数中执行以下操作:

function BadModal({ open }: { open: boolean }) {
  if (open) {
    document.body.style.overflow = 'hidden';
  }

  return null;
}

因为这不是提交后的可靠生命周期,渲染可能被中断,清理也没有对应机会。滚动锁、焦点、事件监听、inert 都应放在 Effect 中,并保证:

每次建立副作用
都有对应的清理副作用

滚动锁还要考虑滚动条消失造成的布局跳动。最小实现:

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

  const previous = document.body.style.overflow;
  document.body.style.overflow = 'hidden';

  return () => {
    document.body.style.overflow = previous;
  };
}, [open]);

但如果页面已有其他组件管理滚动锁,直接恢复旧值可能互相覆盖。全局层管理器应使用引用计数或统一状态,而不是多个实例分别写 body.style


9. 常见失败表现与诊断路径

9.1 “Modal 被别的元素盖住”

先检查真实 DOM,而不是只看 React 组件层级:

1. Portal 是否真的插入了目标容器?
2. 目标容器是否处于预期的层叠上下文?
3. 祖先是否有 transform、filter、opacity、contain?
4. 对比双方的 stacking context,而不是只比较 z-index 数字。
5. 是否存在原生 Top Layer 或 iframe?

浏览器开发者工具中应检查:

  • Modal 最终 DOM 位置;
  • positionz-index
  • overflow 和裁剪;
  • 是否创建了 stacking context;
  • 元素的“布局”和“层叠”面板。

9.2 “点击弹窗按钮时页面也关闭了”

可能原因有三类:

  1. Portal 内点击沿 React 树冒泡到了页面处理器;
  2. 遮罩通过 onClick 关闭,却没有区分 targetcurrentTarget
  3. 原生 document 监听器和 React 监听器同时存在,关闭逻辑执行了两次。

诊断时记录:

onClick={(event) => {
  console.log({
    target: event.target,
    currentTarget: event.currentTarget,
  });
}}

然后确认关闭动作是否只由一个层级负责。

9.3 “打开后按 Tab,焦点跑到背景”

这通常说明至少缺少一项:

  • 没有将初始焦点移动到弹窗;
  • 没有焦点环;
  • 背景没有 inert 或等价的交互隔离;
  • 自定义焦点选择器漏掉了实际控件;
  • Modal 打开期间另一个组件调用了 focus()

诊断方法是连续记录:

setInterval(() => {
  console.log(document.activeElement);
}, 500);

并在键盘事件中记录焦点前后元素。不要只用视觉观察判断焦点,因为屏幕阅读器和键盘用户看到的交互范围可能不同。

9.4 “关闭后焦点消失”

常见原因:

  • 原触发按钮已经卸载;
  • 恢复焦点的代码放在错误的生命周期;
  • 多个弹窗共用一个焦点引用;
  • 动画结束后才恢复,但恢复目标已经失效;
  • 关闭时 document.activeElement 已经是 body,覆盖了原始引用。

应保存打开前的元素,并在恢复前检查:

if (element && element.isConnected) {
  element.focus();
}

如果元素不再存在,应定义后备策略,例如聚焦页面标题、列表容器或当前页面的主要区域,而不是让焦点无声地落到 body

9.5 “客户端 hydration 报错”

检查服务端和客户端第一次渲染是否同时满足:

相同的 open 值
相同的 Portal 容器可见性
相同的 useId 生成结构
相同的条件分支

尤其避免在渲染期间根据 window.innerWidthdocument、随机数或当前时间决定是否输出 Portal 内容。需要浏览器信息时,先输出稳定的服务端结果,再在 Effect 中更新。


10. Portal、Popover、Tooltip 和 Modal 的边界

这些组件都可能使用 Portal,但交互语义不同。

Modal

特点:

  • 阻断背景交互;
  • 通常有 role="dialog"
  • 通常使用 aria-modal="true"
  • 需要焦点进入、焦点环和焦点恢复;
  • Escape 通常关闭,但危险流程可能禁止遮罩关闭。

Popover

特点:

  • 通常不阻断整个页面;
  • 焦点是否转移取决于交互设计;
  • 点击外部可能关闭;
  • 需要处理触发器、浮层和定位关系;
  • 可考虑原生 popover Top Layer。

Tooltip

特点:

  • 通常不承载复杂交互;
  • 不应依赖键盘 Tab 进入;
  • 常由悬停、聚焦触发;
  • 需要让触发元素通过 aria-describedby 关联说明;
  • 不应把 Tooltip 当成可操作菜单。

下拉菜单

特点:

  • 有菜单项键盘导航规则;
  • 触发器与菜单之间有明确的 aria-expandedaria-controls 关系;
  • 关闭后通常恢复到触发器;
  • 菜单本身不等于 Modal,通常不应把整个背景设置为 inert

因此,“统一 Portal 容器”可以统一渲染层,但不能让所有浮层共用一套无差别的焦点和关闭逻辑。架构应区分:

渲染容器
  ↓
层级排序
  ↓
组件语义
  ↓
焦点策略
  ↓
关闭策略

11. 规范保证、实现差异和工程取舍

可以稳定依赖的 React 公共语义包括:

  • Portal 子节点仍属于原 React 树;
  • Context 可以穿过 Portal;
  • React 事件可以沿逻辑父树传播;
  • Portal 目标节点决定真实 DOM 插入位置;
  • Portal 子树的挂载和卸载仍受 React 生命周期控制。

不应当依赖的内容包括:

  • React 内部事件委托具体挂在 document 还是 root 容器;
  • 某个 CSS z-index 数字一定能压过所有元素;
  • aria-modal 自动实现焦点隔离;
  • createPortal 自动保存和恢复焦点;
  • 所有浏览器对 inert、原生 <dialog>popover 的行为完全一致;
  • Portal 可以跨越 iframe 或独立文档边界。

工程上,简单通知可以只使用 Portal 和层级;真正的 Modal 则至少需要:

受控 open 状态
Portal 容器
明确的 dialog 语义
初始焦点
Tab 焦点管理
Escape 策略
遮罩策略
背景交互隔离
焦点恢复
SSR/客户端边界
嵌套层管理

Portal 的价值不是让弹窗“自动正确”,而是把弹窗从局部 DOM 约束中分离出来,同时保留 React 的逻辑组合能力。理解“React 树”和“DOM 树”的分离,才能正确推导事件传播;理解“DOM 位置”和“层叠上下文”的关系,才能诊断覆盖问题;理解焦点和辅助技术的独立状态,才能把视觉弹窗实现成真正可用的交互组件。


系列导航与关联阅读

官方资料

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