Menu

React Portal 教程:用 createPortal 做弹窗和提示框

createPortal 能把组件的一部分渲染到另一个 DOM 节点(比如 document.body)中,同时它在 React 树中的位置不变。弹窗、提示框和菜单需要摆脱 overflow hidden 和 z-index 层叠限制时,就用它。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

Portal 能把组件的一部分渲染到 DOM 中的另一个位置,通常是 document.body,同时它在 React 树中的位置保持不变。用 react-dom 中的 createPortal(children, domNode) 来创建。弹窗、提示框和下拉菜单就是靠 Portal 摆脱带有 overflow: hidden 或自身层叠上下文的父元素。

下面的方框会裁掉所有超出它的内容。把两个提示都打开看看。

普通提示在虚线边框处被截断。Portal 提示则完整显示,因为它的 DOM 节点是 <body> 的子节点,而不是方框的子节点。把方框上的 overflow: 'hidden' 删掉,普通提示就不会再被裁掉了。

语法

import { createPortal } from 'react-dom';

createPortal(children, domNode, key?)
  • children 可以是任意 JSX:一个元素、一个 Fragment、一个组件。
  • domNode 是一个已存在的 DOM 元素,比如 document.body 或 document.getElementById('modal-root')。Portal 渲染时它必须已经存在。
  • key 是可选的,用于渲染一组 Portal 的情况。

createPortal 返回的东西可以像任何元素一样放进 JSX。它不会在父组件自己的 DOM 位置渲染任何内容。

渲染到 document.body 的弹窗

弹窗是最典型的例子。在带有 transform 的卡片内部,position: fixed 的遮罩层会相对于这张卡片而不是窗口定位,然后卡片的 overflow: hidden 又会把它裁掉。渲染到 document.body 中,它就能覆盖整个视口。

打开弹窗,然后按 Escape 或点击深色遮罩层来关闭它。弹窗打开时,焦点会移到 Close 按钮上。现在去掉 createPortal 直接返回遮罩层(删掉这个调用和它的 document.body 参数):遮罩层会缩小到卡片的大小,因为 transform 让卡片成了 fixed 元素的包含块。

事件沿 React 树冒泡

Portal 改变的是 DOM 节点的位置,而不是组件的位置。React 事件沿着 React 树向上冒泡,所以 Portal 内的点击会到达渲染它的组件的 onClick,即使在 DOM 中这个按钮是 <body> 的子元素。

原生监听器则不同。用 addEventListener 添加的监听器沿 DOM 树传播,根本收不到这次点击。

点击“Inside the wrapper”:React 处理函数和原生监听器都会打印日志。点击“In a portal”:只有 React 处理函数会打印。在屏幕上,Portal 中的按钮位于虚线包裹层之外,但 React 仍然把它当作子元素。

这通常正是你想要的:菜单父元素上的 onClick 能收到 Portal 中菜单项的点击,组件上方的 context provider 在 Portal 内也同样生效。但它可能会让“点击外部关闭”的逻辑出乎意料。包裹层上用于关闭菜单的 onClick,在点击菜单 Portal 内部时也会触发,所以要在菜单内部阻止冒泡(e.stopPropagation(),见事件一页),或者使用 document 上的原生监听器,并检查目标是否在 Portal 的 DOM 节点内。

渲染到自己的容器中

document.body 是最简单的目标。有些应用会在 index.html 中专门加一个容器,让所有浮层共用一个位置和同一套层叠顺序:

<body>
    <div id="root"></div>
    <div id="modal-root"></div>
</body>
createPortal(<Modal />, document.getElementById('modal-root'));

使用服务端渲染时(Next.js 和其他框架),服务器上没有 document。只在组件挂载后才渲染 Portal,比如用一个由 effect 设为 true 的 mounted state 来控制。

