☰
原生JavaScript打造轻量div弹窗组件:动画、遮罩与事件代理实践
2026/10/17 1:58:10 网站建设 项目流程

简介:面向 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 和 js
css/modal.css弹窗、遮罩、动画的全部样式
js/modal.js弹窗核心类 Modal,需要保留的源码
js/demo.js演示入口,展示怎么调用 Modal
README.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 class="modal-mask"></div> <div class="modal-content" style="width:${this.options.width}"> <div class="modal-header"> <span class="modal-title">${this.options.title}</span> <span class="modal-close">open() { // 挂载到 body 后才会有真实的布局上下文 document.body.appendChild(this.layer); // 锁住页面滚动,弹窗内部滚动不受影响 document.body.style.overflow = 'hidden'; // 先让浏览器渲染一帧,再切 class,transition 才会触发 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。

方式二是用 Node:

cd 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 变成了能进生产环境的通用弹窗。对我来说最关键的一条经验是:弹窗这种看似不起眼的组件,最容易在关闭路径上出问题。所以从那以后我每次做弹窗,都会强制自己把“关闭动画是否完整播完、节点有没有被移除、背景滚动有没有恢复”这一套流程走完才敢合代码。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询