手把手教你封装一个工业级 React 移动端 Popup 组件
你还在为找不到好的弹窗组件而苦恼吗?既然别人靠不住,那就自己来!
在移动端 H5 开发中,Popup(弹出层)是使用频率最高的组件之一。无论是底部弹出的选择器(Picker)、中间弹出的确认框(Dialog),还是侧边弹出的抽屉(Drawer),本质上都是 Popup 的变体。
今天,我们将从零开始,封装一个支持多方向弹出、流畅动画、且完美解决滚动穿透问题的通用 Popup 组件。
一、 需求分析
一个优秀的 Popup 组件应该具备哪些能力?
- 多方向支持:Top, Bottom, Left, Right, Center 五种位置全覆盖。
- 动画流畅:进场/离场动画必须丝滑,支持过渡效果。
- 防滚动穿透:这是移动端的大坑,必须完美解决。
- 接口简洁:Props 简单易用,支持受控模式。
二、 核心架构设计
我们将组件分为两部分:
- Overlay (遮罩层):负责背景变暗,以及 Flex 布局对齐(决定弹窗位置)。
- Content (内容层):承载具体内容,负责位移动画(Transform)。
1. HTML 结构
// 伪代码结构
<div className="overlay">
<div className="content">
{children}
</div>
</div>
2. SCSS 布局魔法
如何通过 CSS 实现 5 种方向的布局?
Flexbox 是最佳答案。
我们利用 Overlay 的 justify-content 和 align-items 属性,配合 Content 的初始 transform 位移,即可轻松搞定。
Popup/index.module.scss 精华解析:
.overlay {
position: fixed; inset: 0; // 铺满屏幕
display: flex; // 启用 Flex 布局
// ⬇️ 底部弹出:主轴向下对齐
&.bottom { flex-direction: column; justify-content: flex-end; }
// ⬆️ 顶部弹出:主轴向上对齐
&.top { flex-direction: column; justify-content: flex-start; }
// ⬅️ 左侧滑出:主轴向左对齐
&.left { flex-direction: row; justify-content: flex-start; }
// ➡️ 右侧滑出:主轴向右对齐
&.right { flex-direction: row; justify-content: flex-end; }
// ⏺ 居中弹出:双向居中
&.center { align-items: center; justify-content: center; }
}配合 `transform` 实现进场动画:
.content {
transition: transform 0.3s ease-in-out;
// 底部弹出初始状态:向下偏移 100%
&.bottom { transform: translateY(100%); }
// 激活状态:归位
&.active { transform: translateY(0); }
}
这种设计模式极其优雅,完全避免了复杂的 JS 计算,把布局压力交给了 CSS 引擎。
三、 React 逻辑实现
1. 动画生命周期管理
React 组件的卸载是瞬间的,但离场动画需要时间。为了实现“关闭时先播动画,再卸载 DOM”,我们需要引入两个状态:
renderVisible: 控制 DOM 是否存在(if (!renderVisible) return null)。animationVisible: 控制 CSS 类名(.active/.show)。
const [renderVisible, setRenderVisible] = useState(visible);
const [animationVisible, setAnimationVisible] = useState(false);
useEffect(() => {
if (visible) {
// 1. 先挂载 DOM
setRenderVisible(true);
// 2. 下一帧触发动画(利用 setTimeout 让浏览器有时间重排)
setTimeout(() => setAnimationVisible(true), 10);
} else {
// 1. 先移除动画类
setAnimationVisible(false);
// 2. 等待动画结束(300ms)后再卸载 DOM
setTimeout(() => setRenderVisible(false), 300);
}
}, [visible]);
2. 终极难题:滚动穿透
在 iOS Safari 上,单纯的 overflow: hidden 往往无法阻止背景页面的滚动。我们需要祭出(Body 固定定位法)。
当弹窗打开时,我们将 Body 设为 fixed,并记录当前的滚动位置 scrollTop,防止页面跳动。
// 核心防穿透逻辑
useEffect(() => {
if (visible) {
const scrollTop = window.scrollY;
// 🔒 锁死 Body
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollTop}px`;
document.body.style.width = '100%';
document.body.dataset.scrollY = scrollTop.toString();
} else {
// 🔓 解锁并恢复位置
const scrollTop = parseInt(document.body.dataset.scrollY || '0');
document.body.style.position = '';
window.scrollTo(0, scrollTop);
}
}, [visible]);
四、 完整组件接口设计
最终,我们的组件支持极其灵活的配置:
interface Props {
visible: boolean;
onClose: () => void;
position?: 'bottom' | 'top' | 'left' | 'right' | 'center';
height?: string; // 控制高度(用于 bottom/top)
width?: string; // 控制宽度(用于 left/right)
}
五、完整代码结构+样式
/**
* 弹窗组件
* @param {boolean} visible 是否显示
* @param {() => void} onClose 关闭回调
* @param {string} title 标题
* @param {React.ReactNode} children 子组件
* @param {string} height 高度,默认 60vh
* @param {string} width 宽度,默认 70vw
* @param {string} position 位置,默认 bottom
* @returns {React.ReactNode} 弹窗组件
*/
import React, { useEffect, useState } from 'react';
import styles from './index.module.scss';
interface Props {
visible: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
height?: string; // 对于 bottom/top 模式,控制高度
width?: string; // 对于 left/right 模式,控制宽度
position?: 'bottom' | 'center' | 'top' | 'left' | 'right';
}
const Popup: React.FC<Props> = ({
visible,
onClose,
title,
children,
height = '60vh',
width = '70vw',
position = 'bottom'
}) => {
const [renderVisible, setRenderVisible] = useState(visible);
const [animationVisible, setAnimationVisible] = useState(false);
useEffect(() => { // 没有使用css动画是为了解决移动端的滚动穿透问题
if (visible) {
setRenderVisible(true);
// 这里的 setTimeout 是为了确保 DOM 已经渲染,下一帧再添加动画类,从而触发 transition
const timer = setTimeout(() => setAnimationVisible(true), 10);
// 记录当前滚动位置并锁定 Body
const scrollTop = window.scrollY || document.documentElement.scrollTop;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollTop}px`;
document.body.style.width = '100%';
document.body.dataset.scrollY = scrollTop.toString();
return () => clearTimeout(timer);
} else {
setAnimationVisible(false);
// 等待动画结束(这里对应 CSS 的 transition: 0.3s),再卸载 DOM
const timer = setTimeout(() => {
setRenderVisible(false);
// 恢复 Body 样式和滚动位置
const scrollTop = parseInt(document.body.dataset.scrollY || '0', 10);
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, scrollTop);
}, 300);
return () => clearTimeout(timer);
}
}, [visible]);
// 组件卸载时的清理
useEffect(() => {
return () => {
// 这里只做兜底清理,防止组件直接被卸载导致 body 锁死
// 正常流程由上面的 effect 处理
if (document.body.style.position === 'fixed') {
const scrollTop = parseInt(document.body.dataset.scrollY || '0', 10);
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, scrollTop);
}
};
}, []);
if (!renderVisible) return null;
// 根据位置计算样式
const getContentStyle = () => {
switch (position) {
case 'bottom':
case 'top':
return { height };
case 'left':
case 'right':
return { width };
default:
return {}; // center 模式由 css 控制
}
};
return (
<div
className={`${styles.overlay} ${animationVisible ? styles.show : ''} ${styles[position]}`}
onClick={onClose}
onTouchMove={(e) => {
if (e.target === e.currentTarget) {
e.preventDefault();
}
}}
>
<div
className={`${styles.content} ${styles[position]} ${animationVisible ? styles.active : ''}`}
style={getContentStyle()}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={styles.close} onClick={onClose}>×</div>
</div>
<div className={styles.body}>
{children}
</div>
</div>
</div>
);
};
export default Popup;
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
display: flex;
// bottom: 底部对齐
&.bottom {
flex-direction: column;
justify-content: flex-end;
}
// top: 顶部对齐
&.top {
flex-direction: column;
justify-content: flex-start;
}
// left: 左侧对齐
&.left {
flex-direction: row;
justify-content: flex-start;
}
// right: 右侧对齐
&.right {
flex-direction: row;
justify-content: flex-end;
}
// center: 居中对齐
&.center {
align-items: center;
justify-content: center;
}
&.show {
opacity: 1;
pointer-events: auto;
}
}
.content {
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
// Bottom: 从下往上
&.bottom {
width: 100%;
border-radius: 12px 12px 0 0;
transform: translateY(100%);
&.active {
transform: translateY(0);
}
}
// Top: 从上往下
&.top {
width: 100%;
border-radius: 0 0 12px 12px;
transform: translateY(-100%);
&.active {
transform: translateY(0);
}
}
// Left: 从左往右
&.left {
height: 100%;
width: 70%; // 侧边栏一般占 70-80%
max-width: 320px;
transform: translateX(-100%);
&.active {
transform: translateX(0);
}
}
// Right: 从右往左
&.right {
height: 100%;
width: 70%;
max-width: 320px;
transform: translateX(100%);
&.active {
transform: translateX(0);
}
}
// Center: 缩放淡入
&.center {
width: 80%;
max-width: 320px;
border-radius: 12px;
transform: scale(0.9);
opacity: 0;
&.active {
transform: scale(1);
opacity: 1;
}
}
}
.header {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #f0f0f0;
position: relative;
flex-shrink: 0;
.title {
font-size: 17px;
font-weight: 600;
color: #333;
}
.close {
position: absolute;
right: 0;
top: 0;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #999;
cursor: pointer;
}
}
.body {
flex: 1;
overflow-y: auto;
position: relative;
// 居中模式下 body 可能不需要铺满
.center & {
max-height: 70vh;
}
}
使用示例
// 1. 底部选择器(默认)
<Popup visible={show} position="bottom" height="50vh">...</Popup>
// 2. 侧边抽屉菜单
<Popup visible={show} position="left" width="70%">...</Popup>
// 3. 中间确认框
<Popup visible={show} position="center">...</Popup>
六、 总结
通过 Flexbox 布局 + React 双状态管理 + Body 锁定技术,我们实现了一个高性能、无依赖、且体验完美的移动端 Popup 组件。这套方案不仅代码精简,而且具有极强的扩展性,是构建移动端组件库的基石。