React Portal

Portal 提供一種方式,讓你可以將子元件渲染到父元件 DOM 樹以外的 DOM 節點。這在建立 Modal、Tooltip、Dropdown 等需要「跳出」父元件容器的 UI 時非常有用。

為什麼需要 Portal?

通常,React 元件會渲染到其父元件的 DOM 節點內。但有時候這會造成問題:

// 假設這是一個卡片元件
function Card() {
  return (
    <div className="card" style={{ overflow: 'hidden' }}>
      {/* Modal 會被 overflow: hidden 截斷 */}
      <Modal>...</Modal>
    </div>
  )
}

當父元件有 overflow: hiddenz-indextransform 等 CSS 屬性時,子元件可能會被截斷或遮蔽。Portal 讓你可以將子元件渲染到 DOM 的其他位置,避免這些問題。

基本用法

import { createPortal } from 'react-dom'

function MyComponent() {
  return createPortal(<div>這會渲染到 document.body</div>, document.body)
}

createPortal 接受兩個參數:

  1. 要渲染的 React 元素
  2. 目標 DOM 節點

建立 Modal

import { createPortal } from 'react-dom'

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose}>
          ✕
        </button>
        {children}
      </div>
    </div>,
    document.body
  )
}

// 使用
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsModalOpen(true)}>開啟 Modal</button>

      <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
        <h2>這是 Modal 標題</h2>
        <p>Modal 內容...</p>
      </Modal>
    </div>
  )
}

CSS:

.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  padding: 20px;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
  position: relative;
}

.modal-close {
  position: absolute;
  top: 10px;
  right: 10px;
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
}

建立 Tooltip

import { useState, useRef, createPortal } from 'react'

function Tooltip({ children, text }) {
  const [isVisible, setIsVisible] = useState(false)
  const [position, setPosition] = useState({ top: 0, left: 0 })
  const triggerRef = useRef(null)

  function handleMouseEnter() {
    const rect = triggerRef.current.getBoundingClientRect()
    setPosition({
      top: rect.top - 8,
      left: rect.left + rect.width / 2,
    })
    setIsVisible(true)
  }

  function handleMouseLeave() {
    setIsVisible(false)
  }

  return (
    <>
      <span ref={triggerRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
        {children}
      </span>

      {isVisible &&
        createPortal(
          <div
            className="tooltip"
            style={{
              position: 'fixed',
              top: position.top,
              left: position.left,
              transform: 'translate(-50%, -100%)',
            }}
          >
            {text}
          </div>,
          document.body
        )}
    </>
  )
}

// 使用
function App() {
  return (
    <p>
      請將滑鼠移到
      <Tooltip text="這是提示文字">
        <span style={{ textDecoration: 'underline', cursor: 'help' }}>這裡</span>
      </Tooltip>
      查看提示。
    </p>
  )
}

Portal 的事件冒泡

即使 Portal 將元素渲染到 DOM 的其他位置,事件仍然會在 React 樹中正常冒泡:

function Parent() {
  function handleClick() {
    // 這會被觸發,即使 Modal 渲染到 document.body
    console.log('Parent clicked')
  }

  return (
    <div onClick={handleClick}>
      <Modal>
        <button>點我</button>
      </Modal>
    </div>
  )
}

這是因為 Portal 只是改變了 DOM 的位置,React 元件樹的結構沒有改變。

建立 Portal 容器

更好的做法是建立一個專門的 Portal 容器,而不是直接使用 document.body

<!-- index.html -->
<body>
  <div id="root"></div>
  <div id="portal-root"></div>
</body>
function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null

  // 使用專門的 Portal 容器
  const portalRoot = document.getElementById('portal-root')

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    portalRoot
  )
}

完整範例:確認對話框

import { useState, createPortal } from 'react'

function ConfirmDialog({ isOpen, title, message, onConfirm, onCancel }) {
  if (!isOpen) return null

  return createPortal(
    <div className="dialog-overlay">
      <div className="dialog-box">
        <h3 className="dialog-title">{title}</h3>
        <p className="dialog-message">{message}</p>
        <div className="dialog-actions">
          <button className="btn btn-secondary" onClick={onCancel}>
            取消
          </button>
          <button className="btn btn-danger" onClick={onConfirm}>
            確認
          </button>
        </div>
      </div>
    </div>,
    document.body
  )
}

function DeleteButton({ itemName, onDelete }) {
  const [showConfirm, setShowConfirm] = useState(false)

  function handleConfirm() {
    onDelete()
    setShowConfirm(false)
  }

  return (
    <>
      <button onClick={() => setShowConfirm(true)}>刪除</button>

      <ConfirmDialog
        isOpen={showConfirm}
        title="確認刪除"
        message={`確定要刪除「${itemName}」嗎?此操作無法復原。`}
        onConfirm={handleConfirm}
        onCancel={() => setShowConfirm(false)}
      />
    </>
  )
}

// 使用
function TodoItem({ todo, onDelete }) {
  return (
    <li>
      <span>{todo.text}</span>
      <DeleteButton itemName={todo.text} onDelete={() => onDelete(todo.id)} />
    </li>
  )
}

Portal 的使用時機

適合使用 Portal 的情況:

  • Modal / Dialog:需要覆蓋整個頁面
  • Tooltip:需要超出父容器範圍
  • Dropdown:需要避免被父容器截斷
  • Toast / Notification:固定在螢幕角落
  • 全螢幕覆蓋層:如圖片檢視器