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 会冒泡到父组件”的结论:
- 监听器使用
document.addEventListener,而不是 ReactonClick; - Portal 内容位于另一个 React root;
- 内容位于 iframe 内;
- 事件在原生监听器中被
stopPropagation(); - 使用 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 焦点陷阱的形式化要求
模态对话框打开时,设可聚焦元素集合为:
当前焦点为 。键盘 Tab 的期望状态转移是:
Shift+Tab 则是:
如果不实现这个环绕,焦点就可能离开 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-tooltip 的 999999 不能越过外部的 .page 层叠上下文去压过另一个兄弟上下文。这里:
999999 不一定大于 1000
因为比较的不是两个元素的裸数字,而是它们在各自层叠上下文中的位置。
常见会创建层叠上下文或影响定位行为的属性包括:
transform非none;opacity < 1;filter;isolation: isolate;- 某些
contain设置; - 定位元素配合
z-index; will-change指定相关属性。
4.2 为什么 Portal 常把内容挂到 body
把弹窗挂到靠近 body 的专用容器,可以避开页面内容祖先上的:
overflow: hidden裁剪;transform对position: fixed的影响;- 局部
z-index层叠上下文; - 页面布局容器的
clip-path或contain。
例如:
#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-hidden、inert 和背景内容
弹窗打开时,背景页面应当不能被交互。现代浏览器可使用 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 有几个重要性质:
document只在useEffect中访问,因此模块可以被服务端加载;- 服务端首次渲染返回
null,不会假定浏览器存在document.body; - 如果页面已经提供
#modal-root,组件复用它; - 如果组件自己创建了容器,卸载时只删除自己拥有的容器;
- 在 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 位置;
position、z-index;overflow和裁剪;- 是否创建了 stacking context;
- 元素的“布局”和“层叠”面板。
9.2 “点击弹窗按钮时页面也关闭了”
可能原因有三类:
- Portal 内点击沿 React 树冒泡到了页面处理器;
- 遮罩通过
onClick关闭,却没有区分target和currentTarget; - 原生
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.innerWidth、document、随机数或当前时间决定是否输出 Portal 内容。需要浏览器信息时,先输出稳定的服务端结果,再在 Effect 中更新。
10. Portal、Popover、Tooltip 和 Modal 的边界
这些组件都可能使用 Portal,但交互语义不同。
Modal
特点:
- 阻断背景交互;
- 通常有
role="dialog"; - 通常使用
aria-modal="true"; - 需要焦点进入、焦点环和焦点恢复;
Escape通常关闭,但危险流程可能禁止遮罩关闭。
Popover
特点:
- 通常不阻断整个页面;
- 焦点是否转移取决于交互设计;
- 点击外部可能关闭;
- 需要处理触发器、浮层和定位关系;
- 可考虑原生
popoverTop Layer。
Tooltip
特点:
- 通常不承载复杂交互;
- 不应依赖键盘 Tab 进入;
- 常由悬停、聚焦触发;
- 需要让触发元素通过
aria-describedby关联说明; - 不应把 Tooltip 当成可操作菜单。
下拉菜单
特点:
- 有菜单项键盘导航规则;
- 触发器与菜单之间有明确的
aria-expanded、aria-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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Ref 与 useImperativeHandle:焦点、测量和命令式 API
- 下一篇:React lazy 与 Suspense:代码加载、边界、错误和用户体验
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论