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: hidden、z-index 或 transform 等 CSS 屬性時,子元件可能會被截斷或遮蔽。Portal 讓你可以將子元件渲染到 DOM 的其他位置,避免這些問題。
基本用法
import { createPortal } from 'react-dom'
function MyComponent() {
return createPortal(<div>這會渲染到 document.body</div>, document.body)
}
createPortal 接受兩個參數:
- 要渲染的 React 元素
- 目標 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:固定在螢幕角落
- 全螢幕覆蓋層:如圖片檢視器