ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

原生JavaScript实现div弹窗组件:原理、主题换肤与二次开发

原生JavaScript实现div弹窗组件:原理、主题换肤与二次开发

简介:这套资源是一份“漂亮的div弹窗”前端组件示例包,面向Web页面开发者和前端初学者,用于解决弹窗样式单一、交互简陋以及跨浏览器兼容性差等常见问题。压缩包共20个文件,包含11个JavaScript脚本、6个HTML演示页面和3个CSS样式表,整体约71KB,代码轻量,便于直接阅读和复用。包内覆盖了圆角与方角弹窗、半透明背景、可拖动、缩放显示、由下向上滑入、单击边界外隐藏、弹窗内容滚动等常见交互形态,适合在实际项目中按需挑选改造。资源已吸引882人浏览学习,且提供了实际可运行的demo示例,能够帮助开发者直观理解CSS3动画、事件监听、模态与非模态弹窗、定位与响应式设计等核心知识点,对希望快速掌握前端弹窗实现思路的读者比较有参考价值。

1. 漂亮的div弹窗:从“临时拼一个”到“有一套自己的弹窗组件”

如果你正在找一个能直接落地、又能改得动的 web 页面弹窗,这套基于原生 JavaScript 的 div 弹窗值得花十分钟拆一下。它不依赖任何前端框架,也不用构建工具,打开 HTML 就能跑。一个 Popup 类加一套 CSS 变量皮肤系统,把 alert、confirm、prompt 和自定义窗口四种弹窗场景全都收拢到一起。

做后台管理系统时,弹窗需求比想象中琐碎:要适配不同内容长度、要处理遮罩层叠和滚动穿透、还要在动画结束那一刻准确销毁 DOM。这套资源把这些问题收敛到 js/popup.js 一个文件里,调用方只传 type、title、message 这类参数,不用关心节点到底是怎么拼出来的。

所以它适合在两种场景下用:一是想在信息管理类页面里快速替换原生 alert 的人,二是在自研轻量组件库、想参考弹窗层叠管理方案的人。接下来我会从目录、核心生命周期、样式换肤、踩坑记录一直讲到二次改造。

2. 架构与目录:零依赖弹窗的代码是怎么组织的

2.1 目录结构与本地运行

下载资源解压后,整个项目长这样:

popup-dialog/ ├── index.html # 最简演示,双击就能跑 ├── demo.html # 完整的弹窗场景示例 ├── css/ │ └── popup.css # 主题变量、动画、遮罩样式 └── js/ └── popup.js # Popup 类与静态方法

这份资源没有 package.json,也不需要 node_modules。它把样式和逻辑都拆成独立文件,目的就是让任何页面都能在<script src="js/popup.js">和<link rel="stylesheet" href="css/popup.css">之后直接使用。跑起来的方式有两种:直接双击 index.html,或者在项目目录里起一个静态服务:

python -m http.server 8000

为什么要推荐起服务而不是直接双击?如果后续想把 popup.js 改成 ES Module 的import方式加载,直接双击打开就会遇到浏览器对模块跨域的限制。起一个本地 http 服务可以让后续扩展少踩一个坑。

2.2 核心 DOM 模型:遮罩、面板、轻提示

弹窗组件最要紧的是把“遮挡”和“内容”分层。这套组件在页面里会生成三类节点:

<div class="popup-mask"> <!-- 全屏遮罩层 --> <div class="popup-box"> <div class="popup-header">标题区域</div> <div class="popup-body">消息内容</div> <div class="popup-footer">按钮区域</div> </div> </div> <div class="popup-toast">轻提示</div>

popup-mask 负责全屏拦截点击,并给面板一个统一的背景压暗效果。popup-box 是真正的弹窗面板,主题皮肤和动画类都挂在这层上。popup-toast 则是另外一种形态:它不遮罩、不弹按钮,只在屏幕中间或顶部飘几秒然后消失,适合操作成功、失败这类轻提醒。

