Portals传送门 #
一、Portals 概述 #
1.1 什么是 Portals #
Portals 提供了一种将子组件渲染到父组件 DOM 层级之外的 DOM 节点的方式。
text
组件树 DOM 树
┌─────────┐ ┌─────────┐
│ App │ │ App │
└────┬────┘ └────┬────┘
│ │
┌────┴────┐ ┌────┴────┐
│ Modal │ ──────────► │ Modal │ (渲染到 body)
└─────────┘ └─────────┘
1.2 使用场景 #
| 场景 | 说明 |
|---|---|
| 模态框 | 避免父元素 overflow 遮挡 |
| 悬浮提示 | 脱离父组件层级 |
| 通知消息 | 全局显示 |
| 下拉菜单 | 避免裁剪问题 |
1.3 Preact 中的 Portals #
Preact 使用 createPortal 函数实现 Portals:
jsx
import { createPortal } from 'preact';
function Modal({ children }) {
return createPortal(
<div class="modal">{children}</div>,
document.body
);
}
二、基本用法 #
2.1 创建 Portal #
jsx
import { createPortal } from 'preact';
function Tooltip({ children, target }) {
return createPortal(
<div class="tooltip">
{children}
</div>,
target
);
}
// 使用
function App() {
const containerRef = useRef(null);
return (
<div>
<div ref={containerRef} class="tooltip-container" />
<Tooltip target={containerRef.current || document.body}>
Tooltip content
</Tooltip>
</div>
);
}
2.2 模态框示例 #
jsx
import { createPortal } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div class="modal-overlay" onClick={onClose}>
<div
class="modal-content"
ref={modalRef}
onClick={(e) => e.stopPropagation()}
>
<button class="modal-close" onClick={onClose}>×</button>
{children}
</div>
</div>,
document.body
);
}
// 使用
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<button onClick={() => setIsModalOpen(true)}>
Open Modal
</button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<h2>Modal Title</h2>
<p>Modal content goes here.</p>
</Modal>
</div>
);
}
2.3 通知系统 #
jsx
import { createPortal } from 'preact';
import { useState, useEffect } from 'preact/hooks';
const notifications = [];
let setNotifications = null;
function notify(message, type = 'info', duration = 3000) {
const id = Date.now();
const notification = { id, message, type };
if (setNotifications) {
setNotifications(prev => [...prev, notification]);
setTimeout(() => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, duration);
}
}
function NotificationContainer() {
const [items, setItems] = useState([]);
setNotifications = setItems;
return createPortal(
<div class="notification-container">
{items.map(item => (
<div key={item.id} class={`notification notification-${item.type}`}>
{item.message}
</div>
))}
</div>,
document.body
);
}
// 使用
function App() {
return (
<div>
<NotificationContainer />
<button onClick={() => notify('Success!', 'success')}>
Show Success
</button>
<button onClick={() => notify('Error!', 'error')}>
Show Error
</button>
<button onClick={() => notify('Warning!', 'warning')}>
Show Warning
</button>
</div>
);
}
三、高级用法 #
3.1 动态容器 #
jsx
function Portal({ container, children }) {
const [target, setTarget] = useState(null);
useEffect(() => {
if (typeof container === 'string') {
setTarget(document.querySelector(container));
} else if (container instanceof Element) {
setTarget(container);
} else {
setTarget(document.body);
}
}, [container]);
if (!target) return null;
return createPortal(children, target);
}
// 使用
function App() {
return (
<div>
<Portal container="#sidebar">
<div>Sidebar content</div>
</Portal>
<Portal container={customElement}>
<div>Custom container content</div>
</Portal>
</div>
);
}
3.2 嵌套 Portal #
jsx
function NestedPortal() {
return createPortal(
<div>
<h2>Outer Portal</h2>
{createPortal(
<div>
<h3>Inner Portal</h3>
</div>,
document.getElementById('inner-portal')
)}
</div>,
document.getElementById('outer-portal')
);
}
3.3 下拉菜单 #
jsx
function Dropdown({ trigger, children }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const updatePosition = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX
});
}
};
useEffect(() => {
if (isOpen) {
updatePosition();
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
}
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [isOpen]);
return (
<>
<div
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
>
{trigger}
</div>
{isOpen && createPortal(
<div
class="dropdown-menu"
style={{
position: 'absolute',
top: position.top,
left: position.left
}}
>
{children}
</div>,
document.body
)}
</>
);
}
// 使用
function App() {
return (
<div style={{ overflow: 'hidden', height: '100px' }}>
<Dropdown trigger={<button>Open Menu</button>}>
<ul>
<li>Option 1</li>
<li>Option 2</li>
<li>Option 3</li>
</ul>
</Dropdown>
</div>
);
}
四、事件冒泡 #
4.1 事件传播 #
Portal 内的事件会冒泡到父组件:
jsx
function App() {
const handleClick = () => {
console.log('Clicked!');
};
return (
<div onClick={handleClick}>
<p>Click inside or outside portal</p>
{createPortal(
<button>Portal Button</button>,
document.body
)}
</div>
);
}
// 点击 Portal Button 也会触发 handleClick
4.2 阻止冒泡 #
jsx
function Modal({ onClose, children }) {
return createPortal(
<div class="modal-overlay" onClick={onClose}>
<div
class="modal-content"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body
);
}
五、样式处理 #
5.1 CSS 样式 #
css
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow: auto;
}
.modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
}
/* 通知样式 */
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1001;
}
.notification {
padding: 12px 20px;
margin-bottom: 10px;
border-radius: 4px;
color: white;
animation: slideIn 0.3s ease;
}
.notification-success {
background-color: #28a745;
}
.notification-error {
background-color: #dc3545;
}
.notification-warning {
background-color: #ffc107;
color: #333;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
5.2 CSS-in-JS #
jsx
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return createPortal(
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}
onClick={onClose}
>
<div
style={{
background: 'white',
padding: '20px',
borderRadius: '8px',
maxWidth: '500px',
width: '90%'
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body
);
}
六、最佳实践 #
6.1 确保 DOM 节点存在 #
jsx
function Portal({ children, selector }) {
const [container, setContainer] = useState(null);
useEffect(() => {
const element = document.querySelector(selector);
setContainer(element);
}, [selector]);
if (!container) return null;
return createPortal(children, container);
}
6.2 清理副作用 #
jsx
function Modal({ isOpen, onClose, children }) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div class="modal-overlay" onClick={onClose}>
<div class="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
);
}
6.3 无障碍访问 #
jsx
function Modal({ isOpen, onClose, children, title }) {
const modalRef = useRef(null);
useEffect(() => {
if (isOpen && modalRef.current) {
// 焦点陷阱
modalRef.current.focus();
}
}, [isOpen]);
useEffect(() => {
const handleTab = (e) => {
if (e.key !== 'Tab' || !modalRef.current) return;
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
};
if (isOpen) {
document.addEventListener('keydown', handleTab);
}
return () => {
document.removeEventListener('keydown', handleTab);
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div
class="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
ref={modalRef}
class="modal-content"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
<h2 id="modal-title">{title}</h2>
{children}
</div>
</div>,
document.body
);
}
七、总结 #
| 要点 | 说明 |
|---|---|
| createPortal | 创建 Portal |
| 容器 | 指定渲染目标 |
| 事件 | 事件正常冒泡 |
| 样式 | 需要处理 z-index |
核心原则:
- 确保 DOM 容器存在
- 处理事件冒泡
- 清理副作用
- 注意无障碍访问
最后更新:2026-03-28