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

发布时间:2026/10/18 8:46:06
原生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 srcjs/popup.js和link relstylesheet hrefcss/popup.css之后直接使用。跑起来的方式有两种直接双击 index.html或者在项目目录里起一个静态服务python -m http.server 8000为什么要推荐起服务而不是直接双击如果后续想把 popup.js 改成 ES Module 的import方式加载直接双击打开就会遇到浏览器对模块跨域的限制。起一个本地 http 服务可以让后续扩展少踩一个坑。2.2 核心 DOM 模型遮罩、面板、轻提示弹窗组件最要紧的是把“遮挡”和“内容”分层。这套组件在页面里会生成三类节点div classpopup-mask !-- 全屏遮罩层 -- div classpopup-box div classpopup-header标题区域/div div classpopup-body消息内容/div div classpopup-footer按钮区域/div /div /div div classpopup-toast轻提示/divpopup-mask 负责全屏拦截点击并给面板一个统一的背景压暗效果。popup-box 是真正的弹窗面板主题皮肤和动画类都挂在这层上。popup-toast 则是另外一种形态它不遮罩、不弹按钮只在屏幕中间或顶部飘几秒然后消失适合操作成功、失败这类轻提醒。这三类的层级顺序是固定的toast 永远在最上面mask 其次。CSS 里把这三个类分别设置z-index避免弹窗一多就互相盖住。建议数值是popup-mask为 1000popup-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 classpopup-header span classpopup-title${this.options.title}/span ${this.options.showClose ? span classpopup-closetimes;/span : } /div div classpopup-body${this.options.message}/div div classpopup-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 或 scaleflip 只做展示型弹窗。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 classpopup-btn>.popup-lock { overflow: hidden; }同时如果项目的滚动容器不是 body 而是某个.content元素需要让调用方在 options 里传入scrollLockTarget把 overflow 锁到实际滚动容器上。单页应用中尤其常见body 不动但某个布局块在滚动只锁 body 是没用的。5.3 关闭弹窗时内容闪烁一下才消失现象点击确定后弹窗不是平滑淡出而是先闪一下然后瞬间消失看起来像动画没执行。原因close 里移除了popup-active但紧接着如果同步执行了maskEl.remove()浏览器把节点直接移出 DOMtransition 还没开始就已经被销毁了。解决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 退出、快速双击按钮”四步测试确认滚动锁和计数器都正常希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询