原生JavaScript打造轻量div弹窗组件:动画、遮罩与事件代理实践 简介面向 Web 前端初中级开发者的一套 div 弹窗实现合集集成了多种流行页面弹窗效果解决日常开发中通知、确认框、模态与非模态交互界面的设计与浏览器兼容问题。压缩包共 20 个文件含 11 个 JavaScript、6 个 HTML、3 个 CSS整体仅 71KB。HTML 用于搭建弹窗骨架CSS 负责圆角、阴影、半透明等视觉样式与动画JavaScript 则实现显示隐藏、拖动、缩放及边界外关闭等交互逻辑。示例覆盖圆角/方角、有无背景、半透明、可拖动、由下向上滑出、单击边界外隐藏、内容滚动等常见形态并在代码层面对 IE、Firefox、Chrome、Safari、Opera 等主流浏览器做了兼容处理。每个效果均提供可直接运行的 demo便于读者对照修改参数、理解定位方式和事件监听思路也可直接抽取到实际项目中二次开发。目前已有 882 人学习浏览适合希望快速上手弹窗组件并提升前端交互能力的学习者。1. 项目定位一个把 web 页面弹窗做成“门面”的纯前端组件上个月做某数据平台的活动页运营提了个需求页面加载完弹一个漂亮的 div 弹窗。我第一反应是翻组件库可找了一圈要么体积太大要么样式锁死没法照着视觉稿改。后来干脆用原生 JavaScript 加 CSS 从零写了一套弹窗组件就是这份「漂亮的div弹窗」项目源码。它解决的其实是 web 页面弹窗里最日常的那堆破事遮罩层盖错层级、动画播放到一半被硬切、手滑连点按钮弹窗叠成一摞。整个项目不依赖任何框架核心就两个文件复制到任意页面里就能跑。适合做活动页、后台管理系统的开发者也适合正在被 UI 和产品来回打磨弹窗交互的人。如果你只需要一个轻量、可改样式、能看懂的弹窗这个源码可以直接抄走。2. 设计与结构拆包一个可拔插的 div 弹窗组件很多人做弹窗的习惯是HTML 里预先埋一个隐藏的 div需要显示时改一下 display。这个项目偏偏反着来——弹窗的整个 DOM 都是脚本动态创建出来的调用方只传配置对象页面里一个弹窗节点都不预埋。这么设计的好处很明显弹窗跟业务彻底解耦不需要就能整个拔掉一个项目里同时准备信息提示、二次确认、图片预览三种弹窗也互不干扰。2.1 技术选型原生 JavaScript CSS3为什么不引框架弹窗这种组件有一个特点它是纯粹的“表现层”。大部分时候业务只想要一个能关、好看、不挡操作的容器并不想被某个 UI 库的主题变量、边框半径、按钮风格绑住。所以这个项目用 ES6 class 写逻辑、CSS3 的 transition 写动画没有任何框架依赖。选型理由可以拆成三条。第一是接入门槛原生组件不需要构建工具链老项目里的旧页面也能直接引甚至直接放在一个独立 js 文件里就行第二是改样式自由视觉稿说要圆角 16px 就圆角 16px说遮罩要毛玻璃就加一行 backdrop-filter不会出现被组件默认样式覆盖回来的情况第三是体积整个组件两个文件加起来不到 10KB比引一个现成 UI 库的弹窗模块划算太多。有人会问在 Vue 或 React 项目里直接写个弹窗组件不就行了当然可以但这套源码的价值在于它把“弹窗”这件事本身拆成了几个稳定步骤——创建节点、插入页面、触发动画、等待结束、移除节点。你在任何框架里写弹窗本质上都是这五步。看懂原生实现后再封装成某个前端框架的传送门组件就只是换个壳的问题。CSS 这边也有讲究。动画用的全是 opacity 和 transform而不是 width、top、margin 这种会触发重排的属性。opacity 和 transform 能交给 GPU 合成弹窗缩放加位移动画时帧率稳得多这在移动端低端机上差别非常明显。另外所有状态都由一个 .modal-layer-open 类驱动弹窗开不开、动画走到哪一步看这个类在不在就行出了问题也容易定位。2.2 目录结构四个文件各管一段整个项目的文件结构不长我直接列出来文件职责index.html演示页放触发按钮引入 css 和 jscss/modal.css弹窗、遮罩、动画的全部样式js/modal.js弹窗核心类 Modal需要保留的源码js/demo.js演示入口展示怎么调用 ModalREADME.md参数说明、接入方式index.html 是个极简演示页只放三个按钮每个按钮触发一种动画参数的弹窗。它存在的意义是让你不用写代码就能先看到效果真正接入时这个文件可以整个删掉。css/modal.css 内部按三段组织第一段是基础样式定义遮罩和弹窗本体的布局、颜色、字号第二段是状态样式只有一个 .modal-layer-open 开关第三段是动画细节像缩放起止值、淡入淡出的透明度、过渡时长。要调动画节奏只改第三段就行不会误伤其他部分。js/modal.js 是核心一个 Modal 类覆盖全部功能默认参数、DOM 创建、事件绑定、打开与关闭、动画调度。暴露给外面的接口实际只有 constructor、open、close 三个。demo.js 则演示了外部视角怎么传参、怎么拿到实例、回调函数写在哪。如果你只想把组件拿走拷 modal.js 和 modal.css 两个文件就行demo.js 和 index.html 是留给你的“使用说明书”。从扩展性角度再说一下为什么把文件拆这么干净。弹窗很容易被改着改着变成一个几百行的巨无霸函数里面 DOM 拼接、事件、动画全搅在一起。这个项目把 DOM 创建收敛到 _render把事件聚到 _bind把动画调度拆成 open 和 close 两个方法每个函数只干一件事。后面不管是加按钮、改动画、支持多实例都能找到明确的下手位置而不是在堆满的代码里捞针。3. 核心模块拆解一个弹窗从触发到关闭的完整链路这一章直接从 modal.js 的源码讲起。弹窗的生命周期分四段构造时创建 DOM、open 时挂载并播放动画、点击关闭时逆播放、动画结束后卸载。我按这个顺序把关键代码逐段拆开。3.1 渲染阶段new Modal 时发生了什么看构造函数的实现class Modal { constructor(options) { // 默认参数与调用方传参合并后传的覆盖默认值 this.options Object.assign({ title: 提示, // 标题栏文字 content: , // 内容支持 HTML 字符串 width: 480px, // 弹窗宽度支持 px / rem / % maskClosable: true, // 点遮罩是否关闭 animation: zoom, // 动画风格zoom / fade / slide onOpen: null, // 打开完成回调 onClose: null // 关闭完成回调 }, options); this.el null; // 弹窗本体 DOM this.mask null; // 遮罩层 DOM this.layer null; // 外层容器mask 和 content 的父级 this._render(); // 构造时就把 DOM 建好 } }构造函数里没有直接往页面插节点而是先合并参数、再调 _render 把整个弹窗的 DOM 树创建出来存在 this 上。这样做的目的是让 open() 变成一个纯“插入加动画”操作点击按钮时页面不卡。参数表里 width 直接控制弹窗宽度maskClosable 控制点击遮罩行为animation 决定后续加哪个动画类这些都是调用方可以覆盖的。再看 _render 方法_render() { const layer document.createElement(div); layer.className modal-layer; layer.innerHTML div classmodal-mask/div div classmodal-content stylewidth:${this.options.width} div classmodal-header span classmodal-title${this.options.title}/span span classmodal-close>open() { // 挂载到 body 后才会有真实的布局上下文 document.body.appendChild(this.layer); // 锁住页面滚动弹窗内部滚动不受影响 document.body.style.overflow hidden; // 先让浏览器渲染一帧再切 classtransition 才会触发 requestAnimationFrame(() { this.layer.classList.add(modal-layer-open); }); if (typeof this.options.onOpen function) { this.options.onOpen(this); } } close() { // 先移除状态类让动画往回收 this.layer.classList.remove(modal-layer-open); // 动画播完再卸载节点避免关闭动画被硬切 this.layer.addEventListener(transitionend, () { this.layer.remove(); document.body.style.overflow ; }, { once: true }); if (typeof this.options.onClose function) { this.options.onClose(this); } }open 里那个 requestAnimationFrame 是动画能播出来的关键。如果 appendChild 后立刻切 class浏览器还没把初始样式渲染出来transition 会直接跳过弹窗就会“刷”一下出现而不是淡入。close 里 transitionend 决定节点什么时候卸载等动画真的播完再 remove关闭过程才是完整的。两个方法里的 overflow 操作是成对出现的锁住背景滚动关闭后恢复漏一个都会留下页面滚动异常的后遗症。配套的 CSS 核心片段长这样.modal-layer .modal-content { opacity: 0; transform: scale(0.9) translateY(20px); transition: opacity .24s ease, transform .24s ease; } .modal-layer.modal-layer-open .modal-content { opacity: 1; transform: scale(1) translateY(0); }动画只依赖 .modal-layer-open 这个类初始状态在默认样式里目标状态在状态类里。CSS transition 会自动补全中间过程完全不碰 display也不用手写 setInterval 逐帧计算。这套思路同样适用于 fade 和 slide只是把 opacity 和 transform 的起止值换一下。3.3 事件绑定代理一个 close扩展出全部分子再看事件绑定_bind() { this.layer.addEventListener(click, (e) { // closest 向上查找带>cd div-modal python3 -m http.server 8080然后浏览器访问 http://localhost:8080。方式二是用 Nodecd div-modal npx serve .启动后访问 http://localhost:3000。提示双击 index.html 报模块加载错误不是代码问题是浏览器安全策略本地起服务后一切正常。页面打开后有三个按钮分别触发 fade、zoom、slide 三种动画参数的弹窗。点“打开弹窗”弹窗从屏幕中间淡入打开开发者工具的 Elements 面板能看到节点是动态挂到 body 上的关闭后节点从 DOM 移除body 上的 overflow: hidden 也被清掉。这一套检查下来组件的生命周期就是完整的。如果想起服务都省了直接在 DevTools 的 Console 里手动执行new Modal({ title: 测试, content: p手动触发/p }).open()也能看到弹窗效果。这个方式适合快速验证参数改动不碰页面代码。4.2 避坑遮罩层级、动画时序、重复叠加三个翻车现场坑 1遮罩把弹窗本体盖住了。现象弹窗弹出后标题栏和按钮都点不到点击全部落在遮罩上。原因CSS 里遮罩和 content 的 z-index 没有拉开或者遮罩是 content 的祖先元素且设置了超出预期的 z-index。解决第一DOM 上 mask 和 content 必须是兄弟节点第二给 .modal-content 设置比遮罩更高的 z-index同时两个 z-index 都不要超过 100避免和页面顶部导航、悬浮按钮这类全局浮层打架。z-index 在这个场景里够用就行不是越大越安全。坑 2关闭动画播不出来点一下弹窗瞬间消失。现象关闭时没有任何过渡div 直接消失视觉上是生硬的闪断。原因close() 里移除 class 后立刻调 layer.remove()浏览器还没来得及渲染退出帧transition 就被打断了。这个坑我栽过不止一次很多弹窗写法都倒在这里。解决监听 transitionend 等动画播完再卸载同时加一个 setTimeout 兜底。因为有些情况下 transitionend 可能不触发节点会永远留在页面里close() { this.layer.classList.remove(modal-layer-open); const remove () this.layer.remove(); this.layer.addEventListener(transitionend, remove, { once: true }); // 兜底300ms 后强制清理防止 transitionend 偶发不触发 setTimeout(remove, 300); }为什么是 300ms默认过渡时长 240ms加 60ms 余量既能兜底又不会让节点残留太久。真遇上过渡时长改到 500ms 的情况这个值要跟着调否则会看到弹窗被提前拔掉。动画时序的本质就是“等动画真正结束再做下一步”。坑 3快速连点按钮弹窗叠罗汉。现象双击“打开弹窗”页面里出现两个遮罩两层弹窗关一个还剩一个。原因open() 没有做单例判断每次调用都无条件 appendChild。demo 里点一下没事真实用户手滑连点就会触发。解决在 open() 入口先检查当前是否已有弹窗挂载open() { // 防止重复点击造成多弹窗叠加 if (document.querySelector(.modal-layer)) { return this; // 已有弹窗就不重复创建 } // ...原有逻辑 }这个单例判断用 querySelector 检查整个 body 就够了。如果项目里刻意要支持多弹窗堆叠就把这里的 return 改成先把旧弹窗关闭再开新弹窗而不是放任不管。5. 二次开发把固定弹窗改成可配置的通用组件直接拷贝源码用的话你会发现 demo 里的弹窗内容都是字符串拼进去的标题写死按钮只有一个“知道了”。真实业务里弹窗要放图片、表单、甚至一个视频播放器确认按钮还要接住后端的返回结果。这里给三个最常用的改造点。第一个改造是让 content 支持 DOM 节点。在 _render 里加一个分支const body layer.querySelector(.modal-body); if (typeof this.options.content string) { body.innerHTML this.options.content; } else if (this.options.content instanceof HTMLElement) { body.appendChild(this.options.content); }传字符串走 innerHTML传节点走 appendChild。这样外部调用时可以直接把页面里已有的复杂节点塞进去不用在字符串里拼长长的 HTML。第二个改造是确认按钮接 Promise 回调。给确认按钮加>if (action confirm) { const done this.options.onConfirm this.options.onConfirm(); if (done typeof done.then function) { done.then(() this.close()); } else { this.close(); } }确认弹窗大多是异步的比如先调接口再关闭。支持 Promise 后接口失败时不关闭、按钮转 loading都是调用方的事组件本身只保证“等请求完成再收场”。第三个改造是把参数收敛成一份配置表接入方不需要读源码看表就能用参数类型默认值说明titlestring提示标题文字contentstring / HTMLElement空内容支持 HTML 或节点widthstring480px弹窗宽度maskClosablebooleantrue点遮罩关闭animationstringzoom动画风格onConfirmfunctionnull确认回调支持 Promise这三个改造做完这份源码在项目里基本就从演示 Demo 变成了能进生产环境的通用弹窗。对我来说最关键的一条经验是弹窗这种看似不起眼的组件最容易在关闭路径上出问题。所以从那以后我每次做弹窗都会强制自己把“关闭动画是否完整播完、节点有没有被移除、背景滚动有没有恢复”这一套流程走完才敢合代码。希望帮到你。本文还有配套的精品资源点击获取