React Portals and Fragments

深入理解 React Fragment 的用途和与空标签的区别,以及 React Portal 的使用场景、createPortal API、事件冒泡处理和 CSS 层叠上下文外的表现。

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.getElementByIddocument.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 的作用范围。


延展阅读