React Portal:模态框、Tooltip 与层叠上下文
模态框渲染在组件树深处,会被父级的 z-index/overflow/transform 截断。Portal 把它渲染到 document.body,逃离父级限制。本文讲清 Portal 原理、模态框完整实现(Portal + 焦点陷阱 + Escape + 滚动锁定)、无障碍(aria-modal),以及 Radix Dialog 替你做好这一切。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
你写了个模态框,z-index: 9999,自信满满地点开——结果它被父容器那条 overflow: hidden 切掉了一截,或者被旁边一个 transform: translateZ(0) 的元素压在身下,9999 完全没用。你开始往上加 z-index,加到 999999 还是盖不住,最后只能把整个组件挪位置。
这是 React 开发里一个经典的"DOM 树 ≠ 视觉层级"问题。你的模态框在 React 组件树里逻辑上属于某个深层组件,但视觉上它应该浮在整页最顶层——而 CSS 的层叠上下文(stacking context)只认 DOM 祖先,不认你的组件树。Portal 就是为了解开这个矛盾而生的:它让一个组件的 DOM 节点"逃离"父级,直接挂到 document.body(或任何节点)下,同时保留完整的 React 上下文。
但 Portal 只是模态框问题的 1/6。一个真正能用的模态框,还得自己处理焦点陷阱、Escape 关闭、body 滚动锁定、点击遮罩关闭、无障碍语义、关闭后焦点归还——这些才是手写模态框真正费劲的地方。本文讲清 Portal 原理、给一个完整可用的模态框实现,最后告诉你为什么 90% 的场景你根本不该手写。
一、为什么需要 Portal:你的模态框被父级"关禁闭"了
先说清楚问题。假设你有这样的结构:
function Dashboard() {
return (
<main style={{ overflow: "hidden", transform: translateY(0) }}>
<Sidebar />
<Content />
<UserMenu /> {/* 里面会弹出一个 Modal */}
</main>
);
}
UserMenu 里弹出的 Modal,DOM 上是 <main> 的子孙。这会撞上三个 CSS 规则,每一个都能独立毁掉你的模态框:
-
overflow: hidden / auto / scroll:父级一旦设了这些值,超出父级盒子的子元素会被裁切。模态框比父容器大、或定位到视口边缘,就会被切掉一截,连遮罩都铺不满屏。 -
transform、filter、perspective、will-change:这些属性会把元素变成一个新的层叠上下文根。后果是子元素即便设了position: fixed,也不再相对于视口定位,而是相对于这个被 transform 的祖先——模态框的定位全乱。更糟的是,z-index的比较被限制在这个新上下文里,你给模态框设z-index: 999999,也压不过这个祖先外面的兄弟节点。 -
层叠上下文是"祖传"的:哪怕你自己没有
z-index问题,只要 DOM 祖先链上任意一个节点建立了层叠上下文,你的z-index就只在这个上下文内部生效。一个z-index: 1的元素如果处在更外层的上下文里,照样能盖住你z-index: 9999的模态框。
核心矛盾一句话:模态框在 React 组件树上属于某个深层组件(逻辑正确),但在 DOM 树上它被扔进了那个组件的祖先链里(视觉错误)。 CSS 不知道也不关心你的组件树,它只看 DOM 祖先。
解法很直接:把模态框的 DOM 节点搬出问题区域,挂到 document.body 下。 body 是视口直接子节点,没有那些坑爹的祖先属性,z-index 和 position: fixed 都能正常工作。这个"把子节点渲染到别处"的能力,就是 Portal。
二、createPortal:把 DOM 渲染到任何地方,但保留 React 上下文
API 非常简单:
import { createPortal } from "react-dom";
createPortal(children, container);
// createPortal(children, container, key?)
children 是要渲染的 React 子树,container 是目标 DOM 节点。React 会把 children 渲染进 container,而不是当前组件在 DOM 里的位置。
但 Portal 的关键不在"换了个挂载点"——你 container.appendChild 也能换挂载点——而在于它保留了 React 的语义。这是它和手动 DOM 操作的本质区别:
- 事件冒泡按 React 树走,不按 DOM 树走。Portal 内点击触发的
onClick,会冒泡到 Portal 在 React 组件树里的父组件(声明<MyPortal />的那个组件),而不是 DOM 里的父节点(document.body)。这意味着你父组件里写的onClickCapture、Context、错误边界,对 Portal 内的内容完全有效,就像它没搬过家一样。 - Context 穿透 Portal。Portal 子树能照常消费
createPortal调用方所在组件树位置提供的 Context(theme、i18n、router 等)。如果你手动 appendChild 一个独立的 React root,这些全断了。 - unmount 时自动清理。组件卸载,React 自动从
container里把节点拿走,不用你手动removeChild。
最小用法:
import { createPortal } from "react-dom";
import { useEffect, useState } from "react";
function Tooltip({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// SSR 下没有 document,挂载后再渲染避免水合错误
if (!mounted) return null;
return createPortal(
<div className="tooltip" style={{ position: "absolute", /* ... */ }}>
{children}
</div>,
document.body,
);
}
注意 mounted 这一层——这是 Next.js / SSR 场景下用 Portal 的标准防御:服务端没有 document,水合时若直接访问会报错或服务端/客户端结构不一致。把 Portal 的渲染推迟到 useEffect 之后(客户端),既安全又不影响交互(Tooltip 本来也得在客户端才能响应鼠标)。
一个常见误区是把 Portal 当成"全局状态广播"——不是。Portal 只解决"DOM 节点挂哪儿"的问题,它不解决"组件怎么拿到全局状态"。后者是 Context 的事,Portal 恰好不破坏它而已。
三、模态框完整实现:Portal 只是 1/6,剩下五件你都得做
这是本文的核心。一个能上生产的模态框,要同时满足:① Portal 到 body 躲开层叠陷阱;② 焦点陷阱(Tab 不出模态);③ Escape 关闭;④ 打开时锁住 body 滚动;⑤ 点击遮罩关闭但点击内容不关闭;⑥ 关闭后把焦点还给触发元素。只做 Portal 那一条,模态框仍然不可用——键盘用户出不去、屏幕阅读器读不到、背景照样滚。
下面给一个完整、类型安全、可直接落地的实现。先看组件本身:
// Modal.tsx
import {
createPortal,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
}
const FOCUSABLE =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
export function Modal({ open, onClose, title, children }: ModalProps) {
const [mounted, setMounted] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null); // 记住谁打开了模态框
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!open || !mounted) return;
// ④ 记住触发元素,关闭后归还焦点(⑥)
triggerRef.current = document.activeElement as HTMLElement;
// ⑤ 锁住 body 滚动,并记下原 overflow 以便恢复(④)
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// ② 打开时把焦点放进模态框
const panel = panelRef.current;
const firstFocusable = panel?.querySelector<HTMLElement>(FOCUSABLE);
firstFocusable?.focus();
// ③ Escape 关闭
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key !== "Tab") return;
// ① 焦点陷阱:Tab/Shift+Tab 不出模态
const focusables = Array.from(
panel?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? [],
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.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);
document.body.style.overflow = prevOverflow; // 恢复滚动
triggerRef.current?.focus(); // 焦点归还
};
}, [open, mounted, onClose]);
if (!mounted || !open) return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
onClick={onClose} // ⑤ 点击遮罩关闭
>
{/* 遮罩 */}
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
}}
/>
{/* 内容面板:阻止冒泡,点内容不关闭(⑤) */}
<div
ref={panelRef}
onClick={(e) => e.stopPropagation()}
style={{
position: "fixed",
inset: 0,
display: "grid",
placeItems: "center",
}}
>
<div style={{ background: "#fff", padding: 24, borderRadius: 8 }}>
{title && (
<h2 id="modal-title" style={{ marginTop: 0 }}>
{title}
</h2>
)}
{children}
</div>
</div>
</div>,
document.body,
);
}
逐条拆解为什么这么写:
-
① 焦点陷阱:模态框打开后,键盘用户的 Tab 只能在模态内循环,不能跑到背后的页面。实现是手抓
FOCUSABLE选择器,在Tab到达首/尾元素时preventDefault+ 跳到尾/首。这个选择器覆盖了a[href]、未禁用的button、表单控件、正tabindex元素。漏掉焦点陷阱是手写模态框最常见的可访问性硬伤——视觉用户感觉不到,但键盘用户直接被卡死。 -
② 进入时聚焦:打开瞬间把焦点放进面板第一个可聚焦元素,让键盘用户立即能操作,否则焦点还停在背后的触发按钮上,Tab 一下就跑出去了。
-
③ Escape 关闭:监听
keydown,Escape 触发onClose。注意监听挂在document上而不是面板上——因为打开瞬间焦点可能还没到面板,挂在面板上的onKeyDown会漏。 -
④ body 滚动锁定:打开时把
document.body.style.overflow设为hidden,关闭时恢复原值(而不是无脑设回auto——原页面可能是scroll或自定义值)。这里有个细节:如果同一时间有多个模态框(比如弹窗里再弹窗),简单赋值会在内层关闭时把外层的hidden提前恢复。生产实现通常用引用计数(打开 +1、关闭 -1,归零才恢复),这里为了说清原理略过。 -
⑤ 点击遮罩关闭、点击内容不关闭:外层
div的onClick={onClose}处理遮罩点击;内层面板stopPropagation阻止冒泡,所以点内容区域不会误关。这利用了第五节要讲的 Portal 事件冒泡特性——虽然是 Portal 出去的,onClick仍按 React 树冒泡。 -
⑥ 焦点归还:打开前用
document.activeElement记住触发元素,关闭时focus()回去。屏幕阅读器和键盘用户对这条极其敏感——关掉模态框后焦点要是丢了(跑到body上),他们得重新从头摸索。
用法:
function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>删除项目</button>
<Modal open={open} onClose={() => setOpen(false)} title="确认删除">
<p>此操作不可撤销,确定删除吗?</p>
<button onClick={() => setOpen(false)}>取消</button>
<button onClick={() => { /* 删除逻辑 */ setOpen(false); }}>
确认
</button>
</Modal>
</>
);
}
这个实现已经覆盖了模态框 90% 的场景。剩下 10%(多模态栈、动画进出、iOS Safari 的 position: fixed body 滚动穿透、屏幕阅读器的 aria-live 公告)每一条都是单独的坑。这也是为什么下一节的结论是——别手写。
四、无障碍:role / aria-modal / aria-labelledby 不是装饰
模态框的无障碍有两个层次。第一层是焦点管理(上一节已经做了),第二层是语义标注,让屏幕阅读器知道"这是一个模态对话框,背景内容暂时不要交互"。
三个关键属性:
role="dialog":告诉辅助技术这是一个对话框。如果是必须操作完才能离开的(强制确认框、向导),用role="alertdialog"更准确。aria-modal="true":声明这是模态——背景内容应被视为不可交互。较新的屏幕阅读器会据此把焦点限制在模态内(配合你的焦点陷阱是双保险),并隐藏背景。aria-labelledby(或aria-describedby):把模态的标题(和描述)关联到模态容器,打开时屏幕阅读器会朗读它,相当于让盲人用户"看到"标题。
这就是上一节代码里那三行的含义:
<div
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
>
...
<h2 id="modal-title">{title}</h2>
aria-labelledby 的值是对应标题元素的 id——通过 id 关联,而不是把文本复制一遍。如果标题是动态的或由 children 提供,约定俗成用一个稳定 id(如 modal-title);多实例场景下要加唯一前缀(useId() 生成),避免 id 冲突。
另一个常被忽略的点是 role="dialog" 的背景标记。严格的可访问性实现还会给背景主内容加 aria-hidden="true",确保老旧屏幕阅读器(不支持 aria-modal)也不会读到背景。Radix 之类的库做了这层;手写实现里,靠 aria-modal + 焦点陷阱已经覆盖主流场景,但不是 100%。
一句话总结这节:焦点管理让键盘用户能用,ARIA 语义让屏幕阅读器用户能懂。两者缺一不可,只做焦点管理不做 ARIA,或反过来,都算不上可用。
五、事件冒泡的反直觉:Portal 内事件冒泡到 React 树,不是 DOM 树
这是 Portal 最容易踩坑的点,也是一个常见的面试题。看这个例子:
function Page() {
return (
<div onClick={() => console.log("Page clicked")}>
<PortalModal />
</div>
);
}
function PortalModal() {
return createPortal(
<button onClick={() => console.log("button clicked")}>点我</button>,
document.body, // DOM 上挂在 body 下,不在 Page 的 div 里
);
}
点击按钮,DOM 树上是 body → button,跟 Page 的 div 毫无关系。但控制台会依次打印 button clicked → Page clicked。
原因:React 的事件系统是合成事件,冒泡走的是组件树(fiber 树),不是真实 DOM 树。 Portal 在 DOM 上搬家了,但在 React 的组件树里 PortalModal 仍然是 Page 的子孙——所以 onClick 事件按 React 树从 button 冒泡到 Page 的 div,就像 Portal 从没存在过。
这个特性是好事,也是陷阱:
- 好事:你的 Context、捕获阶段监听、错误边界在 Portal 内全部正常工作。你不需要为"Portal 出去了"做任何适配。
- 陷阱:你以为点击 Portal 内的东西"已经离开了父级",结果父级的
onClick照样触发。比如父级div上有个"点击外部关闭下拉"的逻辑,你把下拉菜单 Portal 出去——结果点菜单内部也触发"外部关闭",菜单瞬间消失。解决:在 Portal 内层stopPropagation,或父级用ref.contains(e.target)判断真实点击源。
一个相关的实战细节:onClose 不要依赖事件冒泡实现。比如不要写"点击除触发按钮外任意地方就关闭"靠全局 onClick——这种逻辑在 Portal 下会因为冒泡路径反直觉而频繁出 bug。显式地用遮罩元素 + onClick,比依赖全局事件可靠得多。
六、别手写:Radix UI / Headless UI 早就替你踩完了所有坑
第三节那个完整实现,我用了近 100 行代码,还特意略过了多模态栈、进出动画、iOS Safari 的 position: fixed 滚动穿透、useId 防 id 冲突、aria-hidden 背景隔离……这些坑每一个都够你调半天。而所有这些,Radix UI 的 Dialog 和 Headless UI 的 Dialog 早就替你做完了。
Radix 的 @radix-ui/react-dialog 实现的、你大概率不想自己处理的东西:
- 完整的焦点管理:打开聚焦、Tab 陷阱、关闭归还,全做了;支持
onOpenAutoFocus/onCloseAutoFocus自定义。 - 滚动锁定:带引用计数,多模态栈也不会提前解锁;处理了 iOS Safari 的
position: fixed穿透问题(一个经典坑:锁 body 后整个视口跳到顶部)。 - 无障碍:
role="dialog"/aria-modal/aria-labelledby/aria-describedby全自动关联,aria-hidden背景隔离,屏幕阅读器实测通过。 - 动画:原生支持 CSS 动画的进出(
data-state="open|closed"),卸载时机精确到动画结束。 - 多模态栈:多个 Dialog 嵌套时焦点和层级正确处理。
用法(Radix 示例):
import * as Dialog from "@radix-ui/react-dialog";
function DeleteDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="overlay" />
<Dialog.Content className="content">
<Dialog.Title>确认删除</Dialog.Title>
<Dialog.Description>此操作不可撤销。</Dialog.Description>
<div style={{ display: "flex", gap: 8 }}>
<Dialog.Close asChild>
<button>取消</button>
</Dialog.Close>
<button onClick={() => onOpenChange(false)}>确认</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
注意 <Dialog.Portal>——它内部就是 createPortal(..., document.body),但搭配了上面那一整套逻辑。Headless UI(@headlessui/react 的 Dialog)做的事情类似,风格略有不同(更组件组合化),二选一即可。
我的建议很直接:
- 生产项目用 Radix / Headless UI。不是因为你写不出来——你能——而是因为这些库的每个细节都是无数 issue 喂出来的,你重写一遍就是在重走别人走过的雷区。尤其是可访问性,规范(WAI-ARIA Authoring Practices)细节多到离谱,自研几乎必然在某些屏幕阅读器上翻车。
- 只有两种情况值得手写:① 学习目的,搞清楚 Portal 和模态框到底在做什么(也就是本文前五节);② 极端轻量场景(一个不需要无障碍的内部工具弹层),且你愿意承担后续维护。即便如此,写完去对照 Radix 的实现,看看漏了什么。
一个朴素的判断:只要这个模态框要面对真实用户,就用库。 手写模态框的代码量不在于那 100 行基础实现,而在于把它补全到"各种边界都正确"所要补的另外几百行——那几百行业务价值为零。
小结
- Portal 解决的是"组件树位置"和"DOM 层级"的错位。模态框在组件树上属于深层组件,在 DOM 上却被父级的
overflow、transform、层叠上下文关了禁闭。Portal 让 DOM 节点搬到document.body,躲开这些限制,position: fixed和z-index重新生效。 createPortal(children, container)不只是换挂载点,它保留 React 语义。Context、错误边界、事件冒泡都按 React 组件树走,不按 DOM 树走——这是它和手动appendChild的本质区别。SSR 场景下要在useEffect后再渲染 Portal,避免水合错误。- 一个能用的模态框要同时做六件事:Portal 到 body、焦点陷阱(Tab 不出模态)、进入聚焦、Escape 关闭、body 滚动锁定、点击遮罩关闭 + 内容阻止冒泡、关闭后焦点归还触发元素。只做 Portal 那一条,模态框仍不可用——键盘用户出不去、背景照样滚。
- 无障碍两层都要做:焦点管理(让键盘用户能用)+ ARIA 语义(
role="dialog"/aria-modal="true"/aria-labelledby,让屏幕阅读器用户能懂)。aria-modal是声明、焦点陷阱是执行,二者互为保险。 - Portal 的事件冒泡走 React 树不走 DOM 树——这会让"点击外部关闭"这类依赖冒泡路径的逻辑反直觉出 bug。解法是显式用遮罩元素处理关闭,而非依赖全局事件冒泡。
金句:Portal 让模态框在 DOM 里"出逃",在 React 里"回家"——搬了身体,没断灵魂。 但 Portal 只是模态框六分之一的活,剩下五分之四的可访问性细节,Radix 早就替你踩完了——除非为了学习,否则别和雷区较劲。
Related