createPortal
createPortal 将子节点渲染到父组件 DOM 层次结构之外的 DOM 节点。
核心概述
⚠️ 痛点: React 组件受限于 DOM 层次结构
- • 子组件必须渲染在父组件的 DOM 节点内
- • z-index 无法突破父元素的 overflow/stacking context
- • 模态框、下拉菜单、tooltip 被父容器裁剪
- • CSS 样式继承影响子元素样式
✅ 解决方案: Portal 突破 DOM 层次限制
- • 渲染到任意位置: 可将子节点渲染到 document.body 或其他 DOM 节点
- • 保持 React 上下文: 仍然在父组件的 React 树中
- • 事件冒泡正常: 事件仍然冒泡到 React 父组件
- • 突破样式限制: 不受父容器 overflow、z-index 限制
💡 心智模型: 传送门
将 Portal 想象成"传送门":
- • 入口在组件内: 在 JSX 中像普通组件一样使用
- • 出口在 DOM 外: 实际渲染到指定的 DOM 节点
- • 保持连接: React 上下文和事件仍然连通
- • 位置自由: 可以传送到 document.body 或任何节点
技术规格
类型签名
TypeScript
import { createPortal } from 'react-dom';
function createPortal(
children: ReactNode,
container: Element | DocumentFragment,
key?: string | null
): ReactPortalNode参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
children | ReactNode | 要渲染的 React 子元素 |
container | Element | DocumentFragment | 目标 DOM 节点(如 document.getElementById) |
key | string | null | 可选的唯一 key(用于列表) |
实战演练
示例 1: 模态框渲染
TypeScript
import { createPortal } from 'react-dom';
import { useEffect, useState } from 'react';
function Modal({ children, isOpen, onClose }: ModalProps) {
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
// 创建或获取 Portal 容器
useEffect(() => {
const root = document.getElementById('modal-root');
if (!root) {
const div = document.createElement('div');
div.id = 'modal-root';
document.body.appendChild(div);
setPortalRoot(div);
return () => div.remove();
}
setPortalRoot(root);
}, []);
if (!isOpen || !portalRoot) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg p-6 max-w-md">
{children}
</div>
</div>,
portalRoot
);
}
// 使用
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div style={{ overflow: 'hidden' }}>
<button onClick={() => setIsModalOpen(true)}>打开模态框</button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<h2>模态框内容</h2>
<p>这个模态框渲染在 body 层级,不受父容器 overflow 限制</p>
</Modal>
</div>
);
}示例 2: Tooltip
TypeScript
import { createPortal } from 'react-dom';
import { useState, useRef, Fragment } from 'react';
function Tooltip({ children, content }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseEnter = (e: React.MouseEvent) => {
const rect = (e.target as HTMLElement).getBoundingClientRect();
setPosition({ x: rect.left + rect.width / 2, y: rect.top });
setIsVisible(true);
};
return (
<Fragment>
<div
ref={containerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setIsVisible(false)}
className="inline-block"
>
{children}
</div>
{isVisible &&
createPortal(
<div
className="fixed z-50 px-3 py-2 bg-gray-900 text-white text-sm rounded shadow-lg"
style={{
left: position.x,
top: position.y - 10,
transform: 'translateX(-50%) translateY(-100%)',
}}
>
{content}
</div>,
document.body
)}
</Fragment>
);
}
// 使用
function App() {
return (
<Tooltip content="这是一个提示信息">
<button>悬停显示 Tooltip</button>
</Tooltip>
);
}示例 3: 事件冒泡演示
TypeScript
import { createPortal } from 'react-dom';
// Portal 内的事件仍然冒泡到 React 父组件
function ParentComponent() {
const handleClick = () => {
console.log('父组件点击事件被触发!');
alert('Portal 的点击事件冒泡到了父组件');
};
return (
<div onClick={handleClick} className="p-4 border">
<h3>父组件</h3>
<ChildComponent />
</div>
);
}
function ChildComponent() {
return createPortal(
<div className="fixed top-4 right-4 p-4 bg-blue-500 text-white">
<button onClick={(e) => {
console.log('Portal 内的按钮被点击');
// 事件会冒泡到 ParentComponent!
}}>
点击我(事件会冒泡)
</button>
</div>,
document.body
);
}
// 重要: Portal 内的事件冒泡遵循 React 树,而非 DOM 树避坑指南
❌ 错误 1: 服务端渲染时使用 Portal
TypeScript
// ❌ 错误: SSR 时 document 不存在
function Modal({ children }) {
// 服务端渲染时会报错: document is not defined
return createPortal(
<div className="modal">{children}</div>,
document.getElementById('modal-root')!
);
}
// 问题:
// 1. 服务端没有 document 对象
// 2. 会导致水合失败
// 3. 应用崩溃✅ 正确 1: 条件渲染或使用 useEffect
TypeScript
// ✅ 正确: 使用 useEffect 延迟创建
function Modal({ children }) {
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
useEffect(() => {
// 只在客户端执行
const root = document.getElementById('modal-root');
setPortalRoot(root);
}, []);
// 客户端渲染前返回 null
if (!portalRoot) return null;
return createPortal(
<div className="modal">{children}</div>,
portalRoot
);
}
// 优点:
// 1. SSR 安全
// 2. 水合一致
// 3. 不会崩溃❌ 错误 2: 忘记清理 Portal 容器
TypeScript
// ❌ 错误: 每次都创建新的 div
function Modal() {
const container = document.createElement('div');
document.body.appendChild(container);
return createPortal(
<div>Modal Content</div>,
container
);
}
// 问题:
// 1. 每次渲染都创建新 div
// 2. 内存泄漏
// 3. DOM 节点无限增长✅ 正确 2: 使用 useEffect 清理
TypeScript
// ✅ 正确: 清理 DOM 节点
function Modal({ children }) {
const [container] = useState(() => {
// 只创建一次
const div = document.createElement('div');
return div;
});
useEffect(() => {
document.body.appendChild(container);
// 清理函数
return () => {
document.body.removeChild(container);
};
}, [container]);
return createPortal(children, container);
}
// 优点:
// 1. 只创建一个容器
// 2. 组件卸载时清理
// 3. 无内存泄漏最佳实践
1. 可复用的 Portal Hook
TypeScript
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
function usePortal(id: string) {
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
useEffect(() => {
// 查找或创建容器
let root = document.getElementById(id) as HTMLElement;
if (!root) {
root = document.createElement('div');
root.id = id;
document.body.appendChild(root);
}
setPortalRoot(root);
// 可选: 组件卸载时移除容器
return () => {
// root.remove(); // 取消注释以自动清理
};
}, [id]);
return portalRoot;
}
// 使用
function Modal({ children }) {
const portalRoot = usePortal('modal-root');
if (!portalRoot) return null;
return createPortal(
<div className="modal">{children}</div>,
portalRoot
);
}2. 常见使用场景
| 场景 | 原因 | 目标容器 |
|---|---|---|
| 模态框 | 突破父容器 overflow | document.body |
| 下拉菜单 | 避免被父容器裁剪 | document.body |
| Tooltip/Popover | z-index 层级控制 | document.body |
| 通知/Toast | 固定位置渲染 | document.body |
3. Portal vs 普通渲染
TypeScript
// 普通渲染 vs Portal 对比
// 普通渲染 - 受限于父容器
function NormalModal() {
return (
<div>
{/* 父容器有 overflow: hidden 时,模态框会被裁剪 */}
<div className="fixed inset-0">模态框</div>
</div>
);
}
// Portal 渲染 - 突破限制
function PortalModal() {
return createPortal(
<div className="fixed inset-0">模态框</div>,
document.body // 直接渲染到 body
);
}
// 关键差异:
// 1. DOM 层次: 普通(子节点) vs Portal(body 直接子节点)
// 2. 事件冒泡: 都遵循 React 树
// 3. Context: 都能访问父 Context
// 4. 样式: Portal 不受父容器影响相关链接
- Fragment API - 不创建额外 DOM 的分组
- render API - ReactDOM 渲染方法
- 添加 React 到网站 - 在现有项目中使用 React