React Portals and Fragments
Fragment:避免不必要的 DOM 节点
问题的起点
在 React 中,组件必须返回单个根元素。这意味着当你想要返回多个兄弟元素时,通常需要包一层父元素:
// 需要一个包裹元素
function Article() {
return (
<article>
<h1>Title</h1>
<p>Paragraph 1</p>
<p>Paragraph 2</p>
</article>
);
}
这通常没问题。但考虑一个返回多个列表项的场景:
function Glossary({ terms }) {
return (
<dl>
{terms.map(term => (
// 需要一个包裹元素,但这个 <div> 在语义上不必要
<div key={term.id}>
<dt>{term.name}</dt>
<dd>{term.description}</dd>
</div>
))}
</dl>
);
}
这里的 <div> 只是为了满足 JSX 的单根元素要求,在语义上不属于 glossary 的一部分。它会导致更深的 DOM 层级,在 CSS 布局中可能产生意外效果(比如 flex 或 grid 布局)。
Fragment 的解决方案
Fragment 是 React 提供的组件,它不会在 DOM 中产生实际节点:
import { Fragment } from 'react';
function Glossary({ terms }) {
return (
<dl>
{terms.map(term => (
<Fragment key={term.id}>
<dt>{term.name}</dt>
<dd>{term.description}</dd>
</Fragment>
))}
</dl>
);
}
渲染后的 DOM 结构:
<dl>
<!-- 没有额外的 div 包裹 -->
<dt>term.name</dt>
<dd>term.description</dd>
<dt>another term</dt>
<dd>another description</dd>
</dl>
Fragment vs 空标签 <>
在 JSX 中,<> 是 <Fragment> 的语法糖。两者几乎完全等价:
// 两者等价
<>
<ComponentA />
<ComponentB />
</>
<Fragment>
<ComponentA />
<ComponentB />
</Fragment>
唯一区别:<Fragment> 支持 key 属性
// 可以使用 key(用于列表渲染)
{items.map(item => (
<Fragment key={item.id}>
<dt>{item.name}</dt>
<dd>{item.description}</dd>
</Fragment>
))}
// <> 不支持 key
{items.map(item => (
// 错误!<> 不能加 key
<>
<dt key={item.id}>{item.name}</dt> {/* key 不能加在这里 */}
<dd>{item.description}</dd>
</>
))}
这个限制的原因是:JSX 语法糖 <> 无法传递 attributes(包括 key),只有 <Fragment> 组件可以。
Fragment 的实际应用场景
1. 语义化的列表渲染
function Table() {
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{data.map(row => (
<Fragment key={row.id}>
<td>{row.name}</td>
<td>{row.description}</td>
</Fragment>
))}
</tbody>
</table>
);
}
2. 返回多个根组件的渲染逻辑
function SplitPane({ left, right }) {
return (
<>
<div className="left-pane">{left}</div>
<div className="right-pane">{right}</div>
</>
);
}
3. 条件渲染多个元素
function Notification({ message, isError }) {
return (
<>
{isError && <span className="error-icon">!</span>}
<span className="message">{message}</span>
{isError && <button onClick={dismiss}>Dismiss</button>}
</>
);
}
Portal:渲染到 DOM 树的其他位置
问题的起点:模态框的困境
假设你有一个模态框组件:
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
);
}
这个组件本身没问题。但如果模态框的父组件有特殊的 CSS 上下文呢?
function App() {
return (
<div style={{ overflow: 'hidden' }}> {/* 父组件限制了 overflow */}
<OtherContent />
<Modal isOpen={showModal} onClose={closeModal}>
<Form />
</Modal>
</div>
);
}
模态框虽然视觉上覆盖了其他内容,但实际上仍然在 overflow: hidden 的父元素内部。这意味着:
- 模态框可能被裁剪
- 滚动事件可能被父元素捕获
- z-index 层叠可能不符合预期
createPortal 的解决方案
createPortal 让你把 React 子树渲染到任何 DOM 节点中,而不是当前的 DOM 树位置:
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
// 把模态框渲染到 document.body
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body // 目标 DOM 节点
);
}
现在模态框在 DOM 树中是 document.body 的直接子节点,不受任何父组件 CSS 上下文的影响。
Portal 的使用场景
1. 模态框(Modal)
这是 Portal 最典型的使用场景。模态框需要:
- 覆盖其他所有内容
- 不受父组件 overflow、z-index 等 CSS 属性影响
- 独立于页面滚动
2. 工具提示(Tooltip)和弹出框(Popover)
function Tooltip({ children, text, anchorEl }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
if (anchorEl) {
const rect = anchorEl.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
left: rect.left
});
}
}, [anchorEl]);
return createPortal(
<div className="tooltip" style={{ position: 'fixed', ...position }}>
{text}
</div>,
document.body
);
}
3. 加载指示器(Loading Spinner)
全局加载指示器通常用 Portal 渲染到 body,确保它不受任何父组件样式影响:
function GlobalSpinner() {
return createPortal(
<div className="global-spinner">
<Spinner />
</div>,
document.body
);
}
4. 自定义钩子层(如 react-hot-toast)
很多 toast 通知库内部使用 Portal 把通知渲染到 body。
createPortal 的 API
createPortal(children, domNode)
children: 任何可渲染的 React 子树(JSX、Fragment、字符串、数字等)domNode: 目标 DOM 节点(必须是真实 DOM 节点,通过document.getElementById或document.body获取)
返回值是一个 React 节点,与普通 React 子组件的渲染方式完全兼容。
Portal 的事件冒泡
重要特性:事件仍然冒泡到 React 树
虽然 Portal 把 DOM 节点渲染到了 DOM 树的其他位置,但React 事件系统仍然遵循 React 组件树,而不是 DOM 树。
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div onClick={() => setShowModal(false)}>
<h1>App Title</h1>
<button onClick={() => setShowModal(true)}>Open Modal</button>
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
{/* Portal 渲染到 document.body */}
<div onClick={e => e.stopPropagation()}>
{/* 这个 onClick 会阻止事件冒泡到 App */}
<p>Modal Content</p>
</div>
</Modal>
</div>
);
}
在模态框内部点击,e.stopPropagation() 仍然会阻止事件冒泡到 App 组件(React 树中的父组件),即使 DOM 节点实际上在 DOM 树的 document.body 位置。
这个设计是合理的——React 组件通常与逻辑上的父组件有关联,事件应该按照 React 组件树传播,而不是 DOM 树。
手动处理 Portal 事件
如果你需要让 Portal 的事件"穿透"到父组件的 DOM 节点,可以通过 onClick 手动转发:
function PortalComponent({ onClick, children }) {
return createPortal(
<div onClick={onClick}>
{children}
</div>,
document.body
);
}
// 在父组件中使用
function Parent() {
return (
<div onClick={handleClick}>
<PortalComponent onClick={e => { e.stopPropagation(); handlePortalClick(); }}>
Content
</PortalComponent>
</div>
);
}
Portal 在 CSS 层叠上下文外的表现
层叠上下文(Stacking Context)
CSS 中的层叠上下文(Stacking Context)会影响元素的 z-index 行为。当一个元素创建了新的层叠上下文,它的子元素的 z-index 只在该上下文内有意义。
Portal 渲染到 document.body(或根节点),意味着:
- Portal 默认在所有有层叠上下文的内容之上(因为
document.body是最顶层) - 不受父组件的 z-index 影响
但如果 Portal 内部创建了新的层叠上下文:
function Modal() {
return createPortal(
<div style={{ position: 'fixed', zIndex: 1000 }}>
{/* 这个 div 仍然在 document.body 的层叠上下文中 */}
{/* 如果内部有个子元素有 transform,它会创建新的层叠上下文 */}
<div style={{ transform: 'translateZ(0)' }}>
{/* 这个子元素的 z-index 只在这个新层叠上下文中有效 */}
</div>
</div>,
document.body
);
}
实践建议
1. 避免在 Portal 内部不必要的 transform
Transform 会创建新的层叠上下文,可能导致意外的 z-index 行为:
// 潜在问题:子元素的 z-index 可能不生效
<div style={{ transform: 'translateZ(0)' }}>
<ChildWithZIndex />
</div>
2. 使用 fixed 而非 absolute
Portal 的容器应该用 position: fixed 确保相对于视口定位:
createPortal(
<div style={{ position: 'fixed', inset: 0 }}>
{/* 覆盖整个视口 */}
</div>,
document.body
);
3. 考虑 Portal 内部的 overflow
如果 Portal 内部有滚动需求,确保正确设置 overflow:
createPortal(
<div style={{
position: 'fixed',
inset: 0,
overflow: 'auto'
}}>
{/* 可滚动内容 */}
</div>,
document.body
);
Portal 和 Error Boundary 的关系
Error Boundary 的作用范围
Error Boundary 只能捕获其组件树内部的错误。如果 Portal 内部的组件抛出错误,而这个 Portal 在 Error Boundary 外部,那么 Error Boundary 无法捕获。
// 这个 Error Boundary 无法捕获 ModalPortal 内部的错误
// 因为 ModalPortal 是在 ErrorBoundary 外部渲染的
<ErrorBoundary>
<App>
<NormalComponent />
</App>
</ErrorBoundary>
<ModalPortal> {/* 在 ErrorBoundary 外部 */}
<BuggyComponent /> {/* 这里的错误无法被 ErrorBoundary 捕获 */}
</ModalPortal>
解决方案:把 Portal 放在 Error Boundary 内部
<ErrorBoundary>
<App>
<NormalComponent />
<Modal isOpen={showModal}>
{/* Modal 内部的错误可以被 ErrorBoundary 捕获 */}
<BuggyComponent />
</Modal>
</App>
</ErrorBoundary>
自定义 Error Boundary Portal
有时你希望 Portal 内部的错误不影响主应用,但仍然能优雅地处理:
class SafeModal extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
logError(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <FallbackModal />;
}
return this.props.children;
}
}
// 使用
<SafeModal>
<Modal>
<RiskyContent />
</Modal>
</SafeModal>
面试中的表达
面试官问 Fragment 和 Portal,通常是在考察你对 React DOM 结构管理和组件封装边界 的理解:
Fragment 的作用是在不引入额外 DOM 节点的情况下,让组件返回多个兄弟元素。这在渲染 table、dl、semantic HTML 结构时特别有用。<>
是 Fragment 的语法糖,但不支持 key 属性,只有` 可以。Portal 的核心场景是模态框、tooltip 这类需要"脱离"当前 DOM 层级限制的组件。它通过 createPortal 把子树渲染到 document.body,但 React 事件系统仍然遵循组件树而不是 DOM 树。需要注意 Portal 的 CSS 层叠上下文问题,以及 Error Boundary 对 Portal 的作用范围。
延展阅读
- React Docs: Fragments — 官方 Fragment 文档
- React Docs: Portals — 官方 Portal 文档
- MDN: Stacking Context — 层叠上下文的详细解释
- Kent C. Dodds: Don't Use Props That Already Exist on the DOM — 关于 Portal 和 props 传递的最佳实践