React 门户解决了一个基本问题:DOM 层次结构约束。在深度嵌套组件内呈现的模态框继承其父级的 z-index 堆叠上下文,并且可以通过 overflow: hidden 进行剪辑。门户允许您将子级渲染到不同的 DOM 节点,同时保留 React 树,因此上下文、事件处理和组件生命周期都按预期工作。
关键的见解是将 DOM 位置与 React 树位置分离。门户的子级在 DOM 中的其他位置进行物理渲染,但事件通过 React 组件层次结构而不是 DOM 层次结构冒泡。这种心理模型对于理解所有门户模式至关重要。
创建门户 API
API 表面很小:ReactDOM.createPortal(children, domNode, key?)。 domNode 必须在渲染调用之前存在,因此您通常在 HTML 模板中创建门户容器或急切地注入它们。
import { createPortal } from "react-dom";
function Modal({ children, isOpen }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay">
<div className="modal-content" role="dialog" aria-modal="true">
{children}
</div>
</div>,
document.body
);
}
可选的第三个 key 参数控制门户协调。更改密钥以强制重新安装门户子树 - 当您需要重置内部状态时很有用。
function Portal({ children, selector, key }) {
const [container, setContainer] = useState(null);
useEffect(() => {
const el = document.querySelector(selector) || document.body;
setContainer(el);
}, [selector]);
if (!container) return null;
return createPortal(children, container, key);
}
门户目标管理
硬编码 document.body 适用于简单的情况,但在复杂的布局中会崩溃。更好的方法是通过 React 上下文管理的命名门户目标。
const PortalContext = createContext(null);
function PortalHost({ name, children }) {
const [container, setContainer] = useState(null);
useEffect(() => {
const el = document.createElement("div");
el.id = `portal-${name}`;
document.body.appendChild(el);
setContainer(el);
return () => document.body.removeChild(el);
}, [name]);
if (!container) return null;
return createPortal(children, container);
}
function usePortal(name) {
return useContext(PortalContext);
}
这可以实现多区域渲染:模态转到 #modal-root,工具提示转到 #tooltip-root,通知转到 #notification-root。每个区域都可以有自己的样式和布局,并且组件以声明方式选择其目标。 SSR 的处理很自然,因为 useEffect 防护确保门户仅在客户端上创建。
嵌套门户和事件传播
当门户呈现的组件本身呈现门户时,就会出现嵌套门户 - 例如,模式内的下拉菜单。事件传播遵循 React 树,而不是 DOM 树,因此单击下拉选项会在模式组件层次结构中冒泡,然后到达应用程序根目录。这通常是正确的行为,但如果您期望 DOM 级别的事件流,可能会感到惊讶。
function ModalWithDropdown() {
return createPortal(
<Modal>
<Dropdown> {/* This dropdown may also use a portal */}
<DropdownItem onClick={handleAction} />
</Dropdown>
</Modal>,
modalRoot
);
}
为了管理嵌套门户中的 z-index,PortalStack 组件会跟踪当前深度并应用适当的分层。子门户接收父级的 z-index 作为上下文并递增它,以防止覆盖冲突。
上下文和状态管理
由于门户保留其 React 树位置,因此 Context API 可以自然地工作。通过门户呈现的子级可以访问门户调用站点上方的提供者。但是,在某些边缘情况下,门户树需要连接到不同的提供者子树。
function ConnectedPortal({ children, contextOverrides }) {
return (
<ThemeProvider value={contextOverrides.theme}>
{createPortal(children, document.body)}
</ThemeProvider>
);
}
对于跨门户通信,通过上下文传递回调或使用共享的 Zustand 存储。该商店可从任何门户访问,无需特殊布线,这使其成为多门户协调的最简单解决方案。
动画整合
退出动画对于门户来说很棘手,因为 createPortal 渲染到 DOM 节点中,当父节点卸载时,该节点会立即被删除。解决方案是使用成帧器运动中的 AnimatePresence 控制卸载时序。
import { AnimatePresence, motion } from "framer-motion";
function AnimatedModal({ isOpen, onClose, children }) {
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
className="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="modal"
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
exit={{ scale: 0.9 }}
>
{children}
<button onClick={onClose}>Close</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
);
}
AnimatePresence 组件在播放退出动画时将 DOM 节点固定到位,然后将其删除。如果没有这个包装器,门户节点将立即被删除,并且不会运行退出动画。
可访问性模式
门户网站需要仔细的可访问性处理。关键要求是焦点捕获、焦点返回和适当的 ARIA 属性。
function useA11yPortal(triggerRef, isOpen) {
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement;
} else if (previousFocus.current) {
previousFocus.current.focus();
}
}, [isOpen]);
// Focus trap: keep focus inside the portal while open
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e) {
if (e.key === "Escape") onClose?.();
// Tab cycling logic here
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
}
| 属性 | 目的 |
|---|---|
| 占位符_0 | 将元素标识为对话框 |
| 占位符_0 | 表明外部内容是惰性的 |
| 占位符_0 | 应用于背景内容 |
| 占位符_0 | 引用对话框标题 |
当模式打开时,在门户容器的所有同级元素上设置 aria-hidden="true" 。某些屏幕阅读器仍然可以通过其他方式导航到背景内容,因此将 aria-hidden 与 inert 属性相结合可提供最可靠的行为。
性能考虑因素
当门户容器远离 DOM 树中的主应用程序根时,门户内容可能会导致布局抖动。始终将门户子组件包装在 useMemo 中,以避免在父组件更新但门户内容未更改时不必要的重新渲染。
function App() {
const memoizedModal = useMemo(
() => (
<Modal onClose={handleClose}>
<ExpensiveContent />
</Modal>
),
[handleClose]
);
return (
<>
<MainContent />
{showModal && memoizedModal}
</>
);
}
更改门户的 key 属性会强制 React 卸载并重新挂载整个门户子树。由于 DOM 断开和重新连接,这比内联渲染成本更高,因此请避免在热路径中的门户容器上使用更改键。
概括
门户不仅仅适用于模态。相同的模式适用于工具提示、下拉菜单、通知以及任何需要突破父级 CSS 或 DOM 约束的 UI。通过组合命名门户目标、嵌套门户管理、动画集成和可访问性挂钩,您可以构建可重用的门户抽象层,该抽象层可以处理最复杂的覆盖需求,同时保持高性能和可访问性。

