Featured image of post React 门户:模态和叠加的高级模式Featured image of post React 门户:模态和叠加的高级模式

React 门户:模态和叠加的高级模式

探索模态、工具提示和覆盖的高级 React 门户模式,包括 createPortal API、事件冒泡、嵌套门户和可访问性。

React 门户解决了一个基本问题:DOM 层次结构约束。在深度嵌套组件内呈现的模态框继承其父级的 z-index 堆叠上下文,并且可以通过 overflow: hidden 进行剪辑。门户允许您将子级渲染到不同的 DOM 节点,同时保留 React 树,因此上下文、事件处理和组件生命周期都按预期工作。

关键的见解是将 DOM 位置与 React 树位置分离。门户的子级在 DOM 中的其他位置进行物理渲染,但事件通过 React 组件层次结构而不是 DOM 层次结构冒泡。这种心理模型对于理解所有门户模式至关重要。

创建门户 API

API 表面很小:ReactDOM.createPortal(children, domNode, key?)domNode 必须在渲染调用之前存在,因此您通常在 HTML 模板中创建门户容器或急切地注入它们。

import { createPortal } from "react-dom";

function Modal({ children, isOpen }) {
  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay">
      <div className="modal-content" role="dialog" aria-modal="true">
        {children}
      </div>
    </div>,
    document.body
  );
}

可选的第三个 key 参数控制门户协调。更改密钥以强制重新安装门户子树 - 当您需要重置内部状态时很有用。

function Portal({ children, selector, key }) {
  const [container, setContainer] = useState(null);

  useEffect(() => {
    const el = document.querySelector(selector) || document.body;
    setContainer(el);
  }, [selector]);

  if (!container) return null;
  return createPortal(children, container, key);
}

门户目标管理

硬编码 document.body 适用于简单的情况,但在复杂的布局中会崩溃。更好的方法是通过 React 上下文管理的命名门户目标。

const PortalContext = createContext(null);

function PortalHost({ name, children }) {
  const [container, setContainer] = useState(null);

  useEffect(() => {
    const el = document.createElement("div");
    el.id = `portal-${name}`;
    document.body.appendChild(el);
    setContainer(el);
    return () => document.body.removeChild(el);
  }, [name]);

  if (!container) return null;
  return createPortal(children, container);
}

function usePortal(name) {
  return useContext(PortalContext);
}

这可以实现多区域渲染:模态转到 #modal-root,工具提示转到 #tooltip-root,通知转到 #notification-root。每个区域都可以有自己的样式和布局,并且组件以声明方式选择其目标。 SSR 的处理很自然,因为 useEffect 防护确保门户仅在客户端上创建。


嵌套门户和事件传播

当门户呈现的组件本身呈现门户时,就会出现嵌套门户 - 例如,模式内的下拉菜单。事件传播遵循 React 树,而不是 DOM 树,因此单击下拉选项会在模式组件层次结构中冒泡,然后到达应用程序根目录。这通常是正确的行为,但如果您期望 DOM 级别的事件流,可能会感到惊讶。

function ModalWithDropdown() {
  return createPortal(
    <Modal>
      <Dropdown>  {/* This dropdown may also use a portal */}
        <DropdownItem onClick={handleAction} />
      </Dropdown>
    </Modal>,
    modalRoot
  );
}

为了管理嵌套门户中的 z-index,PortalStack 组件会跟踪当前深度并应用适当的分层。子门户接收父级的 z-index 作为上下文并递增它,以防止覆盖冲突。


上下文和状态管理

由于门户保留其 React 树位置,因此 Context API 可以自然地工作。通过门户呈现的子级可以访问门户调用站点上方的提供者。但是,在某些边缘情况下,门户树需要连接到不同的提供者子树。

function ConnectedPortal({ children, contextOverrides }) {
  return (
    <ThemeProvider value={contextOverrides.theme}>
      {createPortal(children, document.body)}
    </ThemeProvider>
  );
}

对于跨门户通信,通过上下文传递回调或使用共享的 Zustand 存储。该商店可从任何门户访问,无需特殊布线,这使其成为多门户协调的最简单解决方案。


动画整合

退出动画对于门户来说很棘手,因为 createPortal 渲染到 DOM 节点中,当父节点卸载时,该节点会立即被删除。解决方案是使用成帧器运动中的 AnimatePresence 控制卸载时序。

import { AnimatePresence, motion } from "framer-motion";

function AnimatedModal({ isOpen, onClose, children }) {
  return createPortal(
    <AnimatePresence>
      {isOpen && (
        <motion.div
          className="overlay"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          <motion.div
            className="modal"
            initial={{ scale: 0.9 }}
            animate={{ scale: 1 }}
            exit={{ scale: 0.9 }}
          >
            {children}
            <button onClick={onClose}>Close</button>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>,
    document.body
  );
}

AnimatePresence 组件在播放退出动画时将 DOM 节点固定到位,然后将其删除。如果没有这个包装器,门户节点将立即被删除,并且不会运行退出动画。


可访问性模式

门户网站需要仔细的可访问性处理。关键要求是焦点捕获、焦点返回和适当的 ARIA 属性。

function useA11yPortal(triggerRef, isOpen) {
  const previousFocus = useRef(null);

  useEffect(() => {
    if (isOpen) {
      previousFocus.current = document.activeElement;
    } else if (previousFocus.current) {
      previousFocus.current.focus();
    }
  }, [isOpen]);

  // Focus trap: keep focus inside the portal while open
  useEffect(() => {
    if (!isOpen) return;

    function handleKeyDown(e) {
      if (e.key === "Escape") onClose?.();
      // Tab cycling logic here
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen]);
}
属性目的
占位符_0将元素标识为对话框
占位符_0表明外部内容是惰性的
占位符_0应用于背景内容
占位符_0引用对话框标题

当模式打开时,在门户容器的所有同级元素上设置 aria-hidden="true" 。某些屏幕阅读器仍然可以通过其他方式导航到背景内容,因此将 aria-hiddeninert 属性相结合可提供最可靠的行为。


性能考虑因素

当门户容器远离 DOM 树中的主应用程序根时,门户内容可能会导致布局抖动。始终将门户子组件包装在 useMemo 中,以避免在父组件更新但门户内容未更改时不必要的重新渲染。

function App() {
  const memoizedModal = useMemo(
    () => (
      <Modal onClose={handleClose}>
        <ExpensiveContent />
      </Modal>
    ),
    [handleClose]
  );

  return (
    <>
      <MainContent />
      {showModal && memoizedModal}
    </>
  );
}

更改门户的 key 属性会强制 React 卸载并重新挂载整个门户子树。由于 DOM 断开和重新连接,这比内联渲染成本更高,因此请避免在热路径中的门户容器上使用更改键。

概括

门户不仅仅适用于模态。相同的模式适用于工具提示、下拉菜单、通知以及任何需要突破父级 CSS 或 DOM 约束的 UI。通过组合命名门户目标、嵌套门户管理、动画集成和可访问性挂钩,您可以构建可重用的门户抽象层,该抽象层可以处理最复杂的覆盖需求,同时保持高性能和可访问性。