#React#TypeScript

React Portal:模态框、Tooltip 与层叠上下文

模态框渲染在组件树深处,会被父级的 z-index/overflow/transform 截断。Portal 把它渲染到 document.body,逃离父级限制。本文讲清 Portal 原理、模态框完整实现(Portal + 焦点陷阱 + Escape + 滚动锁定)、无障碍(aria-modal),以及 Radix Dialog 替你做好这一切。

本文要点

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

你写了个模态框,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:你的模态框被父级"关禁闭"了

先说清楚问题。假设你有这样的结构:

tsx
function Dashboard() {
  return (
    <main style={{ overflow: "hidden", transform: translateY(0) }}>
      <Sidebar />
      <Content />
      <UserMenu /> {/* 里面会弹出一个 Modal */}
    </main>
  );
}

UserMenu 里弹出的 Modal,DOM 上是 <main> 的子孙。这会撞上三个 CSS 规则,每一个都能独立毁掉你的模态框:

  1. overflow: hidden / auto / scroll:父级一旦设了这些值,超出父级盒子的子元素会被裁切。模态框比父容器大、或定位到视口边缘,就会被切掉一截,连遮罩都铺不满屏。

  2. transform、filter、perspective、will-change:这些属性会把元素变成一个新的层叠上下文根。后果是子元素即便设了 position: fixed,也不再相对于视口定位,而是相对于这个被 transform 的祖先——模态框的定位全乱。更糟的是,z-index 的比较被限制在这个新上下文里,你给模态框设 z-index: 999999,也压不过这个祖先外面的兄弟节点。

  3. 层叠上下文是"祖传"的:哪怕你自己没有 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 非常简单:

tsx
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。

最小用法:

tsx
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 那一条,模态框仍然不可用——键盘用户出不去、屏幕阅读器读不到、背景照样滚。

下面给一个完整、类型安全、可直接落地的实现。先看组件本身:

tsx
// 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 上),他们得重新从头摸索。

用法:

tsx
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):把模态的标题(和描述)关联到模态容器,打开时屏幕阅读器会朗读它,相当于让盲人用户"看到"标题。

这就是上一节代码里那三行的含义:

tsx
<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 最容易踩坑的点,也是一个常见的面试题。看这个例子:

tsx
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 示例):

tsx
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 行基础实现,而在于把它补全到"各种边界都正确"所要补的另外几百行——那几百行业务价值为零。

小结

  1. Portal 解决的是"组件树位置"和"DOM 层级"的错位。模态框在组件树上属于深层组件,在 DOM 上却被父级的 overflow、transform、层叠上下文关了禁闭。Portal 让 DOM 节点搬到 document.body,躲开这些限制,position: fixed 和 z-index 重新生效。
  2. createPortal(children, container) 不只是换挂载点,它保留 React 语义。Context、错误边界、事件冒泡都按 React 组件树走,不按 DOM 树走——这是它和手动 appendChild 的本质区别。SSR 场景下要在 useEffect 后再渲染 Portal,避免水合错误。
  3. 一个能用的模态框要同时做六件事:Portal 到 body、焦点陷阱(Tab 不出模态)、进入聚焦、Escape 关闭、body 滚动锁定、点击遮罩关闭 + 内容阻止冒泡、关闭后焦点归还触发元素。只做 Portal 那一条,模态框仍不可用——键盘用户出不去、背景照样滚。
  4. 无障碍两层都要做:焦点管理(让键盘用户能用)+ ARIA 语义(role="dialog" / aria-modal="true" / aria-labelledby,让屏幕阅读器用户能懂)。aria-modal 是声明、焦点陷阱是执行,二者互为保险。
  5. Portal 的事件冒泡走 React 树不走 DOM 树——这会让"点击外部关闭"这类依赖冒泡路径的逻辑反直觉出 bug。解法是显式用遮罩元素处理关闭,而非依赖全局事件冒泡。

金句:Portal 让模态框在 DOM 里"出逃",在 React 里"回家"——搬了身体,没断灵魂。 但 Portal 只是模态框六分之一的活,剩下五分之四的可访问性细节,Radix 早就替你踩完了——除非为了学习,否则别和雷区较劲。

Related

相关推荐