React createPortal 实战:模态框逃出 overflow:hidden、事件冒泡与焦点管理

React createPortal 实战:模态框逃出 overflow:hidden、事件冒泡与焦点管理

写模态框(Modal)时你大概率遇到过这个诡异现象:弹窗明明该盖住全屏,结果只在某个卡片内部显示一小块,还被父容器的滚动条截断了。加 z-index: 9999 也没用。

问题不在层级,在于 DOM 结构。这篇用 createPortal 把这个坑填平,顺带讲清楚 Portal 里事件冒泡和焦点管理这两个真正容易翻车的地方。

先复现:被父容器裁掉的弹窗

假设有个卡片列表,每张卡片里放一个「查看详情」的弹窗:

jsx 复制代码
function Card() {
  const [open, setOpen] = useState(false);
  return (
    <div className="card" style={{ overflow: "hidden", position: "relative" }}>
      <button onClick={() => setOpen(true)}>查看详情</button>
      {open && (
        <div className="modal-mask">
          <div className="modal">这里是详情内容</div>
        </div>
      )}
    </div>
  );
}

你期望 .modal-mask 铺满屏幕,但实际它是 .card 的子节点。父级的 overflow: hidden 会把溢出部分裁掉,position: relative 又让 absolute/fixed 定位的基准变成了这张卡片而不是视口。于是弹窗被困在卡片里。

治标的办法是去掉父级的 overflowtransform,但这些样式往往是布局必需的,牵一发动全身。真正的解法是让弹窗的 DOM 节点根本不待在卡片里。

createPortal:渲染到别处,状态还在这里

createPortal(children, container) 做的事很简单:把 children 渲染到你指定的 DOM 节点里,而不是当前组件的位置。但这个组件在 React 树里的逻辑位置不变------props、state、context 全都照常从父组件流下来。

jsx 复制代码
import { createPortal } from "react-dom";

function Modal({ children, onClose }) {
  // document.body 不受任何卡片的 overflow/transform 影响
  return createPortal(
    <div className="modal-mask" onClick={onClose}>
      <div className="modal" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body
  );
}

改造后的卡片:

jsx 复制代码
function Card() {
  const [open, setOpen] = useState(false);
  return (
    <div className="card" style={{ overflow: "hidden" }}>
      <button onClick={() => setOpen(true)}>查看详情</button>
      {open && <Modal onClose={() => setOpen(false)}>这里是详情内容</Modal>}
    </div>
  );
}

现在 .modal-maskdocument.body 的直接子节点,position: fixed 相对视口定位,不再被任何卡片裁剪。而 open 这个 state 依然住在 Card 里,onClose 也是 Card 传下去的------逻辑归属和 DOM 归属彻底解耦。

配套的 CSS:

css 复制代码
.modal-mask {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
}
.modal {
  background: #fff;
  padding: 24px;
  border-radius: 8px;
  min-width: 320px;
}

坑一:事件冒泡走的是 React 树,不是 DOM 树

这是 Portal 最反直觉的地方。弹窗的 DOM 节点在 body 下,但 React 的合成事件冒泡,走的是组件树而不是真实 DOM 树。

也就是说,如果 Card 外面还包了个 onClick,点击 Portal 里的按钮,事件依然会冒泡到那个外层 onClick:

jsx 复制代码
function Panel() {
  return (
    // 点弹窗里的按钮,这个 onClick 也会触发
    <div onClick={() => console.log("panel clicked")}>
      <Card />
    </div>
  );
}

这经常导致「点弹窗内部,结果触发了背后面板的点击逻辑」。记住:Portal 里的 React 事件按组件层级冒泡 。要拦就在合适的层级 e.stopPropagation(),别指望 DOM 结构帮你隔离。

上面 Modal 里给 .modalonClick={(e) => e.stopPropagation()},就是防止点击弹窗内容时冒泡到 .modal-mask 触发 onClose------这一步利用的正是「按组件树冒泡」的特性。

坑二:焦点管理,别让键盘用户困在弹窗外

弹窗打开后,键盘 Tab 焦点应该被「关」在弹窗内,关闭后焦点要还给触发它的按钮。很多手写模态框忽略这点,导致视障用户用起来完全乱套。

至少要做三件事:打开时聚焦到弹窗、Esc 关闭、关闭后焦点归还。

