ReactDOM和弹窗

发布于

之前为了显示对话框,我一直习惯用 tdesign 等 UI 库的现成方法。然而,当设计师给出的方案没有遵循 UI 库规范,或者进行了高度定制化(比如毛玻璃特效、奇特的圆角、复杂的层级)时,开发过程就变得非常痛苦。

我往往需要花费大量时间去覆盖 UI 库的默认样式,甚至要用到 !important 这种“核武器”。有时还会因为某个父级元素的 overflow: hidden 导致弹窗显示不全,一些没有顾及到的样式没有还原(如犄角旮旯看不出来的宽度差距等)。

后来我观察了一位同事的代码,发现他很少依赖司内 UI 库的弹窗资源,而是直接使用 Tailwind CSS 配合 div 还原设计稿,并利用 React 的底层能力来处理挂载。

为了掌握这个技巧,我补习了 ReactDOM 的相关知识,特别是 createPortal API,它为实现 Modal、Popup 等悬浮层提供了最底层的解决方案。

核心代码实现

在复杂的业务代码中,抽丝剥茧后,实现一个“脱离当前文档流”的弹窗其实非常简单。以下是我项目中的简化示例:

import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
// 自定义弹窗组件,也就是copy设计师的ui
import { TestReportModal } from './test-report-modal'; 

function CreateAIContent() {
  // 假设这是从全局 Store 或 Hook 中获取的控制变量
  const { testReportOpen } = useRequirementState();

  useEffect(() => {
    // 这是一个小技巧:打开弹窗时锁住 body 滚动,防止背景跟随滚动
    if (testReportOpen) {
      document.body.style.overflow = 'hidden';
    } else {
      document.body.style.overflow = '';
    }
    return () => { document.body.style.overflow = ''; };
  }, [testReportOpen]);

  return (
    <div className="main-container">
      {/* 页面头部、侧边栏等常规内容 */}
      <Header />
      <div className="content-area">
         {/* ... 复杂的业务组件 ... */}
      </div>

      {/* 关键点:使用 Portal 将弹窗挂载到 body 上 */}
      {testReportOpen &&
        ReactDOM.createPortal(<TestReportModal />, document.body)}
    </div>
  );
}

export default CreateAIContent;

原理解析

1. 为什么要用 ReactDOM.createPortal?

你可能会问,直接把 <TestReportModal /> 写在组件里不就行了吗?

使用 createPortal 的核心目的是**“逻辑上保留在 React 组件树中,视觉上渲染到 DOM 树的任意位置”**。

  • 解决样式干扰:如果你的父组件设置了 overflow: hidden 或 z-index,直接嵌套的弹窗很容易被截断或被遮挡。通过 createPortal 渲染到 document.body,弹窗就脱离了父组件的 CSS 堆叠上下文(Stacking Context),可以自由地覆盖在页面最顶层。
  • 事件冒泡保持不变:尽管 DOM 节点被移到了 body 下,但在 React 的事件体系中,它依然是 CreateAIContent 的子组件。这意味着你在 Modal 里触发的点击事件,依然可以冒泡到父组件中,这对于事件管理非常方便。

2. testReportOpen && 的作用

这是一种**短路求值(Short-circuit evaluation)**的写法,用于条件渲染:

  • 当 testReportOpen 为 true 时,JavaScript 会继续执行 && 后面的代码,React 就会执行 createPortal,将弹窗渲染出来。
  • 当 testReportOpen 为 false 时,表达式直接返回 false(React 渲染时会忽略布尔值),此时弹窗组件会被完全销毁,从 DOM 中移除。

这比使用 CSS 的 display: none 性能更好,因为关闭时它完全不占用 DOM 节点资源。


进阶技巧:手写全局 Toast 和 Message

除了模态框(Modal),我们在开发中常需要 Message.success('操作成功') 这种指令式调用的组件,而不是在 JSX 里写 <Message visible={true} />。

利用 ReactDOM,我们完全可以自己封装一套轻量的全局提示库。

实现一个简单的指令式 Toast

这是一个简单的思路,不依赖任何 UI 库:

import React from 'react';
import ReactDOM from 'react-dom/client'; // React 18+ 写法
import './toast.css'; // 定义简单的淡入淡出动画和样式

// 定义 Toast 组件
const ToastComponent = ({ content, onClose }: { content: string, onClose: () => void }) => {
  // 这里可以使用 useEffect 自动关闭
  React.useEffect(() => {
    const timer = setTimeout(onClose, 3000);
    return () => clearTimeout(timer);
  }, [onClose]);

  return (
    <div className="fixed top-10 left-1/2 -translate-x-1/2 bg-black/80 text-white px-4 py-2 rounded shadow-lg z-[9999] animate-fade-in">
      {content}
    </div>
  );
};

// 封装指令式方法
export const Toast = {
  show: (content: string) => {
    // 1. 创建一个容器 div
    const div = document.createElement('div');
    document.body.appendChild(div);

    // 2. 创建 React Root (React 18)
    const root = ReactDOM.createRoot(div);

    // 3. 定义销毁函数:卸载组件并移除 DOM
    const destroy = () => {
      root.unmount();
      if (document.body.contains(div)) {
        document.body.removeChild(div);
      }
    };

    // 4. 渲染组件
    root.render(<ToastComponent content={content} onClose={destroy} />);
  }
};

// 使用方式:
// import { Toast } from './utils/Toast';
// Toast.show('保存成功!');

通过这种方式,我们摆脱了 UI 库样式的束缚。

评论区 (0)