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: 模态框渲染

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

场景原因目标容器
模态框突破父容器 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 不受父容器影响

相关链接