2026/9/24 20:41:24

React createPortal 实战:解决弹窗层级与 DOM 挂载

React createPortal 实战:解决弹窗层级与 DOM 挂载 你是不是也被这个坑过明明写了position: fixed的弹窗却出现在一个被父容器裁剪、盖不住任何东西、层级还乱得一塌糊涂的角落里我刚用 React 那会儿碰到这种问题第一反应就是调z-index从 10 调到 9999 再调到 999999最后还是被某个父级元素死死压住。后来才意识到问题根本不在z-index而在 DOM 节点挂载的位置 —— 而 createPortal 就是 React 官方用来解决这类问题的手段它能把子组件的 DOM 渲染到父组件 DOM 层级之外的任意节点上还不破坏 React 组件的逻辑关系。这篇文章会围绕 createPortal 的定位、原理和实战踩坑展开内容包括它到底解决了什么问题、React 树和 DOM 树为什么可以“脱钩”、怎么用它写一个可靠的弹窗组件、portal 内外的事件和状态怎么协作以及我实际项目中积累的一些边界场景。适合已经能熟练写 React 组件、但遇到层级类问题还只能靠全局样式硬顶的同学。1. 为什么需要把组件渲染到别处一个弹窗引发的“形态崩溃”先说一个反复出现的场景。你在页面中间某个模块里写了一个弹窗组件结构大概是这样的一个相对定位的容器包着列表列表里有个按钮点击按钮弹出工具栏或消息浮层。一切看起来很正常直到浮层出现的一瞬间你发现它被容器裁掉了、看不全或者根本显示不出来。1.1 让人抓狂的四个词overflow、z-index、transform、filter这种“形态崩溃”通常由四个 CSS 属性诱发每一个都能让浮层组件的表现脱离你的预期。第一是overflow: hidden。父容器一旦设置了它而浮层 DOM 恰好作为容器的子节点那它的可视区域就会被硬性裁剪。overflow: auto和overflow: scroll也一样只是表现从“裁剪”变成了“出现滚动条”浮层内容照样被框死在里面。你在 Chrome DevTools 里把overflow勾掉浮层立刻“活”过来但总不能让用户每次打开页面都帮你取消样式。第二是z-index和层叠上下文。z-index只在同一个层叠上下文里比较大小才有效。父元素设置了opacity、transform、filter、will-change之类的属性后会创建一个新的层叠上下文你在这个上下文内部设多少层z-index: 9999都只是在自己那一亩三分地里自嗨对外层元素起不到压制作用。更麻烦的是这类“隐形”的层叠上下文很难通过肉眼检查发现往往要逐个排查祖先节点。第三是transform和filter对position: fixed的降级。严格来说fixed定位是相对于视口的但只要祖先节点上出现transform、filter或perspective这个元素的包含块就变成了最近的这些祖先节点而不是视口。于是你写了一个坚决固定的遮罩层结果页面一滚动它也跟着滚或者位置完全错乱。第四是各种布局上下文比如contain: paint、isolation: isolate。这些属性也会限制子节点的渲染和层叠行为遇到不常见但确实存在。如果你把这类问题单独拆开看每个都有各自的 CSS 解决方案。但把它们放在一个复杂页面上加上第三方组件库、弹窗叠加、动画过渡你很快就会意识到这些方案全是“打地鼠”。改好了一个弹窗另一个弹窗在另一个容器里又出问题。1.2 常见解决办法为什么总是让人难受在没有 createPortal 的年代我见过很多团队用“脏办法”绕过层级问题把弹窗样式写到全局直接通过document.body.appendChild把 DOM 节点插到 body 下或者用高到离谱的z-index配合!important硬压再或者干脆把弹窗组件放到 App 根部用 Redux 或全局状态来触发。这些办法有一个共同的毛病绕开了 React 对 DOM 的管理。你手动 append 了一个节点React 不知道它存在后续组件卸载时这个节点可能变成“幽灵 DOM”样式、事件、状态全得你手工清理维护成本极高。而且弹窗内容通常是某个业务组件的一部分它理应能读到最近的 context、能正常触发父级事件、能跟随父组件的生命周期。手动插节点把这些逻辑全断了组件隔离得越干净功能就越残缺。1.3 createPortal 的出发点把 DOM 挂载点解出来createPortal 的出现相当于 React 官方给了一个答案组件逻辑关系不变但 DOM 位置可以“飞”出去。它的基础用法非常简单React 16.8 之后一般直接从react-dom导入import { createPortal } from react-dom; function Tooltip() { return createPortal( div classNametooltip这是一条提示/div, document.body ); }第二个参数是目标 DOM 节点返回的是可以直接嵌入组件树的 React 元素。子节点会被渲染到这个目标节点下但事件、context、props 以及 React 组件的嵌套关系全部保持不变。一个Tooltip组件无论被嵌在页面多深的组件树里渲染出来的 DOM 都能直接挂在document.body下而它在 React 层面的父组件依然能收到它的事件冒泡。这意味着 CSS 层级问题被绕开了你不再跟那串“包含块”链纠缠浮层 DOM 直接作为 body 的子节点overflow、transform、z-index的威胁都降到了最低。2. 传送门的本质React 树与 DOM 树的“双重身份”刚接触 createPortal 的时候我最困惑的一点是既然 DOM 都被搬到别处了它到底还算不算某个父组件的子组件这种疑虑在排查 bug 时特别致命 —— 如果对 portal 的机制理解不透你会在 context 失效、事件不触发这种问题上完全摸不着头脑。2.1 先用 API 把“搬移”这件事说清楚在 React 18 的写法下createPortal是react-dom包导出的一个函数不是组件所以它通常出现在组件的返回值里import { createPortal } from react-dom; function Modal({ children, open }) { if (!open) return null; return createPortal( div classNamemodal{children}/div, document.getElementById(modal-root) ); }createPortal(children, container)接收两个参数第一个是要渲染的 React 子节点第二个是真实的 DOM 容器节点。返回值看起来是一个普通的 React 元素可以被放在任意组件的 JSX 中。React 在提交阶段会把子节点渲染进container对应的真实 DOM 节点里而不是组件树的当前位置。需要注意的是React 19 之前container必须是一个已经存在于文档中的 DOM 节点。如果你的container是动态创建的就要保证在当前组件渲染到提交阶段之间它已经被插入到document.body或者其他父节点下。2.2 渲染结果的两层结构对比为了搞清楚 portal 前后的差异可以看一个典型例子function App() { return ( div classNameapp Header / Layout Modal open{true} / /Layout Footer / /div ); }在不使用 portal 时DOM 结构大致是body └── #root └── div.app ├── div.header ├── div.layout │ └── div.modal └── div.footer用了createPortal(modal content, document.body)之后DOM 结构会变成body ├── #root │ └── div.app │ ├── div.header │ ├── div.layout // 这里不再有 modal 节点 │ └── div.footer └── div.modal // portal 渲染到这里在 React 组件的层级关系里Modal依然是Layout的子组件它的 props 更新、context 读取、事件冒泡路径都遵循 React 树结构。但在真实 DOM 里它的内容已经脱离div.layout成为body的直接子节点。这个“双重身份”是理解 createPortal 一切行为的关键React 层的关系没变DOM 层的位置变了。2.3 为什么 React 层的关系保持不变如此重要如果 createPortal 只是单纯把 DOM 搬个家那它和手写document.body.appendChild没有本质区别。它真正的价值在于React 的组件树依然是一个完整的逻辑树portal 节点只是在这个逻辑树里被“标记”为渲染到别处。这意味着几件事context 依然共享。Modal内可以正常读取外层ThemeProvider、I18nProvider提供的值和方法不因为 DOM 位置改变而丢失。props 更新正常驱动重渲染。父组件状态变化portal 内部的子组件会照常更新。React 合成事件依然遵循组件树冒泡。在 portal 内部点击某个按钮事件会一路冒泡到App甚至更高的组件而不是受限于真实 DOM 的父子关系。这三点解决了“手动插 DOM”方案最头疼的问题。我们之所以能放心地把弹窗内容写成一个纯正的 React 子组件而不是拆出去单独维护一套状态就是因为这些逻辑关系在 portal 中全部保留。2.4 与“渲染到另一个 root”的本质区别还有一个容易混淆的概念createRoot创建多个根节点把弹窗渲染到另一个根里这不是和 portal 一样吗其实差别很大。如果换用const anotherRoot createRoot(document.getElementById(another-root)); anotherRoot.render(Modal /);那么这个Modal是一个独立的应用根节点它和原来的 React 树之间没有任何组件树关系。它读不到外层 context事件也不会冒泡回原组件树状态同步只能靠外部事件总线、自定义全局状态等手段。虽然 DOM 上的效果很像但两个根节点是两个独立的 React 渲染体系它们之间的协调成本远高于 portal。createPortal 更像“身在曹营心在汉”DOM 挂到别人家React 的心还在原本的组件树里。3. 手写一个可靠的 ModalcreatePortal 的完整落地概念部分铺垫了这么多现在落到实践。弹窗是 createPortal 最经典的应用场景但不是所有弹窗都值得手写。我的建议是项目里如果只是需要一两个简单的消息提示或确认框直接用组件库Ant Design 的 Modal、arco 的 Modal 等都内置了 portal 处理但如果你需要高度定制弹窗、要做统一的面板组件或者团队对产物体积有严格控制那还是有必要亲手实现一个。3.1 先搭一个牢靠的容器节点管理方案portal 的第二个参数需要的是真实 DOM 节点。直接把div写死在 HTML 页面里也行但更好的做法是在组件里动态创建、用后再清理避免 HTML 里留着无用的空节点。下面这个 Modal 组件会在挂载时创建一个专属容器并插入到document.bodyimport { useEffect, useMemo, useRef } from react; import { createPortal } from react-dom; function Modal({ open, children, onClose }) { const containerRef useRef(null); if (!containerRef.current) { containerRef.current document.createElement(div); containerRef.current.className portal-modal-container; } useEffect(() { const container containerRef.current; document.body.appendChild(container); return () { document.body.removeChild(container); }; }, []); if (!open) return null; return createPortal( div classNamemodal-overlay div classNamemodal-panel{children}/div /div, containerRef.current ); }这里用useRef保存容器节点并且在组件首次渲染时就创建。useEffect里把容器插入 body清理函数里再移除。这样每次打开弹窗DOM 里会临时多出一个div classportal-modal-container弹窗关闭后自动清理干净。注意不能把document.createElement放在组件函数内部的普通变量里否则每次渲染都会新建节点导致渲染位置不稳定。用useRef或模块级变量的方式保持节点引用唯一。3.2 处理弹窗关闭的几个通道弹窗除了按钮关闭通常还有三种关闭方式点击遮罩层关闭、按ESC键关闭、点击右上角 X 关闭。点击遮罩层关闭时要注意事件目标判断。遮罩层和面板是嵌套关系直接给遮罩层加onClick可能会在点击面板内元素时误触发所以更稳妥的是判断事件目标const handleOverlayClick (event) { if (event.target event.currentTarget) { onClose?.(); } };event.target是实际点击的元素event.currentTarget是绑定了事件的元素。只有点击遮罩层本身空白区域时两者才相等点击面板内部不会触发关闭。ESC键关闭需要监听全局键盘事件并结合useEffect在弹窗打开期间挂载监听、关闭后移除useEffect(() { if (!open) return; const handleKeyDown (e) { if (e.key Escape) onClose?.(); }; document.addEventListener(keydown, handleKeyDown); return () document.removeEventListener(keydown, handleKeyDown); }, [open, onClose]);3.3 锁住页面滚动别让弹窗底下还能滑弹窗打开时用户通常不应该还能滚动背后的页面。最简单的做法是弹窗打开时给document.body加上overflow: hidden关闭时移除useEffect(() { if (!open) return; const previousOverflow document.body.style.overflow; document.body.style.overflow hidden; return () { document.body.style.overflow previousOverflow; }; }, [open]);这里的细节是保存之前的样式值而不是直接设置为空字符串。否则如果页面本来就有内联overflow样式关闭后会被错误清除。另外如果页面上可能同时存在多个弹窗就需要一个计数器来控制 body 的滚动锁最后一个弹窗关闭时才恢复滚动。简单实现可以用模块级变量let modalCount 0; function lockBodyScroll() { modalCount 1; if (modalCount 1) { document.body.style.overflow hidden; } } function unlockBodyScroll() { modalCount Math.max(0, modalCount - 1); if (modalCount 0) { document.body.style.overflow ; } }比在每个弹窗里盲目覆盖document.body.style.overflow安全得多。3.4 样式层面怎么处理才不逆天portal 把弹窗内容挂到了 body 下但它仍然需要配合适当的 CSS 才能达到预期效果。我给弹窗样式定了几条基本规则遮罩层使用position: fixedinset: 0背景色半透明z-index设置一个足够大的值比如 1000。面板使用position: fixed或absolute通过inset和margin: auto或transform: translate(-50%, -50%)做水平垂直居中。因为 DOM 挂到了 body 层级这里用z-index就能和页面其他元素可靠地比较不会再受祖先层叠上下文限制。下面是常用的样式示例.portal-modal-container { position: relative; z-index: 1000; } .modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; } .modal-panel { background: #fff; border-radius: 8px; padding: 24px; min-width: 400px; max-width: 90vw; max-height: 80vh; overflow: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); }实际业务里还会遇到一个问题弹窗内部又打开一个新的弹窗。由于 portal 容器都挂在 body 下后渲染的弹窗 DOM 会排在前面弹窗的后面天然处于更高的层叠顺序这通常符合我们“新弹窗覆盖旧弹窗”的预期。如果你对层级有非常严格的控制可以用z-index细调但大多数时候依赖 DOM 顺序就够了。4. portal 内外的事件与状态那些容易被忽略的协作细节如果只是把内容搬到 body 下那 createPortal 的价值还不算完全体现。真正让它在业务中好用的是它保留了 React 的协作机制事件冒泡、context 传递、ref 获取。4.1 合成事件冒泡portal 内的事件并不会“飞出边界”React 的合成事件系统在 React 17 之前是挂在document上的React 17 之后改挂到了 React 树的根容器上。它做事件冒泡时遵循的是 React 组件树的层级而不是真实 DOM 的层级。所以即使弹窗的 DOM 已经移到了 body 下面你在弹窗里点击按钮触发的事件依然会按照“按钮 - Modal - Layout - App”的 React 组件树路径向上冒泡。这意味着父组件可以直接在外部容器上监听来自 portal 内部的事件不需要手动转发function Layout() { return ( div onClick{() console.log(捕获来自 Modal 的点击)} Modal open{true} / /div ); }这在手动appendChild的方案里是不可能做到的。那些方案里你需要自己在弹窗内部维护一堆回调再把回调传给外层组件代码很快就会被回调参数淹没。不过要注意onClick这类 React 合成事件不会受真实 DOM 关系影响但如果你在任何一个节点上使用了原生事件监听比如addEventListener它遵循的是真实 DOM 树冒泡。在 portal 场景下原生事件不会从 portal 内容冒泡到 React 父组件的真实 DOM 节点。这两套事件体系的差异是 portal 相关 bug 的常见来源。4.2 context 依然触手可及弹窗里最常用到的业务逻辑通常是“确认删除”或“填写表单”。这些内容往往依赖全局配置比如主题色、文案、权限信息。这些都是通过 context 传递的。因为 portal 的组件树位置没变它在 React 树里依然是原来的父组件的后代所以 context 传递不会断function App() { return ( ThemeContext.Provider value{{ theme: dark }} Page / /ThemeContext.Provider ); } function Page() { return Modal open{true} /; } function Modal() { const theme useContext(ThemeContext); // theme 依然能读到 { theme: dark } return createPortal(div当前主题{theme.theme}/div, document.body); }如果你曾经用过早期的弹窗库可能记得有些库需要你把 context 值手动透传给弹窗组件那本质上就是因为那些库不是基于 portal 实现的。4.3 ref 和 DOM 查找的边界给 portal 内部的元素打ref拿到的依然是对应元素的真实 DOM 节点这一点和普通组件没有区别function Modal() { const panelRef useRef(null); return createPortal( div ref{panelRef} classNamemodal-panel 内容 /div, document.body ); }但有一个容易踩坑的地方如果你用document.querySelector去父组件容器里查找弹窗内部的 DOM是查不到的因为弹窗 DOM 已经挂到 body 下了。反过来在一些测试用例里你用screen.getByText(xxx)能找到弹窗内容但用container.querySelector却返回 null原因也在这里。写测试时需要用document.body或baseElement作为查询范围而不是组件的容器节点。4.4 用 portal 而不额外增加状态负担在不需要弹窗功能时即使 Modal 没有渲染到 DOM它依然作为 React 组件被挂载在树里只是返回了null或者你没传open。这样弹窗内容的复用一个组件就完成了父组件不需要额外维护一套“弹窗状态机”只需要控制一个布尔值。和渲染到另一个 React 根节点的方案相比portal 组件可以顺理成章地使用 hooks 管理内部表单、加载状态、数据请求这些逻辑都封装在 Modal 内部父组件只负责开关。这在代码组织上的收益比“省几行代码”要重要得多。5. 避坑清单与进阶玩法从挂载时机到多 portal 并存createPortal 本身不难难的是在真实项目里把它用好。这一节我把自己踩过、以及帮同事排查过的坑整理成一份清单按发生频率排序。5.1 容器节点不存在的经典报错在 React 16 时代很多人这样写createPortal(children, document.getElementById(portal-root))然后报错Target container is not a DOM element.原因通常是 JS 代码在 DOM 尚未解析完时执行或portal-root这个节点压根没有在 HTML 里定义。对应方案有两种在 HTML 里提前写死div idportal-root/div并确保脚本在它之后执行。运行时动态创建容器如第 3 节写的用useRef创建并插入 body 的方式。动态创建方式更灵活也更好清理。我在组件库开发时通常会封装一个createPortalContainer的工具函数统一管理容器的创建、class 命名和销毁逻辑。5.2 SSR 场景下的兼容处理document在服务端渲染时不存在如果 SSR 期间直接调用createPortal第二个参数就会是undefined导致报错。即使第二个参数不报错服务端渲染也不应该把弹窗内容渲染到某个容器节点里。常见做法是做一个渲染环境判断import { useMemo } from react; import { createPortal } from react-dom; function ClientOnlyPortal({ children }) { const container useMemo(() { if (typeof document undefined) return null; const el document.createElement(div); document.body.appendChild(el); return el; }, []); if (!container) return null; return createPortal(children, container); }或者更简单一点在组件挂载后再渲染 portal 内容const [mounted, setMounted] useState(false); useEffect(() setMounted(true), []); if (!mounted) return null; return createPortal(children, document.body);这种做法在 SSR 时先不渲染弹窗客户端水合后再挂载。需要注意的是水合前后会产生一次可见的内容闪现差异先无弹窗后有弹窗但动效弹窗通常能掩盖这个问题。5.3 多个 portal 并存容器的隔离与层级控制如果页面上存在多个 portal 实例每个都往 body 下挂自己的容器最担心的是全局遮罩和弹窗上下层关系混乱。我的经验是一个业务场景维护一个容器节点。比如页面级弹窗用page-modal-container全局消息通知用global-notification-container工具提示用tooltip-container。这样有几个好处全局通知永远在弹窗上层不会被页面弹窗盖住。工具提示可以统一设置很小的z-index不会和弹窗争抢层级。排查问题时DevTools 里的 DOM 结构一目了然。如果多个 portal 容器都粗暴地直接挂在 body 下层级就完全由渲染顺序决定。后挂载的在前但什么时候挂载取决于组件状态这种顺序往往是隐晦的、不可预测的非常容易踩坑。5.4 事件冒泡相关的场景化问题在 portal 里用e.stopPropagation()时要格外小心。它阻止的不仅是 React 合成事件向 React 树父级冒泡还包括真实 DOM 事件冒泡到document层级的监听器。如果你在弹窗里点击某个按钮按钮的onClick里调用了stopPropagation那么弹窗外层div上通过原生addEventListener绑定的点击监听器也不会收到这个事件。举个例子我在一个项目里给页面根节点绑定了原生点击事件用来实现“点击页面其他区域收起侧边栏”的效果但弹窗内用户点击任何一个按钮都因为按钮的stopPropagation导致外层收不响应。后来我改成了“另一种判断方式”在根节点监听点击判断event.target是否在某个容器内而不是依赖冒泡路径的阻断。所以我的建议是尽量避免在 portal 内部随意调用stopPropagation尤其是不知道外层有没有原生监听器时。确实需要阻断时先确认阻断范围。5.5 性能与 React 渲染路径createPortal 不会跳过 React 的协调过程。portal 内部组件更新时React 依然会从最近的状态变更源头开始协调并更新 portal 内的子树。这意味着 portal 并不像有些人想象的那样是一个独立的“轻量级渲染层”。如果你在 portal 内渲染了一个非常大的列表或复杂图表它的性能特征和排在组件树里的普通节点是一样的。想做性能优化该用React.memo还是用该拆分组件树还是拆。createPortal 解决的是 DOM 位置问题不是渲染性能问题。另外如果 portal 的目标容器每次渲染都换成新的节点React 会频繁卸载和重建子树性能会肉眼可见地下降。所以第 3 节里用useRef保持容器引用稳定不仅是逻辑需要也是对性能的保障。5.6 在不依赖第三方库的前提下扩展功能基于 portal 还可以做不少富有业务价值的封装全局通知系统一个NotificationProvider内部维护通知列表用 portal 渲染到固定容器配合context暴露notice.success / notice.error等方法。按需加载弹窗弹窗内容用React.lazy懒加载portal 负责把它渲染到 body 下降低首屏体积。浮层坐标跟踪做一个类似 Popover 的组件根据触发元素的getBoundingClientRect()计算结果再用 portal 把浮层渲染到 body 下这样浮层不会被容器裁剪也不易出现定位抖动。import { useState } from react; import { createPortal } from react-dom; function Tooltip({ anchor, content }) { const [rect, setRect] useState(null); useEffect(() { if (!anchor) return; const update () setRect(anchor.getBoundingClientRect()); update(); window.addEventListener(resize, update); window.addEventListener(scroll, update, true); return () { window.removeEventListener(resize, update); window.removeEventListener(scroll, update, true); }; }, [anchor]); if (!rect) return null; const style { position: absolute, left: rect.left rect.width / 2, top: rect.bottom 8, transform: translateX(-50%), }; return createPortal(div style{style}{content}/div, document.body); }这个思路在时间轴气泡、自定义右键菜单、富文本编辑器浮层工具条这些组件里都适用。5.7 React 18 与 React 19 下 createPortal 的变化React 18 之后ReactDOM.createPortal依然可用但推荐从react-dom直接导入createPortal。同时React 18 的并发渲染特性对 portal 内部是透明的你不需要为 portal 单独处理Suspense、transition这些机制它们照常生效。React 19 里 createPortal 的定位没有变化仍然是跨层级渲染的主要手段。如果你在官网文档和类型定义里看到它出现的位置和用法应该能发现它已经被视为组件树上一等公民的能力而不是需要特殊照顾的边缘 API。就我个人在实际项目里的体感早期没有 createPortal 的时候弹窗组件和浮层组件的开发就像在泥巴地里开车样式、事件、层级全得自己扛有 createPortal 之后这些组件的形态稳定了很多很多“看起来是玄学”的层级问题从根上就消失了。如果你在项目中遇到的浮层问题总是反复可以先停下来看看问题组件的 DOM 到底挂在哪一层 —— 很多时候把它从父级那棵“锁住”的 DOM 树下解放出来比继续调z-index要有效得多。