当 Portal 需要定位在触发元素旁边时,先测量触发元素。第一个例子是在点击处理函数中测量的;对于不能闪烁的提示框,要在 useLayoutEffect 中测量,它会在浏览器绘制之前运行。

无障碍弹窗

单纯把标记移到 document.body 中,对键盘用户和屏幕阅读器用户没有任何帮助。弹窗还需要:

  • role="dialog" 和 aria-modal="true",并用 aria-labelledby 指向它的标题。
  • 打开时把焦点移进对话框(例子中聚焦到 Close),关闭时把焦点还给打开它的按钮。
  • 按 Escape 关闭。
  • 打开期间把焦点限制在内部,让 Tab 键不会跑到背后的页面上。弹窗打开时给应用根元素设置 inert 属性,就能阻止那里的焦点和点击。

原生的 <dialog> 元素能替你处理其中大部分工作。用 dialogRef.current.showModal() 打开时,它会绘制在浏览器顶层,位于所有 z-index 之上,让页面其他部分变为 inert,并在按 Escape 时关闭。它不需要 Portal,所以很适合作为简单确认对话框的默认选择;提示框、菜单和自定义浮层仍然要用 Portal。

为什么只靠 z-index 不够

开发者往往是在一个很大的 z-index 也没能让菜单显示在页面最上层之后,才想到 Portal。原因在于层叠上下文。带有 position 和 z-index 的元素、opacity 小于 1 的元素,以及带有 transform、filter 或 isolation: isolate 的元素,都会创建新的层叠上下文,它的子元素的 z-index 只在这个上下文内部相互比较。在 z-index: 1 的卡片里,一个 z-index: 9999 的子元素仍然位于 z-index: 2 的兄弟卡片之下。

Portal 会把元素移出所有这类祖先元素。作为 <body> 的子元素,它的 z-index 是和页面顶层元素比较的,所以浮层用 1000 这样适中的值就够了。

常见错误

渲染到一个还不存在的节点。 如果元素不存在,document.getElementById('modal-root') 会返回 null,createPortal 会抛出“Target container is not a DOM element”。检查一下 HTML,或者改为渲染到 document.body。

在渲染时创建目标节点。 在组件函数体中写 document.createElement('div'),每次渲染都会创建一个新节点。应该在 effect 中只创建一次,或者使用固定的容器。

弹出层不跟随触发元素。 用 getBoundingClientRect() 定位的提示框,在页面滚动或缩放时不会跟着触发元素移动。在 scroll 和 resize 时重新计算位置(并在 effect 的清理函数中移除这些监听器),或者在页面滚动时关闭提示框。

常见问题

React 中的 Portal 是什么?

一种把子元素渲染到父组件 DOM 元素之外的某个 DOM 节点中的方法。用 react-dom 中的 createPortal(children, domNode) 创建。子元素在 React 树中的位置不变,所以 props、state 和 context 照常工作。

什么时候应该使用 Portal?

当某样东西必须显示在容器之上或之外时:弹窗、提示框、下拉菜单、toast 通知。否则带有 overflow: hidden、transform 或自身层叠上下文的父元素会把它裁掉或遮住。

事件会从 Portal 中冒泡出来吗?

会,沿着 React 树冒泡。Portal 内的点击会到达 React 父组件上的 onClick 处理函数,即使 DOM 节点位于 document.body 中。而用 addEventListener 添加的原生监听器则沿 DOM 树传播。

context 在 Portal 中能用吗?

能。和事件一样,context 也沿着 React 树传递。渲染到 document.body 中的弹窗,仍然能读取创建它的组件上方的 provider 提供的主题或用户。

做弹窗一定需要 Portal 吗?

不一定。用 showModal() 打开的原生 <dialog> 元素会绘制在浏览器的顶层(top layer),位于所有 z-index 之上,并且内置了焦点处理。自定义弹窗以及提示框和菜单,通常会选用 Portal。

Coddy 编程语言插图

和 Coddy 一起学编程

开始