jsx 复制代码
function Modal({ children, onClose }) {
  const ref = useRef(null);

  useEffect(() => {
    // 记住打开弹窗前的焦点元素,关闭时好还回去
    const prev = document.activeElement;
    ref.current?.focus();

    const onKey = (e) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", onKey);

    return () => {
      document.removeEventListener("keydown", onKey);
      // 归还焦点,否则焦点会掉到 body 上,键盘用户彻底迷路
      prev instanceof HTMLElement && prev.focus();
    };
  }, [onClose]);

  return createPortal(
    <div className="modal-mask" onClick={onClose}>
      <div
        className="modal"
        ref={ref}
        tabIndex={-1}  // 让这个 div 能接收 focus
        role="dialog"
        aria-modal="true"
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    document.body
  );
}

tabIndex={-1}.modal 可以被 .focus() 聚焦但不进入 Tab 序列;role="dialog"aria-modal="true" 让屏幕阅读器知道这是个模态对话框。

坑三:body 滚动穿透

弹窗打开时,如果背景页面还能滚,移动端体验会很糟(滑弹窗结果背景在动)。打开时锁住 body 滚动:

jsx 复制代码
useEffect(() => {
  const original = document.body.style.overflow;
  document.body.style.overflow = "hidden";
  return () => {
    document.body.style.overflow = original;  // 恢复原值,别写死成 ""
  };
}, []);

注意恢复时用保存的 original 而不是硬写 ""。如果同时开了多个需要锁滚动的组件,写死会导致先关的那个把滚动提前放开。生产项目里更稳的做法是用计数器管理,但单弹窗场景保存原值就够了。

进阶:挂到专用容器而不是 body

直接挂 document.body 在多数场景够用,但如果项目里有多个弹窗层级(弹窗里再弹确认框),或者要控制它们的 DOM 顺序,建议挂到一个专用的根节点:

jsx 复制代码
function usePortalContainer() {
  const [el] = useState(() => {
    const div = document.createElement("div");
    div.id = "portal-root";
    return div;
  });
  useEffect(() => {
    document.body.appendChild(el);
    return () => el.remove();  // 组件卸载时清掉,别留一堆空 div
  }, [el]);
  return el;
}

useState 的惰性初始化保证 div 只创建一次(直接 useRef(document.createElement(...)) 也行,但每次渲染都会执行 createElement,虽然结果被丢弃仍是浪费)。返回的 el 传给 createPortal 第二个参数即可。

小结

  • Portal 的价值 :把弹窗 DOM 渲染到 body,逃出父级 overflow: hidden / position / transform 的束缚,状态和 props 仍从原组件流转。
  • 事件冒泡按组件树走 ,不按 DOM 树------想隔离必须显式 stopPropagation,别靠 DOM 结构。
  • 无障碍三件套 :打开聚焦弹窗、Esc 关闭、关闭归还焦点,配 role="dialog" + aria-modal
  • 锁 body 滚动记得保存并恢复原值,别硬写空字符串。

一句话记忆:createPortal 只搬 DOM 不搬逻辑------DOM 挂到 body,事件和状态还在原地。

相关推荐
用户059540174461 小时前
Playwright测试AI记忆存储踩坑实录:这个时序问题让我排查了6小时
前端·css
IT_陈寒1 小时前
Redis踩了个大坑,原来DEL命令也会卡住整个实例
前端·人工智能·后端
一个水瓶座程序猿.1 小时前
基于Spring AI RAG 的AI知识库前端交互实现
前端·人工智能·spring
breeze jiang2 小时前
React + TypeScript 编辑表单:为什么要区分 name 和 editingName
前端·typescript
米码收割机2 小时前
【移动】线上购物移动端网站(源码+文档)【独一无二】
java·开发语言·前端·python·django
To_OC2 小时前
对接大模型流式接口,我被一个 ReadableStream 卡了半小时
前端·node.js·llm
fthux8 小时前
RenoPit 能为普通业主做什么?看懂图纸、审查合同,提前发现装修坑
javascript·人工智能·ai·开源·github·chrome扩展·open source·edge扩展·firefox扩展
仿生狮子9 小时前
✂️ Nuxt 最简单的字体裁剪工具:Fontize
javascript·vue.js·nuxt.js
石小石Orz12 小时前
我发现了开发者AI产品营收的新方向
前端·虚拟现实