跳转到主要内容
跳转到主要内容

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

参数说明

参数类型说明
childrenReactNode要渲染的 React 子元素
containerElement | DocumentFragment目标 DOM 节点(如 document.getElementById)
keystring | null可选的唯一 key(用于列表)

实战演练

示例 1: 模态框渲染

import { useState } from 'react';
import Modal from './Modal';

export default 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

import Tooltip from './Tooltip';

export default 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. 常见使用场景

场景原因目标容器
模态框突破父容器 overflowdocument.body
下拉菜单避免被父容器裁剪document.body
Tooltip/Popoverz-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 不受父容器影响

相关链接

createPortal API - ReactDOM Reference - React 文档