1. 选择弹窗到底解决的是什么问题
1.1 从页面跳转到弹窗的交互演进
很多人在第一次接触前端开发时,遇到"需要做一个选择"的需求,第一反应是跳转页面:列表页跳到详情页,详情页跳到编辑页,编辑页提交完再跳回来。但实际做下来会发现,这种方式在"临时选择"场景下特别别扭——用户本来只是想选一个负责人、挑一种支付方式、确认一个操作,结果被迫离开当前页面,来回加载,不仅慢,而且很容易在跳转过程中丢掉之前的操作状态。
我自己第一次被"弹窗需求"打脸,是做一个后台工单系统。当时需求方说"在工单列表里加一个分配功能,点击分配按钮弹出一个框,里面列出所有处理人,选了之后工单直接挂上去"。当时图省事,直接跳转到单独的分配页面,结果被吐槽"为什么我每条工单都要跳转一次?"从那以后我就明白了:当选择动作本身是当前流程的中间步骤,而不是终点时,弹窗是最自然的交互形态。
选择弹窗的本质,是在不打断当前上下文的前提下,把"选择"这个动作压缩成一个即时响应的小交互。用户不用离开当前页面,不用记忆临时信息,点了就有反馈,选完就带回结果。这种模式在电商支付、后台分配、商品筛选、数据管理等场景里无处不在。
1.2 选择弹窗和普通弹窗的分界点
说到弹窗,很多人会把提示弹窗、广告弹窗、选择弹窗混为一谈。但从代码实现看,它们的侧重点完全不同。
- 提示类弹窗(如"保存成功"):核心是单向通知,用户只能点确认,几乎没有反馈回路。
- 广告类弹窗(如优惠券弹窗):核心是曝光和引流,通常带图片和跳转按钮。
- 选择类弹窗(如选支付方式、选负责人):核心是用户主动决策,需要明确的可选项、选中状态、确认/取消动作,以及结果回传。
这也是为什么很多UI库里的Modal组件,虽然能满足80%的弹窗场景,但真正做选择弹窗时,还是需要自己再封装一层。因为选择弹窗的复杂度不在于"弹出来"这个动作,而在于"选项怎么组织、怎么选中、结果怎么带回来"这一条完整链路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写一个原子级选择弹窗:结构、状态、交互拆开看
2.1 弹窗的HTML骨架:遮罩层与面板缺一不可
一个最基础的选择弹窗,HTML结构就三块:触发按钮、遮罩层、弹窗面板。遮罩层负责把背景压暗、拦截背景点击;面板负责承载选项内容和操作按钮。
我习惯的写法是这样:
html复制<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>选择弹窗基础示例</title>
<style>
/* 遮罩层 */
.modal-mask {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 999;
}
/* 弹窗面板 */
.modal-panel {
display: none;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 320px;
background: #fff;
border-radius: 8px;
z-index: 1000;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
}
.modal-panel.active,
.modal-mask.active {
display: block;
}
.modal-header {
padding: 14px 16px;
border-bottom: 1px solid #eee;
font-size: 16px;
font-weight: 600;
}
.modal-body {
padding: 16px;
}
.modal-footer {
padding: 12px 16px;
text-align: right;
border-top: 1px solid #eee;
}
.modal-footer button {
margin-left: 8px;
padding: 6px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-confirm {
background: #1677ff;
color: #fff;
}
.btn-cancel {
background: #f0f0f0;
color: #333;
}
</style>
</head>
<body>
<button id="openModalBtn">选择处理人</button>
<!-- 遮罩层 -->
<div class="modal-mask" id="mask"></div>
<!-- 弹窗面板 -->
<div class="modal-panel" id="panel">
<div class="modal-header">选择处理人</div>
<div class="modal-body">
<!-- 选项列表由JS生成 -->
<ul id="optionList"></ul>
</div>
<div class="modal-footer">
<button class="btn-cancel" id="cancelBtn">取消</button>
<button class="btn-confirm" id="confirmBtn">确定</button>
</div>
</div>
<script>
// 状态定义
const state = {
visible: false,
selectedUser: null,
users: [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
]
};
</script>
</body>
</html>
这里有几个结构上的关键点:
第一,遮罩层和弹窗面板是兄弟节点。有些同学会把面板直接套在遮罩里面,写成mask > panel的结构,虽然视觉上没问题,但后续做点击遮罩关闭、动画分离的时候会变得很麻烦。兄弟节点可以各自控制显隐、各自做过渡动画,互不干扰。
第二,z-index一定要给遮罩和面板留出足够层级。我见过不少项目里弹窗被某个position: relative的父级容器盖住的情况,最后排查下来都是z-index和层叠上下文的问题。这里的取值只是一个基准,实际项目里如果页面结构复杂,建议把弹窗层统一提到body下直接挂载,避免被嵌套容器的层叠上下文锁死。
2.2 打开、关闭、回收:最少的状态管理
弹窗核心的状态其实只有三个:是否可见、当前选中的项、确认或取消后的回调。没必要一上来就用Vue、React的全局状态管理,简单场景里一个局部对象就够了。
先看最原始的原生写法:
javascript复制// DOM引用
const mask = document.getElementById('mask');
const panel = document.getElementById('panel');
const optionList = document.getElementById('optionList');
const openBtn = document.getElementById('openModalBtn');
const cancelBtn = document.getElementById('cancelBtn');
const confirmBtn = document.getElementById('confirmBtn');
// 打开弹窗
function openModal() {
state.visible = true;
mask.classList.add('active');
panel.classList.add('active');
renderOptions();
}
// 关闭弹窗
function closeModal() {
state.visible = false;
mask.classList.remove('active');
panel.classList.remove('active');
}
// 渲染选项列表
function renderOptions() {
optionList.innerHTML = '';
state.users.forEach(user => {
const li = document.createElement('li');
li.textContent = user.name;
li.dataset.id = user.id;
li.style.cursor = 'pointer';
li.style.padding = '8px 0';
li.style.listStyle = 'none';
li.style.borderBottom = '1px solid #f0f0f0';
li.addEventListener('click', function () {
// 高亮选中项
const items = optionList.querySelectorAll('li');
items.forEach(item => item.style.background = '#fff');
li.style.background = '#e6f4ff';
state.selectedUser = { id: user.id, name: user.name };
});
optionList.appendChild(li);
});
}
// 事件绑定
openBtn.addEventListener('click', openModal);
cancelBtn.addEventListener('click', closeModal);
mask.addEventListener('click', closeModal);
confirmBtn.addEventListener('click', function () {
if (!state.selectedUser) {
alert('请先选择一位处理人');
return;
}
closeModal();
console.log('选中的处理人是:', state.selectedUser);
});
这个版本,打开和关闭的逻辑很直白:通过classList.add/remove控制display,渲染列表时用createElement和appendChild逐个生成。为什么不直接用innerHTML拼字符串?因为用createElement生成的元素可以直接绑定事件,不需要在字符串里写onclick,也不用担心字符串里的引号转义问题。这在选项很多、字段比较复杂时更安全。
关于"回收"这个概念,简单场景下,关闭弹窗不一定非要把DOM删掉。但如果你发现弹窗在二次打开时带着上一次的选中状态、滚动位置、甚至上一次的DOM残留,那就需要考虑在openModal()里做一次初始化——把选中状态清零、把选项列表重新渲染。这比在关闭时做清理要更省事,因为打开时初始化可以保证每次交互都是全新状态。
2.3 让弹窗真正"可用"的细节处理
手写弹窗最大的一个优势就是可以精确控制交互细节。我总结了几个"没有它们也能跑、但加上之后体验提升很多"的细节:
点击遮罩关闭的边界判定
上面的代码里,mask.addEventListener('click', closeModal)这行有一个隐患:如果你点击的是弹窗面板内部,但面板恰好是遮罩的子节点,或者发生了事件冒泡,就会导致"点面板内部也触发了关闭"。兄弟节点结构能规避这个问题,但更稳妥的做法是在面板内部阻止冒泡:
javascript复制panel.addEventListener('click', function (e) {
e.stopPropagation();
});
Esc键关闭
键盘可用性在后台系统中非常重要。很多用户习惯按Esc关闭弹窗,尤其当弹窗堆叠多层时。实现很简单:
javascript复制document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && state.visible) {
closeModal();
}
});
打开弹窗后锁定焦点
对于追求体验的页面,打开弹窗后应该把焦点移到弹窗内部,关闭后再把焦点还给触发按钮。这个属于可访问性范畴,不要求每个项目都做,但如果你的页面有大量键盘操作用户,这个细节会让他们很舒服。
3. 选项数据驱动:从"写死"到"动态渲染"
3.1 静态选项的渲染方式
最简单的场景,选项是固定写死的,比如性别选择(男、女),这时直接在HTML里写死li标签就行,连JavaScript都不用。但稍微复杂一点的选择弹窗,选项往往来自接口、来自配置表、来自另一棵树的子节点,这时候就必须用"数据驱动渲染"的思路。
数据驱动渲染的核心就一句话:选项列表是数据的一个投影,你操作的是数据,不是DOM。
举个反例,我见过有人写多选逻辑,是去数DOM上有几个li被加了.active类,然后把这些li的文本拼起来当作结果。这种方法在选项固定、顺序固定的页面里能跑,但一旦选项变成接口返回的、顺序变的、带禁用状态的,代码立刻失控。
正确做法是维护一份"选中项集合",DOM只负责呈现:
javascript复制const state = {
users: [],
selectedIds: new Set()
};
function toggleSelect(userId) {
if (state.selectedIds.has(userId)) {
state.selectedIds.delete(userId);
} else {
state.selectedIds.add(userId);
}
renderOptions();
}
function renderOptions() {
optionList.innerHTML = '';
state.users.forEach(user => {
const li = document.createElement('li');
li.textContent = user.name;
if (state.selectedIds.has(user.id)) {
li.classList.add('selected');
}
li.addEventListener('click', () => toggleSelect(user.id));
optionList.appendChild(li);
});
}
用Set存选中状态,好处是增删和判断都是O(1),不会在数据量大的时候卡顿。配合renderOptions()全量重绘,虽然看起来"每次刷新所有DOM",但只要选项数量不是几千上万,性能完全没问题,而且代码逻辑极其清晰。
3.2 接口数据动态加载与异步渲染
如果选项来自接口,打开弹窗时就要考虑异步状态。我经历了从"不处理异步直接渲染"到"补上loading和空态"的过程,后者才是一个能上生产的弹窗。
前端请求接口的常见流程:
javascript复制async function openModalWithData() {
openModal(); // 先弹出遮罩和面板
optionList.innerHTML = '<li style="color:#999;list-style:none;">加载中...</li>';
try {
const response = await fetch('/api/users');
const data = await response.json();
state.users = data;
renderOptions();
} catch (error) {
optionList.innerHTML = '<li style="color:red;list-style:none;">加载失败,请重试</li>';
}
}
为什么先弹出弹窗再加载数据?因为用户已经点了触发按钮,心理上希望立刻看到反馈。如果先等接口回来再弹窗,碰到接口慢的时候,用户会以为点击没生效。先弹窗、再加载、加载失败还给重试能力,这种体验明显更稳。
这里有一个常见坑:接口返回的数据不一定能直接塞进state.users。比如后端返回的字段名是realName而不是name,那就要在赋值时做一次映射,而不是把接口数据直接绑定到渲染函数里。尽量让"内部数据结构"保持稳定,接口字段变化只改一处映射。
3.3 单选多选与结果回传
单选和多选在渲染上几乎一样,区别只在点击时的逻辑:单选是"先清空再选中",多选是"有则删、无则加"。
javascript复制// 单选
function selectSingle(userId) {
state.selectedIds.clear();
state.selectedIds.add(userId);
renderOptions();
}
// 多选
function toggleMulti(userId) {
if (state.selectedIds.has(userId)) {
state.selectedIds.delete(userId);
} else {
state.selectedIds.add(userId);
}
renderOptions();
}
结果回传是选择弹窗最容易被忽略的部分。很多新手把按钮点击和结果处理混在一起,直接在弹窗内alert或者console.log,这样弹窗组件就变成一个"不可复用"的模块。正确做法是将结果通过回调函数交还给调用方,由调用方决定后续动作。
javascript复制let onSubmitCallback = null;
function openModalWithCallback(callback) {
state.selectedIds.clear();
onSubmitCallback = callback;
openModal();
// 加载数据...
}
confirmBtn.addEventListener('click', function () {
const result = state.users.filter(user => state.selectedIds.has(user.id));
closeModal();
if (onSubmitCallback) {
onSubmitCallback(result);
}
});
这样设计之后,同一个人事选择弹窗,在工单页面用来选处理人,在会议页面用来选参会人,在权限页面用来选用户组,全部复用同一套代码,只需要在打开时传入不同的回调。这就是把结果回传和弹窗内部逻辑解耦的价值。
4. 三种高频实战场景拆解
4.1 场景一:支付方式选择弹窗
移动端H5里最常见的选择弹窗,就是支付方式选择。微信支付、支付宝支付、余额支付、聚合支付……每个业务方的支付渠道都不一样,而且往往还有"默认选中项"和"禁用项"(比如余额不足时置灰)。
这种弹窗的特点是:选项结构稳定但渠道配置经常变,而且需要展示额外的说明信息(比如"微信支付有立减活动")。
实现思路上,可以把每个支付方式建模为:
javascript复制const paymentMethods = [
{ id: 'wechat', name: '微信支付', desc: '推荐有优惠', disabled: false },
{ id: 'alipay', name: '支付宝支付', desc: '', disabled: false },
{ id: 'balance', name: '余额支付', desc: '余额不足', disabled: true }
];
disabled 状态必须在渲染时处理:置灰的选项不能点击,也不能在确认时作为有效结果回传。这比在点击函数里再判断要更清晰。
另外,支付弹窗一般带"记住本次选择"或者"每笔默认上次支付方式"这类需求。这时可以在确认回调里把选中的支付方式写入localStorage,下次打开时读取并设置为默认选中;这是很常见的产品逻辑。
4.2 场景二:带搜索的用户选择弹窗
后台管理系统里,选择弹窗经常面临"候选人员太多"的问题。几十个人还好,几百上千个人的时候,纯滚动列表体验非常差。这时候需要在弹窗内加搜索框。
搜索不一定要请求后端。如果数据量在几千条以内,前端本地过滤完全够用。
javascript复制function handleSearch(keyword) {
const filtered = state.allUsers.filter(user => {
return user.name.includes(keyword) || user.phone.includes(keyword);
});
renderOptions(filtered);
}
这里有一个小技巧:搜索状态和选中状态要解耦。也就是说,用户在搜索模式下选中了一项,清空搜索词之后,这一项在完整列表里仍然要保持在选中状态。所以我在实现里把state.allUsers(完整列表)和state.selectedIds(选中项集合)区分开,renderOptions只负责渲染传入的列表,不负责管理选中逻辑。
搜索框的实现还有几个细节:
- 输入防抖。
input事件触发很频繁,如果每次输入都做过滤或者发请求,性能会有压力。用300ms的setTimeout做防抖即可。 - 搜索框的清除按钮。用户输入之后想重置,需要点击搜索框内的叉号,或者按
Esc清空。 - 键盘事件。上下箭头选择列表项、回车确认,这些操作在用户频繁使用弹窗的系统里会大幅提升效率。
搜索场景下还有一个"全选"的需求,比如"给所有筛选出来的人发通知"。这时需要判断当前过滤列表是否全部在selectedIds中,再决定全选按钮的状态。
4.3 场景三:确认型选择弹窗替代原生confirm
confirm这个原生弹窗,虽然用起来最简单,但它有一个致命问题:样式太丑、而且不同浏览器渲染出来的样子完全不一样。在需要品牌统一的页面里,很多团队会选择用自定义弹窗替代confirm。
不过需要注意,替代confirm不是简单地把confirm('确定删除吗')换成自定义弹窗,而是要考虑原来的阻塞式API如何适配。原生confirm是同步阻塞的,弹窗出来之后,JS执行会停住等用户点击;而自定义弹窗是异步的,代码不会停在打开弹窗那一行。
所以替代方案通常是提供一个Promise封装:
javascript复制function showConfirm({ title, message }) {
return new Promise(resolve => {
openModal();
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmMessage').textContent = message;
confirmBtn.onclick = () => {
closeModal();
resolve(true);
};
cancelBtn.onclick = () => {
closeModal();
resolve(false);
};
});
}
// 使用
async function handleDelete() {
const isConfirmed = await showConfirm({
title: '删除确认',
message: '确定要删除这条记录吗?此操作不可恢复。'
});
if (isConfirmed) {
// 执行删除逻辑
}
}
这个封装的最大价值是:调用方可以用await把异步弹窗写得像是同步逻辑一样顺滑,不需要在每个弹窗打开处再嵌套一层回调。团队里其他成员用起来也简单,不容易出错。
5. 样式打磨、移动端适配与滚动穿透处理
5.1 过渡动画:没有动画的弹窗像"断电"
弹窗的过渡动画,成本最低、效果最明显的方案是用CSS的transition配合opacity和transform。我记得最开始实现弹窗时完全没有动画,弹窗出现是硬切,用户反馈"像断电一样"。后来加了一个0.2秒的淡入淡出,体验好了很多。
下面是一组简单的动画方案:
css复制.modal-mask {
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.modal-mask.active {
opacity: 1;
pointer-events: auto;
}
.modal-panel {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
.modal-panel.active {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
pointer-events: auto;
}
这里有个坑:如果只用display: none和display: block切换显隐,transition动画是无效的,因为元素从无到有之间没有"中间状态"。解决办法有两个:一是像上面这样,用opacity和pointer-events来控制显隐,同时维持元素始终在文档流里;二是用visibility和opacity组合。第二种更利于无障碍访问,因为visibility: hidden的元素不会被读屏器读取。
不过注意,用opacity方案时,隐藏的弹窗仍然占据布局空间,也可能被querySelector选中。如果弹窗里有什么敏感内容或者不希望被搜索引擎索引的内容,可以在关闭后彻底移除DOM,或者用visibility: hidden规避。
5.2 移动端适配与安全区
移动端H5的选择弹窗,最大的适配难点在底部弹出式面板。这种弹窗一般不是居中弹窗,而是从屏幕底部滑上来的面板,看起来更像微信/支付宝的弹出动作。
底部弹出面板的CSS核心是:
css复制.modal-panel-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
border-radius: 12px 12px 0 0;
transform: translateY(100%);
transition: transform 0.3s ease;
}
.modal-panel-bottom.active {
transform: translateY(0);
}
iPhone X及以后机型有底部安全区(env(safe-area-inset-bottom)),如果弹窗底部有确认按钮,不加安全区适配,按钮会被系统手势条遮挡。我的处理方式是在底部加一段padding:
css复制.modal-footer-bottom {
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
还有按钮的点击区域。移动端上,手指点击区域如果小于44x44像素,很容易误触别的元素。我一般会把弹窗里的选项项加高到48px以上,并适当增加间距。这个不算什么高深技术,但确实是影响好评度的一个细节。
5.3 body滚动穿透:最容易被忽视的坑
移动端弹窗场景里有一个经典bug:弹窗打开后,触摸背景部分,背景页面会跟着滚动,而弹窗内部的选项列表反而滚不动。这是因为背景的滚动并没有被禁用,触摸事件穿透到了body。
解决方案是在弹窗打开时锁定body的滚动:
css复制body.modal-open {
overflow: hidden;
position: fixed;
width: 100%;
}
然后在打开弹窗时给body添加这个类,关闭时移除。
但直接给body加position: fixed会产生一个副作用:页面会跳到顶部。因为fixed定位是相对于视口的,当body被固定后,会丢失当前的滚动位置。稳妥的做法是记录当前的scrollY,在关闭时恢复:
javascript复制let scrollTop = 0;
function openModal() {
scrollTop = window.pageYOffset || document.documentElement.scrollTop;
document.body.classList.add('modal-open');
document.body.style.top = -scrollTop + 'px';
}
function closeModal() {
document.body.classList.remove('modal-open');
window.scrollTo(0, scrollTop);
}
在桌面端,滚动穿透一般不会有问题,因为桌面浏览器可以用鼠标滚轮滚动背景区域,但很少会把弹窗里的滚动带走。不过我也见过某些桌面浏览器里,弹窗遮罩层没有拦截鼠标滚轮事件,导致背景滚动的情况。最简单的兜底就是给遮罩层加上overflow: hidden,并在打开时把焦点放在弹窗内部。
6. 真实项目里的踩坑记录
6.1 iframe嵌套页面里弹窗层级被压住
有一次做一个嵌入其它平台的H5页面,对方用iframe把我们页面嵌进去,结果我们的弹窗打开后,层级被外层的某个元素盖住了。排查半天,发现问题出在iframe本身是一个水平垂直居中的容器,外层平台给它加了很多层transform和z-index,导致我们页面内的position: fixed元素定位基准不再是最外层的视口,而是那个被transform扭曲后的包含块。
position: fixed遇到祖先元素有transform、filter、perspective等属性时,定位基准不再是视口,而是那个祖先元素。这是CSS层叠上下文的一个冷门规则。如果你在写弹窗组件,最好把它挂到body最外层,并确保没有父级容器被施加transform。
还有一个折中方案:不用position: fixed,而是用position: absolute配合body的高宽来计算位置。但在移动端、键盘弹出、地址栏隐藏等场景下,absolute方案会有各种边界问题,所以一般还是优先处理层叠上下文。
6.2 快速双击导致的重复提交
选择弹窗里的确认按钮,最怕用户手快双击。如果确认回调里发起的是支付请求、删除请求、提交订单请求,双击可能产生两条重复请求,这在支付场景里是致命的。
常用的防重复提交方案有两种:
第一种:按钮级禁用。
javascript复制confirmBtn.addEventListener('click', function () {
confirmBtn.disabled = true;
// 执行回调
setTimeout(() => confirmBtn.disabled = false, 1000);
});
第二种:状态级锁。
javascript复制let isSubmitting = false;
confirmBtn.addEventListener('click', function () {
if (isSubmitting) return;
isSubmitting = true;
// 执行回调
isSubmitting = false;
});
状态级锁更适合异步操作,比如确认之后要等接口返回。虽然对交易类操作,真正的幂等控制还是要靠后端做,但前端在交互层做防重仍然是必要的,这是减少无效请求、降低后端压力的第一道防线。
6.3 数据源更新后,旧选中状态残留
弹窗复用的时候最容易翻车的情况是:上次选择的数据还留在状态里,这次打开一个新场景,但选中的还是上一次的项。
比如同一个用户选择弹窗,在甲页面的候选人员列表和乙页面的候选人员列表不同,但state.selectedIds继承了上一次的选中结果,导致用户打开弹窗没做任何操作,直接点确认,就把上一个场景的选中项带进了当前场景。
解决办法就一条:每次打开弹窗时,强制初始化所有状态。 包括清空selectedIds、恢复搜索词为空、重置列表滚动位置、把输入框的防抖定时器清除。这一条可以写进团队的代码规范里,能避免很多隐蔽的数据错乱问题。
7. 从零封装一个可复用选择弹窗的最小工具
把上面的思路整合起来,我平时会维护一个简单的ChoiceModal工具类。它不是完整的UI库组件,但足够支撑大多数业务页面:
javascript复制class ChoiceModal {
constructor(options) {
this.mask = document.getElementById(options.maskId);
this.panel = document.getElementById(options.panelId);
this.body = this.panel.querySelector('.modal-body');
this.confirmBtn = this.panel.querySelector('.btn-confirm');
this.cancelBtn = this.panel.querySelector('.btn-cancel');
this.titleEl = this.panel.querySelector('.modal-header');
this.state = {
visible: false,
items: [],
selectedIds: new Set(),
multiple: options.multiple || false,
onConfirm: null
};
this.bindEvents();
}
bindEvents() {
this.cancelBtn.addEventListener('click', () => this.close());
this.mask.addEventListener('click', () => this.close());
this.confirmBtn.addEventListener('click', () => this.handleConfirm());
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.close();
});
}
open({ title, items, selectedIds = [], multiple = false, onConfirm }) {
this.state.items = items;
this.state.multiple = multiple;
this.state.selectedIds = new Set(selectedIds);
this.state.onConfirm = onConfirm;
if (this.titleEl) this.titleEl.textContent = title || '请选择';
this.render();
this.show();
}
render() {
this.body.innerHTML = '';
this.state.items.forEach(item => {
const div = document.createElement('div');
div.className = 'choice-item';
if (this.state.selectedIds.has(item.id)) {
div.classList.add('selected');
}
div.textContent = item.name;
div.addEventListener('click', () => this.toggleSelect(item.id));
this.body.appendChild(div);
});
}
toggleSelect(id) {
if (!this.state.multiple) {
this.state.selectedIds.clear();
this.state.selectedIds.add(id);
this.render();
} else {
if (this.state.selectedIds.has(id)) {
this.state.selectedIds.delete(id);
} else {
this.state.selectedIds.add(id);
}
this.render();
}
}
handleConfirm() {
const result = this.state.items.filter(item => this.state.selectedIds.has(item.id));
if (result.length === 0) {
alert('请先选择一项');
return;
}
this.close();
if (this.state.onConfirm) {
this.state.onConfirm(this.state.multiple ? result : result[0]);
}
}
show() {
this.state.visible = true;
document.body.classList.add('modal-open');
this.mask.classList.add('active');
this.panel.classList.add('active');
}
close() {
if (!this.state.visible) return;
this.state.visible = false;
document.body.classList.remove('modal-open');
this.mask.classList.remove('active');
this.panel.classList.remove('active');
}
}
用的时候:
javascript复制const userModal = new ChoiceModal({
maskId: 'mask',
panelId: 'panel'
});
document.getElementById('openModalBtn').addEventListener('click', function () {
userModal.open({
title: '选择处理人',
items: [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
],
selectedIds: [1],
multiple: false,
onConfirm: (user) => {
console.log('选中的用户是:', user.name);
}
});
});
这个类本身不到100行,但已经覆盖了打开、关闭、选项渲染、单选多选、结果回传、Esc关闭等核心能力。如果项目中已经引入了jQuery或者其它工具库,可以把它再封装成插件或者指令的形式,但底层的逻辑是一样的。
封装成类的好处是,弹窗相关的状态和DOM操作都收敛在实例内部,不会污染页面的全局变量,也方便在同一个页面上放多个互不干扰的选择弹窗实例。
我自己的经验是,这类工具类不用一开始就设计得特别庞大。先满足当前页面的需求,跑通之后再根据新需求逐步加参数。如果一上来就支持搜索、分页、异步加载、全选、多级联动,代码会急速膨胀,而大部分页面根本用不到那么多能力。选择弹窗的核心边界就是"把一列东西展示给用户,让用户挑出需要的,把结果带出来",围绕这个边界做加法才是最合理的方式。
