之前为了显示对话框,我一直习惯用 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)