React Portal 与 Dialog
为什么 Dialog 需要 Portal
Dialog(模态框)在视觉上应该「跳出」当前的文档流,覆盖整个页面。但在 DOM 结构上,Dialog 通常是页面的子组件,受父组件样式影响:
function App() {
return (
<div style={{ overflow: 'hidden' }}> {/* 父组件限制了 overflow */}
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && (
<div className="modal-overlay">
<div className="modal-content">
Modal Content
</div>
</div>
)}
</div>
);
}
这个模态框虽然视觉上覆盖了页面,但在 DOM 中仍然是 <div style={{ overflow: 'hidden' }}> 的子元素。这意味着:
- 可能被父组件的
overflow: hidden裁剪 - 可能受父组件 z-index 影响
- 滚动事件可能被子元素捕获
Portal 解决了这个问题。
createPortal 的基础用法
import { createPortal } from 'react-dom';
function Modal({ children, isOpen }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay">
<div className="modal-content">
{children}
</div>
</div>,
document.body // 渲染到 body
);
}
Portal 把组件渲染到 DOM 树的任何位置(通常是 document.body),而不影响组件树结构。
Focus Trapping(焦点捕获)
模态框的一个重要无障碍(a11y)要求是:当模态框打开时,焦点应该被困在模态框内部,直到模态框关闭。
为什么需要 Focus Trapping
- 键盘用户:他们使用 Tab 键导航,应该只能在模态框内导航
- 屏幕阅读器用户:焦点应该在模态框内朗读
- 防止焦点丢失:如果焦点跑到模态框后面,用户可能无法找到
实现 Focus Trapping
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
const modal = modalRef.current;
if (!modal) return;
// 获取模态框内的可聚焦元素
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// 保存之前焦点的位置
const previouslyFocused = document.activeElement;
// 聚焦到第一个元素
firstElement?.focus();
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
return;
}
// Shift + Tab:移动到最后一个元素
if (e.key === 'Tab' && e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
return;
}
// Tab:移动到第一个元素
if (e.key === 'Tab') {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
modal.addEventListener('keydown', handleKeyDown);
return () => {
modal.removeEventListener('keydown', handleKeyDown);
// 恢复之前的焦点
previouslyFocused?.focus();
};
}, [isOpen, onClose]);
return createPortal(
<div
ref={modalRef}
role="dialog"
aria-modal="true"
className="modal-overlay"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="modal-content">
{children}
</div>
</div>,
document.body
);
}
滚动锁定(Scroll Lock)
当模态框打开时,应该禁止背景页面滚动。
基础实现
function useScrollLock(locked) {
useEffect(() => {
if (!locked) return;
const originalOverflow = document.body.style.overflow;
const originalPaddingRight = document.body.style.paddingRight;
// 计算滚动条的宽度,防止布局偏移
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = originalPaddingRight;
};
}, [locked]);
}
使用
function Modal({ isOpen, onClose, children }) {
useScrollLock(isOpen);
return createPortal(
isOpen && (
<div className="modal-overlay" onClick={onClose}>
<div onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
),
document.body
);
}
无障碍(Accessibility)属性
ARIA 属性
function Modal({ isOpen, onClose, title, children }) {
return createPortal(
isOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
className="modal-overlay"
>
<h2 id="modal-title" className="sr-only">{title}</h2>
<div id="modal-description">
{children}
</div>
<button onClick={onClose} aria-label="Close modal">
×
</button>
</div>
),
document.body
);
}
返回对话框(Alertdialog)
如果模态框的目的是警告用户(而不是一般性交互),使用 role="alertdialog":
function ConfirmDialog({ isOpen, onConfirm, onCancel, message }) {
return createPortal(
isOpen && (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-message"
>
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-message">{message}</p>
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>
),
document.body
);
}
完整的 Modal Hook 实现
function useModal({ isOpen, onClose }) {
const previousActiveElement = useRef(null);
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement;
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
previousActiveElement.current?.focus();
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
return { previousActiveElement };
}
常见的 Modal 实现模式
1. Render Props 模式
function Modal({ isOpen, renderContent }) {
return createPortal(
isOpen && (
<div className="modal-overlay">
{renderContent()}
</div>
),
document.body
);
}
// 使用
<Modal isOpen={showModal} renderContent={() => (
<div className="modal">
<h2>Hello</h2>
<button onClick={() => setShowModal(false)}>Close</button>
</div>
)} />
2. Compound Components 模式
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
);
}
Modal.Title = function ModalTitle({ children }) {
return <h2>{children}</h2>;
};
Modal.Body = function ModalBody({ children }) {
return <div>{children}</div>;
};
Modal.Footer = function ModalFooter({ children }) {
return <div className="modal-footer">{children}</div>;
};
// 使用
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
<Modal.Title>Confirmation</Modal.Title>
<Modal.Body>Are you sure?</Modal.Body>
<Modal.Footer>
<button onClick={() => setShowModal(false)}>Cancel</button>
<button onClick={handleConfirm}>Confirm</button>
</Modal.Footer>
</Modal>
3. Context + Hook 模式
const ModalContext = createContext(null);
function ModalProvider({ children }) {
const [modals, setModals] = useState({});
const openModal = (id, content) => {
setModals(prev => ({ ...prev, [id]: { isOpen: true, content } }));
};
const closeModal = (id) => {
setModals(prev => ({
...prev,
[id]: { ...prev[id], isOpen: false }
}));
};
return (
<ModalContext.Provider value={{ openModal, closeModal }}>
{children}
{Object.entries(modals).map(([id, { isOpen, content }]) => (
<Modal key={id} isOpen={isOpen} onClose={() => closeModal(id)}>
{content}
</Modal>
))}
</ModalContext.Provider>
);
}
export const useModal = () => useContext(ModalContext);
第三方库
在生产环境中,通常使用成熟的库来确保无障碍支持:
- Radix UI Dialog:无障碍优先的 Dialog 组件
- Reach UI Dialog:另一个无障碍优先的实现
- Headless UI:Tailwind CSS 团队的 headless 组件库
面试中的表达
面试中聊到 Portal 和 Dialog,通常是在考察你对 React DOM 结构和无障碍访问的理解:
Portal 的核心用途是把 React 子树渲染到 DOM 树的其他位置,这对模态框、tooltip 这类需要「跳出」当前层级的组件特别有用。createPortal 接受两个参数:要渲染的内容和目标 DOM 节点。
实现模态框的关键技术点包括:焦点捕获(focus trapping)——用 Tab 键时焦点不能跑出模态框;滚动锁定——模态框打开时背景不能滚动;无障碍属性——role="dialog"、aria-modal="true" 等。
关于无障碍,很多开发者会忽略这一点。但对于键盘用户和屏幕阅读器用户来说,正确实现 focus trapping 和 ARIA 属性是基本要求。
延展阅读
- React Docs: Portals — 官方 Portal 文档
- MDN: ARIA dialog role — Dialog 无障碍指南
- WAI-ARIA Authoring Practices: Dialog — W3C 无障碍规范
- Radix UI Dialog — 无障碍优先的 Dialog 实现