简介以HTML5技术实现的手机验证抽奖领券交互效果源码适合网页前端初学者、活动运营人员以及需要快速搭建营销抽奖页面的开发者参考。压缩包共29个文件包括8个JavaScript脚本、8个CSS样式表、7个PNG图片以及HTML页面、GIF动图、说明文档等整体约415KB结构精简便于直接部署或二次定制。源码包含手机号验证、抽奖触发、中奖提示等核心流程并针对移动端触控操作与屏幕适配做了处理可在此基础上扩展电商促销、会员活动等场景。目前已有118人学习下载适合作为理解HTML5表单交互、CSS动效与前端抽奖逻辑的入门案例。包内附README说明与源码注释可帮助开发者快速读懂验证规则和概率分配思路也便于替换文案与奖品图片后投入实际活动使用。1. 手机端H5抽奖领券一套能直接改的落地源码做门店活动或公众号拉新时最常被问到的需求就是“帮我做个手机抽奖页要能验证手机号抽中直接发券”。这套HTML5抽奖源码解决的就是这个场景九宫格抽奖动画、手机验证码校验、中奖后领券展示一条线串好拿到压缩包解压就能在本地跑起来。它不依赖任何框架原生HTML/CSS/JavaScript 实现改奖品、换配色、接真实短信接口都不需要动骨架。适合前端刚入门的人拿去练手也适合运营同学把活动页快速搭出Demo给领导看接私活的同学更可以直接拿它当交付底子改。下面按我实际拆包验证的顺序把文件结构、验证模块、抽奖引擎和踩过的坑逐个讲清楚。2. 拆开压缩包文件结构与三个核心模块的分工2.1 解压后先看清目录别急着双击 index.html这类源码包最常见的问题是“双击能跑一部署就挂”。先看目录确认资源引用方式是相对路径还是绝对路径。常规做法是下面这套结构├── index.html ├── css/ │ └── style.css ├── js/ │ ├── verify.js │ ├── lottery.js │ └── main.js ├── img/ │ ├── bg.png │ ├── gift-1.png │ └── gift-2.png └── README.mdindex.html 是入口css 目录管样式js 目录里 verify.js 负责手机验证逻辑、lottery.js 负责抽奖判定与动画、main.js 做初始化绑定。img 放活动素材。README.md 里通常会写作者预留的配置项但很多打包下载的资源 README 是空的所以下面几章我直接按源码逻辑给你拆。在浏览器打开页面之前先用编辑器全局搜一下src和href看有没有/img/xxx.png这类以斜杠开头的引用。本地双击打开时斜杠开头的路径会直接指向磁盘根目录图片全部裂掉。遇到这种情况把斜杠开头的引用改成相对路径img/xxx.png或者保持目录结构放到服务器根目录下访问。这是一个低成本但极其常见的部署坑。2.2 页面骨架viewport 与九宫格布局的适配写法手机端 H5 页面的第一道关口是 viewport。源码里 index.html 的 head 区域一般会有这么一段meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno /这段的作用是让页面宽度等于设备宽度缩放比例初始为 1。maximum-scale1.0和user-scalableno配合禁掉用户在抽奖过程中双指缩放或双击放大的操作。活动页里用户手抖放大页面导致按钮错位是运营反馈最多的问题之一所以这两个参数在这个场景里是有实际意义的不是为了抄而抄。九宫格布局用 Flex 和 Grid 都能做。这套源码里常见写法是 Grid因为九宫格天然是三行三列Grid 的写法更直接.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px; } .grid .cell { aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center; background: #f5f5f5; border-radius: 12px; }repeat(3, 1fr)表示三列等宽gap: 8px控制格子间距。关键在aspect-ratio: 1 / 1它让每个格子强制保持正方形避免不同屏幕宽度下格子被拉伸成长方形。较老的安卓 WebView 不支持aspect-ratio所以生产环境我更习惯用padding-bottom: 100%的经典 hack 方案兜底。在这个资源里如果看到的是aspect-ratio那说明它面向现代浏览器用在微信内置浏览器和 Chrome 上没毛病但如果你要兼容老旧设备自己替换成 padding 方案。九宫格一共九个格子中间那个通常不放奖品而是放“立即抽奖”按钮。布局上通过 Grid 的grid-area或者直接给中间格子单独设样式来实现。源码里最省事的做法是给第 5 个 cell 加一个独立类名.btn-cell把它当作按钮容器这个设计在后面讲抽奖逻辑时会反复提到。2.3 JS 模块划分verify、lottery、main 各管一段三个 JS 文件的划分逻辑非常清楚这也符合我对这类活动页的习惯验证归验证抽奖归抽奖页面初始化归初始化。职责拆开之后出问题能快速定位是验证环节还是抽奖环节。模块文件核心职责验证模块verify.js手机号格式校验、验证码生成/发送、倒计时控制抽奖模块lottery.js奖品权重配置、中奖判定、跑马灯动画、领券结果展示初始化模块main.js页面加载后绑定事件、串联验证与抽奖流程main.js 里通常只做两件事页面 DOM 加载完成后绑定按钮点击事件以及维护一个全局状态对象比如是否已通过验证、当前是否正在抽奖。状态对象是避免抽奖过程中用户重复点击的关键这个在避坑章节会展开。3. 手机验证模块验证码校验与倒计时交互的落地写法3.1 手机号合规校验HTML5 表单标签与正则双保险手机号输入框在源码里一般是input typetel ...。typetel是 HTML5 新增表单标签家族的一员它会在手机上弹出数字键盘而不是全键盘。相比typenumber它不会出现 iOS 上数字键盘没有小数点却非要显示小数点的问题。配合maxlength11限制输入长度这是移动端表单的标准组合。校验逻辑在 verify.js 里核心就是一个正则function validateMobile(mobile) { var reg /^1[3-9]\d{9}$/; return reg.test(mobile); }这个正则的含义是以 1 开头第二位是 3 到 9 之间的数字后面跟 9 位任意数字总共 11 位。^和$保证从头到尾完全匹配防止输入 13 位数字时只匹配前 11 位就放行。目前国内手机号段第二位基本覆盖 3-9用这个正则不会误伤正常号码。如果你要更严格可以维护一个号段列表但对活动页来说完全没有必要——正则能拦掉 90% 的误输入剩下的交给短信服务商去校验。3.2 验证码生成与发送本地模拟与真实接口的分界线源码里点击“获取验证码”后的实现是分两种的。纯 Demo 版本会在本地生成一个随机四位数并把验证码存在一个全局变量里接真实短信服务的版本会发一个 Ajax 请求到后端。先看 Demo 版本的代码function sendVerifyCode(mobile) { if (!validateMobile(mobile)) { showToast(请输入正确的手机号); return false; } // 本地生成四位验证码仅供演示 var code Math.floor(Math.random() * 9000) 1000; window.__debugCode code; startCountdown(60); showToast(验证码已发送请在手机上查看); return true; }Math.floor(Math.random() * 9000) 1000生成的是 1000 到 9999 之间的整数。window.__debugCode是调试用的全局变量方便你在浏览器控制台直接取到当前验证码。源码里大概率会有类似if (inputCode window.__debugCode)的比对语句这就是前端模拟验证的完整闭环。调试时通过F12在 Console 里输入window.__debugCode就能拿到验证码这是这套源码最方便的地方。但你要清醒地认识到这个设计只要上线就等于把验证码写在了前端任何人都能绕过。生产环境这段必须换成接口请求验证码比对必须在服务端完成前端只负责把用户输入的验证码发给后端让后端返回成功或失败。这个切换的接法我放在下面小节专门说因为这里涉及的不只是改一个 URL 的事。3.3 倒计时与按钮状态机setInterval 的清除时机发送验证码之后按钮要进入 60 秒倒计时这段时间内按钮置灰不可点。源码里的实现一般是这样的var countdownTimer null; function startCountdown(seconds) { var btn document.getElementById(sendCodeBtn); btn.disabled true; var left seconds; btn.textContent left s; countdownTimer setInterval(function () { left--; if (left 0) { clearInterval(countdownTimer); countdownTimer null; btn.disabled false; btn.textContent 获取验证码; return; } btn.textContent left s; }, 1000); }这里有几个细节值得注意。第一clearInterval不仅要在倒计时结束时调用还要在组件被销毁或页面切换时调用否则定时器会继续跑出现按钮文字变了但时间早就过了的诡异现象。第二btn.disabled true要放在发送验证码之前而不是之后因为用户在点击瞬间连点两次可能导致两个定时器同时启动倒计时速度直接加倍。防止连点需要在事件处理函数开头就检查并置位。更稳妥的方案是把倒计时截止时间存到localStorage页面从后台切回来时读取时间戳计算剩余秒数。这个方案能解决用户锁屏、切应用导致的倒计时不准问题具体的做法我在避坑章节展开因为这是移动端 H5 活动页最常见的隐性 bug 之一。3.4 对接真实短信接口时的替换方案真实短信服务阿里云、腾讯云、聚合数据等一般需要你把手机号、场景 ID、签名等参数 POST 给后端后端返回一个 token前端拿不到验证码本身。所以替换时sendVerifyCode里的随机数生成和startCountdown之间要插进一个 Ajaxfunction sendVerifyCode(mobile) { if (!validateMobile(mobile)) { showToast(请输入正确的手机号); return false; } fetch(/api/sms/send, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ mobile: mobile, scene: lottery_coupon }) }).then(function (res) { return res.json(); }).then(function (data) { if (data.code 0) { startCountdown(60); showToast(验证码已发送); } else { showToast(data.message); } }).catch(function () { showToast(网络异常请稍后重试); }); return true; }参数里scene用来标识业务场景防止同一个手机号在注册、登录、抽奖等不同场景共用同一套验证码逻辑导致串号。生产环境务必让后端加上时间戳和 nonce 做签名校验否则你的短信接口会被刷短信炸弹——攻击者循环请求这个接口每条短信都是一笔钱。这是血泪经验很多活动页上线第一天短信费就爆了原因就是接口裸奔没有频控。验证码比对环节也要改成请求后端fetch(/api/sms/verify, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ mobile: mobile, code: inputCode, scene: lottery_coupon }) })后端返回成功后你拿到的应该是一个短时效的抽奖凭证 token而不是isVerified true这样一个前端变量。关于为什么必须这么做避坑章第 4 条会专门讲。4. 抽奖动画与概率控制九宫格跑马灯的完整实现4.1 权重表驱动的中奖判定不要用 Math.random 直接取格子很多新手写抽奖直接var index Math.floor(Math.random() * 9)随机到哪个格子就中哪个奖品。这样做在演示页里能跑但活动方的成本完全不可控——如果某个格子是 iPhone你可能会在一小时内送出去十几台。正确做法是先定奖品再定格子奖品的中奖概率用权重表控制。源码 lottery.js 里通常是这样的var prizes [ { id: 0, name: 5元优惠券, weight: 15, coupon: coupon_5yuan }, { id: 1, name: 10元优惠券, weight: 5, coupon: coupon_10yuan }, { id: 2, name: 谢谢参与, weight: 60, coupon: }, { id: 3, name: 1元优惠券, weight: 20, coupon: coupon_1yuan } ]; function drawPrize() { var totalWeight 0; prizes.forEach(function (p) { totalWeight p.weight; }); var rand Math.random() * totalWeight; for (var i 0; i prizes.length; i) { rand - prizes[i].weight; if (rand 0) { return prizes[i]; } } return prizes[0]; }这里的判定原理是把所有权重加起来得到一个总权重Math.random() * totalWeight在 0 到总权重之间生成一个随机数然后用这个随机数依次减去每个奖品的权重。减到哪个奖品时随机数变负值就命中哪个奖品。举例来说4 个奖品权重分别是 15、5、60、20总权重 100随机数落在 0-15 之间就是 5 元券落在 15-20 之间就是 10 元券。权重的比例关系直接决定概率15/100 即 15% 的中奖率。注意这里的一个关键点prizes数组里前三个奖品后面都跟了coupon字段谢谢参与的 coupon 是空字符串。中奖结果通过coupon字段的有无来判断是否发券而不是通过prizes[i].name判断这样避免了文案改动导致逻辑失效的隐患。我建议你拿到源码后先确认抽奖判定读的是 ID 或 coupon 字段不要读名称字符串。4.2 九宫格与奖品映射每个格子里放的是哪个奖品上面定义了 4 个奖品但九宫格有 8 个可展示格子中间是按钮所以格子与奖品之间需要一个映射关系。常见做法是一个长度为 8 的数组按顺时针方向对应九宫格的外围 8 个格子var gridPrizeMap [0, 1, 2, 0, 3, 1, 2, 3]; // 分别对应外围8个格子index为格子序号 // 格子里只展示奖品名中奖判定以drawPrize()结果为准数组里存的是prizes数组的下标也就是每个格子展示哪个奖品。比如gridPrizeMap[0] 0表示第一个格子展示prizes[0]5元券gridPrizeMap[1] 1表示第二个格子展示 10 元券。这样设计的好处是调整格子摆放位置时只改这个映射数组不用动prizes定义和抽奖算法。但这里有一个容易被忽略的问题drawPrize()返回的奖品和格子并不一定对应。比如说抽奖结果落到 5 元券但 5 元券在展示上同时出现在第 1 格和第 4 格动画最终应该停在哪个格源码里一般有两种策略。第一种是取该奖品第一次出现的格子gridPrizeMap.indexOf(prizeIndex)。第二种是随机取一个包含该奖品的格子。后者看起来更自然因为用户不知道奖品藏在哪个格子里但实现时要注意如果该奖品只出现一次indexOf和随机取结果一样如果出现多次要用Math.floor(Math.random() * count)在候选位置中随机挑一个。无论用哪种策略动画的停格位置必须来自drawPrize()的结果而不是反过来先决定停在哪格再反推奖品。这个顺序一颠倒你的概率控制就失效了。4.3 跑马灯动画setTimeout 递归实现减速停格九宫格抽奖的动画核心是外围 8 个格子依次高亮看起来像是灯光在绕圈最后慢慢停在目标格子。源码里常见的实现是setInterval固定间隔轮播但固定间隔的体验很生硬——从头到尾一个速度说停就停。我拆包后看到很多版本都是这种实际演示效果非常“廉价”。更好的方案是用setTimeout递归在递归过程中动态调整下一次调用的延迟时间。下面这个写法可以直接替换源码里的动画逻辑var currentCell -1; var stepCount 0; var totalSteps 0; var targetIndex -1; var timer null; function startRolling(callback) { var baseSpeed 80; var minLaps 3; var cells document.querySelectorAll(.grid .cell); totalSteps minLaps * 8; stepCount 0; // 从当前格子之后继续走避免每次都从1号格开始 var startFrom currentCell; function step() { if (currentCell 0) { cells[currentCell].classList.remove(active); } currentCell (startFrom stepCount 1) % 8; cells[currentCell].classList.add(active); stepCount; if (stepCount totalSteps) { // 最后4步逐渐减速 var delay baseSpeed; if (stepCount totalSteps - 4) { delay baseSpeed (stepCount - (totalSteps - 4)) * 40; } timer setTimeout(step, delay); } else { // 停在目标格子目标格子由外部传入 stopRolling(callback); } } timer setTimeout(step, baseSpeed); } function stopRolling(callback) { if (currentCell 0) { var cells document.querySelectorAll(.grid .cell); cells[currentCell].classList.remove(active); } currentCell targetIndex; var activeCell document.querySelectorAll(.grid .cell)[currentCell]; activeCell.classList.add(active); if (typeof callback function) { callback(); } }这里有几个参数需要解释。baseSpeed 80是每步的基础间隔单位毫秒值越小转得越快。minLaps 3表示至少完整绕 3 圈避免动画太短失去悬念。totalSteps minLaps * 88 是外围格子数绕 3 圈就是 24 步。减速逻辑在最后 4 步生效步数越靠后delay越大视觉上就是灯越走越慢最终停住。这段代码里最值得注意的写法是var startFrom currentCell。它让本次动画从上次停留的格子继续走而不是每次重载页面都从第 1 格开始。如果代码里每次都将currentCell当成 0 处理就会出现每次抽奖都从同一个格子起步的情况话不多说但观感很假。调用入口在 main.js 里完整串起来是这样var isRunning false; function onDrawClick() { if (isRunning) return; if (!isUserVerified()) { showToast(请先完成手机验证); return; } isRunning true; var prize drawPrize(); targetIndex getCellIndexByPrize(prize.id); startRolling(function () { // 停格后弹出中奖结果 showResultModal(prize); isRunning false; }); }getCellIndexByPrize根据gridPrizeMap反查目标格子下标这个函数需要你自己在源码里确认是否有现成实现。如果没有按 4.2 节的策略自己补一个。isRunning是整个流程的互斥锁动画期间任何点击都被拦掉。这个锁的粒度必须覆盖到startRolling回调执行完而不是动画代码启动后就释放否则用户在转盘转动过程中连点按钮会触发第二次抽奖。4.4 中奖弹窗与领券展示券码从哪来抽奖停格后弹出的中奖弹窗一般包含奖品名称、优惠券面额和一个“领取到卡包”按钮。纯 Demo 版本里券码是前端随机生成的function generateCouponCode() { var chars ABCDEFGHJKLMNPQRSTUVWXYZ23456789; var code ; for (var i 0; i 12; i) { var idx Math.floor(Math.random() * chars.length); code chars.charAt(idx); if (i 3 || i 7) { code -; } } return code; }这个生成器刻意去掉了容易混淆的I、O、0、1生成类似AB3D-EF7K-LMNP格式的 12 位券码分为 3 组每组 4 位。i 3 || i 7时插入短横线目的是提高人工录入的可读性。在演示版里这个券码直接展示给用户点击“复制”写入剪贴板。但真实场景中券码必须由后端在确认中奖后返回前端展示的只是后端下发的结果。如果源码里只有前端生成券码的逻辑说明这份源码定位就是演示版上线前必须把券码生成挪到服务端。5. 避坑记录从“能跑”到“能上线”之间的五个坑5.1 现象倒计时刷新后立刻恢复验证码可以无限重发活动页在微信里被用户频繁切后台、锁屏再回来setInterval在后台会被浏览器节流甚至挂起。切回前台时倒计时还停留在切出时的数字上但实际已经过了很久。只要把页面刷新一下倒计时直接清零按钮恢复可点状态用户可以马上再发一次验证码。原因startCountdown里的left变量只存在于内存中页面刷新后重新执行脚本left被重置为 60。而短信服务商侧的频控是基于手机号的前端重置只影响界面显示不改变服务端的限制。但如果服务端没做频控这个 bug 就等于验证码无限发送。解决把倒计时改成“截止时间戳”模式。发送成功时把Date.now() 60000存到localStorage每次页面加载先读这个时间戳用剩余时间初始化倒计时function resumeCountdown() { var expireAt parseInt(localStorage.getItem(sms_expire_at) || 0, 10); var left Math.floor((expireAt - Date.now()) / 1000); if (left 0) { startCountdown(left); } else { localStorage.removeItem(sms_expire_at); } }从那以后我每次写活动页都强制走一遍这个逻辑再也没出现过倒计时重置的反馈。5.2 现象转盘停在“5元券”的格子发放的却是“谢谢参与”测试时随手一点转盘动画结束后高亮停在5元券的格子上但弹窗显示“谢谢参与”。反复测了几次发现停格和结果对不上不是偶发而是每次都对不上。原因格子高亮用的是currentCell的下标展示的奖品名来自gridPrizeMap而弹窗内容来自drawPrize()返回的prizes数组对象。两套数据在代码里没有任何交叉校验停格动画和抽奖判定各跑各的。解决停格回调里做一次一致性校验。目标格子确定后读取该格子的实际展示奖品与drawPrize()的判定结果对比。如果是我上面说的不同奖品多次出现的映射策略停格格子里展示的必须是判定奖品否则就是映射表配错了。我给这段加一个校验函数function validateCellWithResult(cellIndex, prizeId) { var cellPrizeId gridPrizeMap[cellIndex]; return cellPrizeId prizeId; }如果返回false优先检查gridPrizeMap是否漏了某个奖品或者奖品在数组里的下标被改动过。5.3 现象快速连点抽奖按钮一次活动发出多张券用户手速快在转盘动画还没结束时连续点了五六下按钮结果抽奖接口被请求了五六次等动画结束后弹出五六个中奖弹窗。原因onDrawClick里虽然有if (isRunning) return;但isRunning true的赋值位置在事件绑定之后、动画启动之前如果源码里把isRunning赋值写在了startRolling内部或者某个异步回调里赋值时机就会晚于第二次点击。解决把isRunning的置位提前到onDrawClick第一行并在动画期间同时把按钮disabledfunction onDrawClick() { var btn document.getElementById(drawBtn); if (isRunning) return; isRunning true; btn.disabled true; // 抽奖判定与动画... btn.disabled false; isRunning false; }按钮disabled和isRunning双保险能防住绝大部分连点场景。但要注意按钮恢复可点的时机必须在动画完全停止之后有的源码在startRolling调用后就立刻恢复了按钮这是错的。5.4 现象绕过手机验证直接调用抽奖函数也能抽在控制台执行onDrawClick()或者直接改verify.js里的isVerified变量绕过了验证流程直接抽奖。测试同学拿着这个操作路径来找你说“活动有漏洞”。原因Demo 源码把“是否已验证”存成了前端全局变量判断逻辑完全暴露在浏览器里。前端代码是明文任何校验都能被绕过。这不是源码的 bug是纯前端演示的天然边界。解决真实上线时手机验证必须由后端完成前端拿到的不是“验证通过”这个布尔值而是一个有时效的抽奖凭证。抽奖接口带着凭证请求后端校验通过才返回中奖结果。前端这份验证逻辑只作为交互引导不能作为安全边界。如果你是在给客户做演示 Demo建议在 README 里明确写明“前端验证仅用于演示”避免交付后产生争议。5.5 现象配了 20% 中奖率测试时连抽十次全中开发自测时设置了某奖项权重 20手动点了十次结果十次全中。测试群里开始讨论“这个抽奖是不是有 bug”。原因Math.random是独立随机事件20% 中奖率不保证十次里有两次中。短期样本内连续中奖的概率客观存在。测试反馈“概率不对”时先别怀疑权重算法用大样本验证后再改代码。解决把抽奖判定抽出来写一个自动化压力测试循环跑一万次统计各奖品命中次数看分布是否接近权重占比function simulateDraw(times) { var stat {}; for (var i 0; i times; i) { var prize drawPrize(); if (!stat[prize.name]) { stat[prize.name] 0; } stat[prize.name]; } console.log(stat); } simulateDraw(10000);一万次模拟后看控制台输出各奖品命中的占比和权重对得上说明算法没问题不用瞎改。这里顺带提一嘴如果客户要求“前三次必中之后概率降低”那你需要加一个保底计数器判断用户已抽次数在特定次数强制返回中奖奖品。这类业务规则不要写在drawPrize里单独做一个shouldForceWin的拦截逻辑更清晰。6. 让这套源码变成自己的项目配置外部化与真机验证的收尾技巧拿到这套 HTML5 抽奖源码后直接改prizes数组就能换奖品但这样做有一个隐患每次上线都要在源码里翻找配置。更省事的做法是把奖品配置、验证码倒计时时长、最低转圈数提取到一个独立的config.js页面加载时读取var CONFIG { smsCountdown: 60, minLaps: 3, baseSpeed: 80, prizes: [ { id: 0, name: 5元优惠券, weight: 15, coupon: coupon_5yuan }, { id: 1, name: 10元优惠券, weight: 5, coupon: coupon_10yuan }, { id: 2, name: 谢谢参与, weight: 60, coupon: }, { id: 3, name: 1元优惠券, weight: 20, coupon: coupon_1yuan } ], gridPrizeMap: [0, 1, 2, 0, 3, 1, 2, 3] };改活动时只动这份配置不碰逻辑代码。运营同学自己都能替换奖品名和权重开发同学接到需求时改一行字符串就完事血泪经验告诉我在活动页场景里业务变了十万次也不会改逻辑真正天天改的就是这三样奖品、概率、文案。分离出去以后测试范围也小回归一把就过。部署前做一次真机验证按这四步走基本不会翻车。第一用微信开发者工具的公众号网页调试或者直接用手机微信扫码打开页面确认九宫格布局在小屏上没有挤压变形。第二走一遍完整流程输入手机号、获取验证码、等待倒计时、抽奖、领券任何一个环节卡住优先看 Console 报错。第三切换到 3G/弱网环境确认fetch请求有超时处理不要出现白屏。第四在浏览器控制台执行simulateDraw(10000)确认抽奖概率分布与配置一致。验证码倒计时这个环节我在真实项目里吃过一次亏上线当天运营反馈“验证码按钮一直灰着”排查发现是后端返回的sms_expire_at用的是服务器时间前端用的是手机本地时间两个时间差了几分钟导致倒计时算出来是负数。从那以后我每次接短信服务都会强制确认时间基准统一用服务器时间戳做倒计时计算前端只负责展示剩余秒数不再自己取本地时间。希望帮到你这套源码如果拿去做演示和练手是完全够用的但上线前按上面 5.1 到 5.5 的坑逐条过一遍能省下不少不必要的返工。本文还有配套的精品资源点击获取