news 2026/7/30 5:07:27

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React createPortal 实战:模态框逃出 overflow:hidden、事件冒泡与焦点管理

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

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

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

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

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

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 全都照常从父组件流下来。

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 ); }

改造后的卡片:

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:

.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:

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 关闭、关闭后焦点归还。

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 滚动:

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

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

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

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

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,事件和状态还在原地。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/30 5:04:22

AI 与传统文化融合的下半场:从娱乐到研究的方法论升级

AI 与传统文化融合的下半场&#xff1a;从娱乐到研究的方法论升级 一、个性化深度引言 过去两年&#xff0c;AI与传统文化的结合方式可以概括为"娱乐化"——AI写古诗、AI画水墨画、AI生成对联。这些应用传播广、趣味性强&#xff0c;但本质上是用AI的生成能力为传统…

作者头像 李华
网站建设 2026/7/30 5:04:16

2026深度实测:16款降AIGC网站实测,闭眼入这款就对了!

随着AI写作技术的迅猛发展&#xff0c;越来越多的学术创作者开始依赖各类生成工具提升效率。然而&#xff0c;随着2026年各大高校与科研机构对AIGC检测技术的不断升级&#xff0c;论文中若存在明显AI痕迹&#xff0c;极有可能被判定为学术不端。在这一背景下&#xff0c;如何有…

作者头像 李华
网站建设 2026/7/30 5:03:53

【单片机毕业设计推荐】基于 STM32 的人体健康监测与跌倒报警装置设计与实现,基于 STM32 的可穿戴运动健康监测终端及 WiFi 移动端系统设计(013304)

文章目录20 个相关毕业设计备选题目项目研究背景摘要总体方案核心功能技术路线项目演示关于我们项目案例源码获取温馨提示&#xff1a;本人主页置顶文章(点我)有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)有 CSDN 平台官…

作者头像 李华
网站建设 2026/7/30 5:03:19

REFramework终极指南:5分钟为RE引擎游戏安装模组和脚本平台

REFramework终极指南&#xff1a;5分钟为RE引擎游戏安装模组和脚本平台 【免费下载链接】REFramework Mod loader, scripting platform, and VR support for all RE Engine games 项目地址: https://gitcode.com/GitHub_Trending/re/REFramework 你是否曾经想过为《生化…

作者头像 李华
网站建设 2026/7/30 5:02:22

Selenium模拟登录全攻略:从环境搭建到实战优化

1. 项目概述&#xff1a;为什么Selenium是模拟登录的“瑞士军刀”&#xff1f; 在数据驱动的时代&#xff0c;从公开网页上获取信息是很多数据分析、市场研究甚至个人兴趣项目的起点。但很多有价值的数据都藏在登录墙后面&#xff0c;比如查看个人账户详情、获取会员专属内容或…

作者头像 李华
网站建设 2026/7/30 5:01:54

2026AI论文工具稀缺功能排行榜[特殊字符]真正有独家技术的只有OKBIYE

很多人选论文工具只看“能不能用”&#xff0c;但2026双检内卷拼的是独家技术、稀缺功能、定稿不翻车&#xff01; 市面90%AI工具都是通用模板、同质化套壳&#xff0c;看似功能多&#xff0c;实则没有一项能顶住高校严格双检、盲审、期刊投稿审核。 今天按行业稀缺度、技术壁…

作者头像 李华