这三类的层级顺序是固定的:toast 永远在最上面,mask 其次。CSS 里把这三个类分别设置z-index,避免弹窗一多就互相盖住。建议数值是popup-mask为 1000,popup-toast为 1100。如果你的项目里还有导航栏或侧边栏,记得给它们小于 1000 的层级,否则弹窗打开后会被导航栏压住一半。

2.3 Popup 类结构:一个实例对应一个窗口

核心文件 popup.js 里定义了一个Popup类,以及Popup.alert、Popup.confirm、Popup.prompt这几个静态便捷方法。每一个用户可见的弹窗,都对应一个 Popup 实例。

class Popup { constructor(options) { this.options = Object.assign({}, Popup.defaults, options); this.id = 'popup_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); this.maskEl = null; this.container = document.getElementById(this.options.container) || document.body; } }

constructor 里做三件事:合并默认参数、生成唯一 id、确定挂载点。id 不止为了给 DOM 记标记,后面做队列管理、路由切换统一关闭时,都要靠这个 id 找到具体实例。

Popup.defaults存放所有默认配置,外部传入 options 只覆盖默认值,不直接修改默认对象,所以多个弹窗之间不会互相污染。这也是这套组件可以同时打开多个弹窗的基础。

架构层面还有一个隐藏的静态计数器Popup.activeCount = 0。每 open 一个实例加 1,每 close 一个实例减 1,当计数归零时才移除 body 上的滚动锁。这样既支持同时打开多层弹窗,又保证最后一个弹窗关闭后页面能恢复正常滚动。

类的静态属性在 ES6 中承担了全局共享状态的作用。以前的零依赖组件通常会挂一个全局window.PopupManager,现在用类属性更整洁。但要注意Popup.activeCount的修改必须和 open/close 成对出现,如果是在单页应用内部路由切换后忘记 close 残留实例,计数会只增不减,滚动锁永远无法解除。这个在最后一章的closeAll里会解决。

3. 核心模块拆解:一个弹窗从 open 到销毁的完整链路

3.1 默认参数与配置合并

继续看 popup.js 的默认配置:

Popup.defaults = { type: 'alert', // alert / confirm / prompt / custom title: '', // 标题栏文字 message: '', // 正文内容,可以是 HTML 字符串 theme: 'default', // default / glass / neon / dark animation: 'fade', // fade / slide / scale / flip showMask: true, // 是否显示遮罩 maskClose: false, // 点击遮罩是否关闭 showClose: true, // 是否显示右上角关闭按钮 width: 420, // 面板宽度,number 或 string buttons: [], // 自定义按钮数组,默认不渲染 container: 'body', // 挂载容器选择器 onOpen: null, // 打开后回调 onClose: null, // 关闭后回调 onConfirm: null, // 确认按钮回调 onCancel: null // 取消按钮回调 };

构造函数里用Object.assign({}, Popup.defaults, options)完成合并。注意第一个参数是一个空对象,这样默认配置永远不会被某个实例修改。

参数里面值得解释的有三个。type决定渲染逻辑:alert 只渲染一个确定按钮,confirm 渲染确定和取消两个按钮,prompt 会在正文下面插入一个 input 输入框,custom 是完全自定义内容。buttons如果传入数组,会覆盖 confirm 的默认按钮行为,适合做「知道了」「返回」「删除」这种多按钮场景。width既支持固定的 420 数字,也支持'60vw'这种字符串,内部会原样写进box.style.width。

3.2 createDialog:动态生成遮罩和面板

createDialog是这套组件最核心的一段代码:

createDialog() { const mask = document.createElement('div'); mask.className = 'popup-mask'; mask.dataset.popupId = this.id; const box = document.createElement('div'); box.className = `popup-box popup-theme-${this.options.theme} popup-anim-${this.options.animation}`; box.style.width = typeof this.options.width === 'number' ? this.options.width + 'px' : this.options.width; box.innerHTML = ` <div class="popup-header"> <span class="popup-title">${this.options.title}</span> ${this.options.showClose ? '<span class="popup-close">&times;</span>' : ''} </div> <div class="popup-body">${this.options.message}</div> <div class="popup-footer"></div> `; this.renderFooter(box.querySelector('.popup-footer')); mask.appendChild(box); this.maskEl = mask; return mask; }

这里用模板字符串拼出面板结构,再根据 type 决定 footer 里放什么按钮。renderFooter是类里拆出来的辅助方法,alert 时添加一个「确定」按钮,confirm 时添加「取消」和「确定」,prompt 时还会先往 body 里塞一个 input。按钮都绑定了>open() { this.createDialog(); document.body.classList.add('popup-lock'); Popup.activeCount += 1; this.container.appendChild(this.maskEl); requestAnimationFrame(() => this.maskEl.classList.add('popup-active')); this.bindEvents(); this.options.onOpen && this.options.onOpen(this); return this; } close() { const finish = () => { this.maskEl.remove(); Popup.activeCount -= 1; if (Popup.activeCount <= 0) { document.body.classList.remove('popup-lock'); } this.options.onClose && this.options.onClose(this); }; this.maskEl.classList.remove('popup-active'); const box = this.maskEl.querySelector('.popup-box'); if (box) { box.addEventListener('transitionend', finish, { once: true }); } setTimeout(finish, 400); }

open 里有一个容易被忽略的点:为什么用requestAnimationFrame再添加popup-active?因为浏览器需要在样式重绘之后切换 class 才会触发 transition 过渡。如果同步加类,很多浏览器会直接跳到最终状态,动画看起来就像没发生。

close 里有两个退出路径:transitionend事件和setTimeout(400)兜底。transitionend 是面板过渡动画真正结束的标志,动画结束后再移除节点可以避免闪烁;但某些极端情况下 transition 没有触发,比如 CSS 被压缩掉了 transition 属性,此时 400ms 兜底能保证节点一定会被销毁。这里的 400ms 要和 CSS 里最长的动画时长保持接近。

关闭时还要注意滚动锁的处理:Popup.activeCount只在到达 0 时才移除 body 的popup-lock类。如果这里写成每次 close 都移除,第二个弹窗打开时滚动锁就失效了。

3.4 静态方法:alert、confirm、prompt 一行调用

为了让业务代码不用记忆 new Popup 的流程,popup.js 在类上挂载了三个静态方法:

Popup.alert = (message, title = '提示', options = {}) => { return new Popup(Object.assign({ type: 'alert', message, title }, options)).open(); }; Popup.confirm = (message, title = '确认', options = {}) => { return new Popup(Object.assign({ type: 'confirm', message, title }, options)).open(); }; Popup.prompt = (message, title = '请输入', options = {}) => { return new Popup(Object.assign({ type: 'prompt', message, title }, options)).open(); };

三个方法的入参都遵循同一个约定:第一个参数是正文,第二个是标题,第三个是额外配置。调用Popup.confirm('确定要删除这条记录吗?', '删除确认', { theme: 'dark' })就能打开一个暗色主题的确认框。

静态方法返回的是 Popup 实例,所以还能继续链式调用实例方法。但要注意,按钮点击结果的获取不是在静态方法里 return 的,需要通过onConfirm/onCancel回调拿到。如果你想写成await Popup.confirm(...)的形式,需要自己再做一层 Promise 包装,这个改造在最后一章会给方案。

4. 主题皮肤与交互扩展:CSS 变量和一拖就走的弹窗

4.1 CSS 变量定义主题皮肤

整个弹窗的外观不依赖图片和字体,所有颜色、圆角、阴影都走 CSS 变量。默认主题这样定义:

:root { --popup-bg: #ffffff; --popup-color: #1f2329; --popup-radius: 12px; --popup-shadow: 0 12px 40px rgba(0, 0, 0, 0.12); --popup-border: 1px solid rgba(0, 0, 0, 0.06); } .popup-box { background: var(--popup-bg); color: var(--popup-color); border-radius: var(--popup-radius); box-shadow: var(--popup-shadow); border: var(--popup-border); }

换肤只需要在主题类里覆盖这些变量。比如玻璃拟态主题:

.popup-theme-glass { --popup-bg: rgba(255, 255, 255, 0.72); --popup-radius: 16px; --popup-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }

暗黑主题则把--popup-bg改成#1f2329,把--popup-color改成#e5e6eb,同时给遮罩稍微加深。由于按钮、输入框、关闭按钮都引用这些变量,所以不需要逐个覆盖子元素样式。实际使用中,如果要做品牌定制,直接在:root下覆写这几个变量就够了,不需要动 JS。

需要注意:backdrop-filter在部分浏览器上兼容性一般。玻璃主题如果不想影响性能,可以改成半透明背景加轻微阴影,同样能呈现层次感。

4.2 动画状态类:从 fade 到 flip

动画是弹窗“漂亮”的关键。这一版支持 fade、slide、scale、flip 四种,前三种用 transition 实现,flip 用 animation + transform。

.popup-anim-fade .popup-box { opacity: 0; transition: opacity 0.25s ease, transform 0.25s ease; } .popup-active .popup-anim-fade .popup-box { opacity: 1; } .popup-anim-scale .popup-box { opacity: 0; transform: scale(0.92); transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); } .popup-active .popup-anim-scale .popup-box { opacity: 1; transform: scale(1); } .popup-anim-flip .popup-box { animation: popupFlipIn 0.4s ease both; } @keyframes popupFlipIn { 0% { transform: perspective(800px) rotateX(-12deg); opacity: 0; } 100% { transform: perspective(800px) rotateX(0deg); opacity: 1; } }

fade 和 scale 走同一套路:通过.popup-active切换目标状态。而 flip 如果使用 animation,关闭动画不能再靠同一组 animation 反向执行,需要单独写popupFlipOut或者在 close 时用 transition 处理。为了避免这个复杂度,我一般建议项目里常用 fade 或 scale,flip 只做展示型弹窗。

4.3 拖拽、遮罩关闭、Esc 键盘监听

弹窗交互里最常加的是拖拽。这套组件的拖拽实现很轻,只依赖 mousedown、mousemove、mouseup 三对事件:

enableDrag() { const header = this.maskEl.querySelector('.popup-header'); const box = this.maskEl.querySelector('.popup-box'); if (!header || !box) return; header.addEventListener('mousedown', (e) => { const rect = box.getBoundingClientRect(); const offsetX = e.clientX - rect.left; const offsetY = e.clientY - rect.top; const onMove = (ev) => { box.style.left = `${ev.clientX - offsetX}px`; box.style.top = `${ev.clientY - offsetY}px`; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }

代码里的 offsetX 和 offsetY 是鼠标按下去时,鼠标指针相对面板左上角的偏移。移动时用ev.clientX - offsetX算出面板新的 left/top,这样面板不会因为鼠标点在标题栏不同位置而跳动。mousemove 和 mouseup 挂在 document 上,保证拖出浏览器范围再松开也能正确结束。

遮罩关闭和 Esc 关闭在 bindEvents 里统一处理。maskClose 为 true 时,给 mask 监听 click,如果事件目标是 mask 本身而不是内部面板,就调用 close。Esc 用一个 document keydown 监听,判断event.key === 'Escape'时关闭当前栈顶弹窗。

4.4 自定义按钮:从两个按钮扩展到任意操作

默认 confirm 只有确定和取消,但业务中常会遇到「保存并继续」「保存并关闭」「取消」这种多操作。组件支持 buttons 数组来覆盖默认按钮:

buttons: [ { text: '取消', action: 'cancel', theme: 'plain' }, { text: '保存并继续', action: 'save-continue', color: 'primary' }, { text: '保存并关闭', action: 'save-close', color: 'success' } ]

renderFooter 在遍历 buttons 时,为每个按钮创建<button class="popup-btn">.popup-lock { overflow: hidden; }

同时,如果项目的滚动容器不是 body 而是某个.content元素,需要让调用方在 options 里传入scrollLockTarget,把 overflow 锁到实际滚动容器上。单页应用中尤其常见,body 不动但某个布局块在滚动,只锁 body 是没用的。

5.3 关闭弹窗时内容闪烁一下才消失

现象:点击确定后,弹窗不是平滑淡出,而是先闪一下,然后瞬间消失,看起来像动画没执行。

原因:close 里移除了popup-active,但紧接着如果同步执行了maskEl.remove(),浏览器把节点直接移出 DOM,transition 还没开始就已经被销毁了。

解决:close 方法不能立刻 remove,要等到 transitionend 或 setTimeout 兜底之后再做 remove。前面第 3 章的 close 实现就是标准做法。如果你自己写,特别要注意不要在关闭按钮 click 事件里顺手this.maskEl.remove()。

5.4 多层弹窗叠加时,关闭其中一个导致滚动锁提前释放

现象:先打开确认框,再打开了一个自定义设置弹窗,设置弹窗关闭后,页面重新开始滚动;但下面还有一个没有关闭的确认框,滚动锁不应该被释放。

原因:close 时只做了document.body.classList.remove('popup-lock'),没有考虑到还有其他弹窗实例存在。

解决:引入Popup.activeCount计数器。open 时加一,close 时减一,只有计数归零时才允许移除滚动锁。这个计数器必须放在类静态属性上,而不能放在实例里,否则每个实例都是独立数字,无法判断全局状态。

5.5 快速双击按钮创建了两个一模一样的弹窗

现象:用户快速点击“删除”,页面同时弹了两个确认框,点击其中一个确定后,另一个还残留,要再点一次才能完全关掉。

原因:open 之前没有做重复实例判断,双击触发了两次 open,且两个实例属于不同的 id,互不知道对方存在。

解决:可以引入一个Popup.openInstances = []数组,在 open 时检查相同 type 和相同 message 是否已经存在,存在就直接 return 当前实例。或者更通用地加一个singleton参数,只有配置了singleton: true时跳过重复创建。在弹窗这种高频交互场景中,防重复是必不可少的。

6. 二次开发:给 popup 加 Promise 支持和路由级清理

如果你想把弹窗结果从“回调地狱”改成“await 一行拿到结果”,只需要给 Popup 增加一个confirmAsync静态方法。核心思路是把 open 的实例引用起来,在按钮回调里通过 resolve 把用户选择抛出去。

Popup.confirmAsync = (message, title, options = {}) => { return new Promise((resolve) => { let done = false; const finish = (value) => { if (done) return; done = true; resolve(value); }; Popup.confirm(message, title, { ...options, onConfirm: () => finish(true), onCancel: () => finish(false), onClose: () => finish(false) }); }); };

调用时:

const ok = await Popup.confirmAsync('确定删除这条数据吗?'); if (ok) { deleteRecord(id); }

这里用done标记避免 onConfirm 和 onClose 同时触发时 Promise 被重复 resolve。如果你还需要拿到用户在 prompt 里输入的值,同理把输入框的值作为 resolve 参数传出来即可。

路由级清理是我在实际业务里最依赖的一个改造。单页面应用在切换路由时,如果弹窗没有关闭,会出现上一个页面的弹窗残留。做法是在 Popup 类上维护一个Popup.allInstances = [],open 时 push 当前实例,close 时 remove 掉对应 id,然后暴露一个Popup.closeAll();路由切换钩子里调用一下:

router.afterEach(() => Popup.closeAll());

closeAll 本质是遍历实例数组逐个 close,因为 close 内部有 activeCount 递减逻辑,最终会让滚动锁归零,不会再把 body 锁死。

从那以后,我每次在交付弹窗组件前,都会强制走一遍“同时开两个不同主题弹窗、拖拽、Esc 退出、快速双击按钮”四步测试,确认滚动锁和计数器都正常,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进