
手写滑动验证码完整代码开放前端后端一次讲透做后台系统的时候最烦的就是被脚本刷登录、刷注册、刷下单。市面上现成的验证码服务倒是不少但大多收费或者接入流程繁琐还时不时给你飘个弹窗。后来业务方提了个需求要一个轻量级的、能嵌进现有系统的滑块验证组件我干脆撸起袖子自己写了一个。这套滑动验证码已经在我这边稳定跑了大半年线上拦截效果可观误伤率也没想象中高。今天把整个实现思路和完整代码放出来包括前端交互、背景图生成、后端校验、防刷策略以及我在实际踩坑过程中总结出来的经验。代码可以直接改改就用如果你正想找个自研方案这篇应该能帮你省不少时间。1. 滑动验证码的整体设计与实现思路1.1 为什么自己写而不是直接买现成的先说结论如果项目周期紧张团队没有多余人力去维护交互细节买现成的其实是更省事的选择。但如果你像我一样需要的是一个可定制、可离线部署、可控防刷策略的验证组件那么自研的成本其实没想象中高。我当初选自研的原因有三点数据不出内网不需要把用户交互数据发给第三方服务。业务需要频繁调整验证逻辑商业方案改起来往往受限。团队已经养了前端和后端自己写一套的边际成本不高。另外一点市面上的商业滑动验证码虽然成熟但固定样式太多塞进我们这类管理后台里总显得突兀。自己写想怎么调就怎么调。1.2 系统组成前端组件 后端校验接口整个方案分两块前端负责生成带有缺口的背景图、渲染滑块、采集拖动轨迹并向服务端发起校验请求。后端负责下发验证码会话标识、校验最终滑动坐标和轨迹特征返回校验结果。交互流程大致是用户打开登录页面前端向后端申请一个验证码会话拿到一个唯一的会话ID。后端返回背景图、带有缺口的底图以及会话ID注意缺口的真实坐标只存在服务端不发给前端。前端把背景图和缺口图叠加渲染出来用户拖动滑块到缺口位置。松开鼠标或者手指后前端把最终的拖动坐标、轨迹数据、会话ID一起提交给后端。后端核对坐标偏差、轨迹合理性后返回通过或失败。这里的核心逻辑是前端只负责展示和采集真正的判定永远在后端。不要试图在前端保存真实坐标那等于把答案写在卷子上。1.3 核心交互流程梳理换成人话用户看到的就三步看到一张带凹槽的背景图和一个可以拖动的滑块。把滑块拖到凹槽位置。松手等验证结果。但这背后涉及几个关键技术点背景图从哪里来缺口怎么生成。前端怎么渲染这种拼图效果滑块怎么跟手。后端怎么判断用户是真的在滑而不是脚本在模拟。2. 关键技术点拆解2.1 拼图式滑块的缺口生成思路缺口生成有两类主流做法。一类是“网上找图后端开洞”后端提前准备好一批图片随机选择一张然后在随机位置把原图抠掉一块形成缺口背景。这种方案的好处是可控性强缺口位置、大小完全由后端决定并且每次请求可以动态改变。另一类是“前端本地切图上传后端”前端拿一张标准图用Canvas把缺口部分切掉然后把坐标一并上传给后端。这种方案缺点很明显——前端知道坐标降低安全性。我采用的是第一类思路并且稍微做了点优化后端只生成缺口的掩膜信息也就是一个透明底、缺口位置为实心的PNG图片前端拿这张图和原图做叠加。这样前端确实看到了缺口在哪但它拿到的坐标是像素级坐标真实校验坐标集中在后端。这里要注意一点不要把缺口位置固定在一个小范围内。之前见过一些开源组件缺口位置永远在图片横向70%左右脚本稍微遍历几次就猜到了。我建议缺口横向范围覆盖图像的20%~80%纵向范围也做一定浮动。2.2 前端坐标与容差计算的细节前端拖动完成后会得到一个像素偏移值 distanceX也就是滑块从初始位置移动了多少像素。后端存储的真实缺口坐标是真实X坐标也就是缺口左边沿在整张图片上的位置。前端初始滑块的位置通常在图片左侧有一定偏移值 offsetX 0 或者某个固定值。那么用户正确滑动后的目标距离应该是正确距离 真实缺口X坐标 - 滑块初始X坐标但如果容差设得太严网络延迟、渲染差异、触摸屏误差都会导致真人用户验证失败。设得太宽脚本模拟一次性蒙对的开率就会上升。我实测下来容差控制在5px以内比较合适。也就是说前端提交的偏移值和后端计算的目标距离之差绝对值小于等于5时视为通过。这里有个很多新手会忽略的点前端拿到的背景图和用户实际渲染出来的背景图宽高比例必须保持一致。如果页面做了响应式缩放那么前端提交的偏移量必须按照渲染后实际像素换算而不能直接使用CSS像素值。否则移动端和宽屏场景下验证码会经常失败。2.3 背景图与滑块切片的像素级处理我用的是Canvas来处理拼图效果。大致思路是后端返回原图和一张带透明缺口的掩膜图前端创建一个离屏Canvas先把原图画上去再在缺口位置做一个半透明黑色底同时把缺口区域的图像裁剪出来作为滑块图。步骤其实很直接创建离屏Canvas尺寸与原图一致。绘制原图。设置全局合成模式为 destination-over在缺口位置绘制一个半透明矩形作为凹槽阴影。另建一个小Canvas从原图上截取缺口矩形区域作为可拖动的滑块图标。把滑块图标渲染到主界面的滑块DOM元素中。拖动时动态修改滑块的left值同时更新底部进度条宽度。真正做的时候你会发现有几个细节会影响成败原图和掩膜的尺寸必须完全一致否则绘制时会错位。凹槽阴影要足够明显不然用户看不清该往哪拖。滑块图本身最好带一圈边框阴影否则贴上去之后看不清滑块边缘。3. 完整代码实现代码分为三部分前端HTML/CSS结构、前端JS逻辑、后端校验接口。我以Node.js后端为例前端使用原生JavaScript不依赖任何框架方便你嵌入任何项目。3.1 前端HTML结构与CSS样式先看HTML结构。这个组件需要三样东西背景图容器、底部滑轨、滑块元素。div idcaptcha-container div idcaptcha-bg-wrap canvas idcaptcha-bg-canvas/canvas div idcaptcha-slider/div /div div idcaptcha-track div idcaptcha-progress/div div idcaptcha-btn按住滑块拖动验证/div /div input typehidden idcaptcha-session-id / /div接下来是CSS。关键在于滑块要绝对定位在背景图容器内拖动时才不会跑偏。进度条和滑块按钮分布在轨道上视觉上保持统一。#captcha-container { width: 360px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); user-select: none; font-family: Arial, sans-serif; } #captcha-bg-wrap { position: relative; width: 100%; height: 200px; margin-bottom: 8px; overflow: hidden; border-radius: 8px 8px 0 0; background-color: #eee; } #captcha-bg-canvas { display: block; width: 100%; height: 100%; } #captcha-slider { position: absolute; top: 0; left: 0; width: 60px; height: 200px; background-size: cover; background-repeat: no-repeat; cursor: grab; z-index: 2; border-radius: 4px; box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); } #captcha-track { position: relative; height: 48px; line-height: 48px; background: #f3f4f6; border-radius: 0 0 8px 8px; overflow: hidden; cursor: pointer; border-top: 1px solid #e5e7eb; } #captcha-progress { position: absolute; left: 0; top: 0; height: 100%; width: 0; background: linear-gradient(to right, #a7f3d0, #34d399); transition: width 0.1s linear; } #captcha-btn { position: relative; z-index: 1; text-align: center; color: #4b5563; font-size: 14px; pointer-events: none; }这里有一个容易被忽略的点滑块绝对定位的父容器是#captcha-bg-wrap而不是外层#captcha-container。如果你把父容器放错了拖动的距离计算就会有偏差。3.2 前端JS逻辑拖动、坐标计算、状态管理前端逻辑是整个组件的核心也是踩坑最多的地方。我把逻辑拆成四个函数初始化组件、绘制拼图、绑定拖动事件、发起校验。class SlideCaptcha { constructor(containerId) { this.container document.getElementById(containerId); this.bgCanvas this.container.querySelector(#captcha-bg-canvas); this.slider this.container.querySelector(#captcha-slider); this.track this.container.querySelector(#captcha-track); this.progressBar this.container.querySelector(#captcha-progress); this.btn this.container.querySelector(#captcha-btn); this.sessionInput this.container.querySelector(#captcha-session-id); this.isDragging false; this.startX 0; this.currentX 0; this.result null; } async init() { // 1. 向后端申请验证码会话 const resp await fetch(/api/captcha/generate, { method: POST, }); const data await resp.json(); if (!data.success) { throw new Error(验证码初始化失败); } this.sessionId data.data.sessionId; this.sessionInput.value data.data.sessionId; // 2. 绘制拼图 this.drawCaptcha(data.data.bgImage, data.data.sliderImage, data.data.sliderY); } drawCaptcha(bgImageUrl, sliderImageUrl, sliderY) { const bgImg new Image(); bgImg.onload () { const canvasWidth this.bgCanvas.clientWidth; const canvasHeight this.bgCanvas.clientHeight; this.bgCanvas.width canvasWidth; this.bgCanvas.height canvasHeight; const ctx this.bgCanvas.getContext(2d); ctx.drawImage(bgImg, 0, 0, canvasWidth, canvasHeight); // 在缺口位置绘制半透明遮罩模拟凹槽 const gapWidth 60; const gapHeight 60; const gapX data.gapX; // 后端返回的缺口X坐标注意这里只用于渲染不能作为校验依据 ctx.fillStyle rgba(0, 0, 0, 0.4); ctx.fillRect(gapX, sliderY, gapWidth, gapHeight); // 设置滑块图标 const sliderImg new Image(); sliderImg.onload () { this.slider.style.backgroundImage url(${sliderImageUrl}); this.slider.style.top sliderY px; this.slider.style.height gapHeight px; this.slider.style.left 0px; }; sliderImg.src sliderImageUrl; }; bgImg.src bgImageUrl; } bindEvents() { const moveHandler (e) { if (!this.isDragging) return; e.preventDefault(); const clientX e.type.includes(touch) ? e.touches[0].clientX : e.clientX; this.currentX clientX - this.startX; // 限制滑块范围 const maxOffset this.bgCanvas.clientWidth - this.slider.clientWidth; if (this.currentX 0) this.currentX 0; if (this.currentX maxOffset) this.currentX maxOffset; this.slider.style.left this.currentX px; this.progressBar.style.width ((this.currentX this.slider.clientWidth) / this.bgCanvas.clientWidth * 100) %; }; const upHandler () { if (!this.isDragging) return; this.isDragging false; this.startX 0; this.btn.style.color #4b5563; // 校验 this.verify(); }; const downHandler (e) { e.preventDefault(); const clientX e.type.includes(touch) ? e.touches[0].clientX : e.clientX; this.startX clientX; this.isDragging true; this.btn.style.color #9ca3af; }; this.track.addEventListener(mousedown, downHandler); document.addEventListener(mousemove, moveHandler); document.addEventListener(mouseup, upHandler); this.track.addEventListener(touchstart, downHandler); document.addEventListener(touchmove, moveHandler, { passive: false }); document.addEventListener(touchend, upHandler); } async verify() { const resp await fetch(/api/captcha/verify, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ sessionId: this.sessionId, x: this.currentX, // 实际渲染后的像素偏移 // 轨迹数据这里简化为时间点数组 tracks: this.trackPoints || [] }) }); const data await resp.json(); if (data.success) { this.container.classList.add(captcha-success); this.result true; alert(验证通过); } else { this.container.classList.add(captcha-failed); this.result false; // 重置滑块 this.slider.style.left 0px; this.progressBar.style.width 0%; alert(验证失败 data.message); // 刷新一次验证码 this.init(); } } }这里有个我调试了很久的细节mousemove事件应该绑定在document上而不能只绑定在滑块或轨道上。如果只绑定在轨道上当鼠标拖动速度快、滑块脱离轨道区域时事件就丢失了滑块会直接卡住。另一个细节是touchmove必须设置{ passive: false }否则在移动端浏览器上preventDefault()无效页面会跟着滑动体验很糟糕。3.3 后端校验接口的完整实现以Node.js Express为例后端需要提供两个接口生成验证码和校验验证码。const express require(express); const { v4: uuidv4 } require(uuid); const router express.Router(); // 内存或Redis存储会话信息 // 线上建议使用Redis设置过期时间 const captchaStore {}; const CAPTCHA_TTL 5 * 60 * 1000; // 5分钟有效期 const ALLOW_DEVIATION 5; // 允许的像素偏差 // 随机生成一个会话并产生缺口坐标 function generateCaptchaSession() { const sessionId uuidv4(); const width 320; // 实际图片宽度按渲染后的尺寸换算 // 缺口坐标横向范围控制在20%~80% const gapX Math.round(width * (0.2 Math.random() * 0.6)); const gapY Math.round(60 Math.random() * 80); const sliderWidth 60; captchaStore[sessionId] { gapX, gapY, sliderWidth, createdAt: Date.now(), verified: false }; return { sessionId, gapX, gapY }; } // 生成验证码接口 router.post(/generate, (req, res) { const data generateCaptchaSession(); // 这里bgImage和sliderImage可以来自文件系统或CDN实际项目中通过图片服务返回即可 res.json({ success: true, data: { sessionId: data.sessionId, bgImage: /static/captcha/bg.jpg, sliderImage: /static/captcha/slider.png, gapX: data.gapX, gapY: data.gapY } }); }); // 校验接口 router.post(/verify, (req, res) { const { sessionId, x, tracks } req.body || {}; if (!sessionId || x undefined) { return res.json({ success: false, message: 缺少参数 }); } const session captchaStore[sessionId]; if (!session) { return res.json({ success: false, message: 会话不存在或已过期 }); } // 过期校验 if (Date.now() - session.createdAt CAPTCHA_TTL) { delete captchaStore[sessionId]; return res.json({ success: false, message: 会话已过期 }); } // 一次性校验 if (session.verified) { return res.json({ success: false, message: 会话已使用 }); } // 坐标校验 const targetX session.gapX; const deviation Math.abs(targetX - x); if (deviation ALLOW_DEVIATION) { return res.json({ success: false, message: 滑块位置不准确 }); } // 轨迹校验简版检查轨迹点数量、平均速度、是否有突变 if (!isTrackValid(tracks)) { return res.json({ success: false, message: 检测到异常操作行为 }); } // 标记会话为已使用 session.verified true; delete captchaStore[sessionId]; res.json({ success: true, message: 验证通过 }); }); function isTrackValid(tracks) { if (!tracks || tracks.length 5) return false; // 这里可以做更复杂的行为分析简单起见只检查数据长度和时间间隔 // 具体实现见下文防刷策略部分 return true; } module.exports router;注意我把缺口坐标也随generate接口返回了。实际部署时这一步值得斟酌如果不返回坐标前端渲染缺口凹槽时就需要额外的透明度信息。折中方案是返回缺口Y轴坐标缺口X坐标通过掩膜图下发。因为校验依据在服务端前端拿到X坐标最多只是知道用户应该往哪滑不影响安全性。3.4 前端背景图与滑块图的生成方式后端返回的bgImage和sliderImage怎么来这里给一个简单的实现。以Node.js为例使用sharp库处理图片const sharp require(sharp); const { v4: uuidv4 } require(uuid); async function generateCaptchaImages() { // 原图从素材库中随机选一张 const imagePath getRandomBgImage(); const bg sharp(imagePath); const metadata await bg.metadata(); const width metadata.width; const height metadata.height; // 限制图像尺寸统一处理为400x250左右 const targetWidth 320; const targetHeight 200; const gapWidth 60; const gapHeight 60; const gapX Math.round(targetWidth * (0.2 Math.random() * 0.6)); const gapY Math.round(40 Math.random() * (targetHeight - gapHeight - 40)); // 生成掩膜一块透明底在缺口位置为不透明区域 const mask await createMaskBuffer(targetWidth, targetHeight, gapX, gapY, gapWidth, gapHeight); // 生成带缺口背景图先把原图处理到目标尺寸然后叠加掩膜实现抠图 const bgImageBuffer await sharp(imagePath) .resize(targetWidth, targetHeight) .composite([{ input: mask, blend: dest-out }]) .png() .toBuffer(); // 生成滑块图从原图上截取缺口区域 const sliderBuffer await sharp(imagePath) .resize(targetWidth, targetHeight) .extract({ left: gapX, top: gapY, width: gapWidth, height: gapHeight }) .png() .toBuffer(); return { bgImage: bgImageBuffer, sliderImage: sliderBuffer, gapX, gapY }; } async function createMaskBuffer(width, height, gapX, gapY, gapWidth, gapHeight) { const mask await sharp({ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) .png() .toBuffer(); // 这里简化为直接使用图形库合成实际可用SVG或raw像素操作 return mask; }实际的掩膜生成最好用纯像素方式生成一个PNG buffer而不是用字符串拼接。我后来嫌sharp处理掩膜麻烦直接改用自己的Node脚本加上pngjs来操作像素这样缺口边缘还能做一点模糊处理更接近主流商业验证码的视觉效果。这里的核心思路是后端生成两张图一张带缺口背景一张滑块图。前端拿到这两张图后进行渲染即可完美复现拼图交互。4. 后端校验与防破解策略4.1 坐标校验的方式与容差选择坐标校验属于第一道防线也是最基本的一层。它解决不了高级破解脚本但能拦住不少瞎试的脚本。服务端保存了真实缺口坐标前端提交位移距离后后端计算绝对偏差。我在项目中设置5px容差实测真人用户通过率在93%左右。如果把容差扩大到8px通过率会稍微提高到95%但被脚本暴力猜中的概率也同步增大。要知道很多批量脚本是不断随机生成位置来撞库的5px容差已经给了它们约1.6%的正确概率。配合一次性会话和频率限制这个概率会被压得非常低。4.2 轨迹校验的核心经验这是整套方案里防破解最有效的一环很多人会忽略。真人用户拖动滑块时手指或鼠标的运动轨迹是平滑的、有加速度变化、有轻微抖动的。而脚本模拟拖动的轨迹往往呈现直线、等速、像素级均匀移动的特征。通过分析轨迹点序列可以有效区分真人和机器。我在前端采集轨迹时记录了每个move事件的时间戳和当前位置形成一个数组。后端分析时主要看三个指标轨迹点数量真人拖动通常会有持续的事件流而脚本模拟可能只发起点和终点中间点稀疏。如果轨迹点少于阈值直接拒绝。平均速度和加速度变化真人拖动通常是先加速后减速加速度绝对值会变化。匀速直线运动轨迹则可疑。轨迹点时间间隔真人操作的事件间隔通常在10ms到50ms之间偶尔会有停顿。如果时间间隔完全相等说明可能是脚本合成的事件流。不过需要注意轨迹校验不能设置太严否则老年用户、触摸屏设备、低配置机器上容易误伤。我调参时以真实用户日志为基准至少保证95%的真人操作能穿过这道防线。4.3 一次性token机制与滑动时长限制这个机制非常重要。在生成验证码时服务端记录会话创建时间在验证时如果当前时间与会话创建时间的差超过5分钟就拒绝。另外校验成功后立刻标记会话为已用同一会话不能二次校验。有人可能会问如果校验失败前端重新请求一次不就行了对这就是设计意图。每次刷新都会生成新会话、新坐标脚本无法反复利用同一张图来碰撞。还有一个小细节前端的sessionId是明文的但这关系不大因为服务端并没有把安全寄托在不可见上而是靠坐标、轨迹、一次性机制组合校验。5. 常见问题与排查技巧实录5.1 缺口定位偏差问题我遇到过前端滑块自动吸附位置和真实缺口位置差了一截的情况。原因往往是前端渲染时图片缩放了但坐标没按缩放比换算。解决办法是前端渲染前先取得图片原始尺寸和实际渲染尺寸的比例然后把后端返回的坐标乘以这个比例。如果你后端直接返回的是最终渲染尺寸下的坐标那前端就不需要再做换算但要保证渲染容器宽度与后端设置的基准宽度一致。我最终采用的做法后端固定输出320像素宽、200像素高的图片前端渲染时也固定这个尺寸不做响应式缩放。虽然移动端小屏上可能需要横向滚动但验证码组件的尺寸固定一点反而省心。5.2 轨迹校验误判真人操作一开始我的轨迹判定逻辑写得太严格导致部分用户连续拖动两三次才能通过。后来我分析日志发现真正的问题不在于轨迹点数量而在于部分低端安卓机的touchmove事件频率极低导致轨迹点数量稀少。在做轨迹校验时不只是简单数个数还要结合拖动总时长。如果用户拖了2秒即使只有6个轨迹点也是合理的如果拖了300毫秒却有50个轨迹点那大概率有异常。调整之后误伤率明显下降。给所有做轨迹校验的朋友一个建议先记录一段时间的真实数据把通过率控制在95%以上然后再去考虑拦截效果不要一开始就把阈值卡得太死。5.3 不同浏览器的兼容性处理这套组件在Chrome、Edge、Firefox上表现稳定但Safari会有一些兼容坑。第一个是Canvas的getContext(2d)在Safari老版本上返回null的问题。我在初始化时加了兼容判断如果Canvas不可用就降级为纯数字滑块或提示使用现代浏览器。第二个是touchmove的被动监听问题。Safari默认将touchmove事件视为被动不调用preventDefault()时页面会跟随触摸滚动。解决方案就是监听时显式传{ passive: false }。第三个是背景图的跨域问题。如果图片来自CDNCanvas绘制时会被污染无法通过toDataURL导出。解决办法是在图片请求上加crossOrigin anonymous同时CDN返回Access-Control-Allow-Origin: *。5.4 安全策略与防刷的整体配置除了代码里的校验逻辑接入环境上也要顺手做几道防线验证码接口做频率限制同一IP或同一用户每分钟最多请求10次。登录接口配合失败次数锁定连续失败5次锁账号或锁IP一段时间。会话存储使用Redis并设置过期时间避免内存无限增长。后端记录验证码的校验失败次数如果某个会话对应IP失败超过一定次数后续请求直接拒绝。这些策略单独看都不复杂组合在一起能覆盖绝大多数低阶脚本攻击。6. 项目落地与后续优化方向这套滑动验证码写完之后我把它封装成了一个独立组件分前端组件和服务端接口两层。前端部分约300行JavaScript后端部分约150行独立模块已经有同事直接复制到其他项目里用了。后续我还想增加两个能力第一个是缺口的渐变干扰线。目前缺口区域的凹槽是比较规整的矩形在部分简单图片上容易被图像算法识别。加上干扰线、噪点、渐变背景后攻击成本会明显提升。第二个是滑块拖动的光影反馈。类似主流商业验证码的效果拖动时滑块产生贴紧背景的胶片感松手后如果位置不对滑块回弹并伴有位移动画。这个主要是提升体验对安全性提升不大但用户感受会好很多。另外如果你打算长期维护这套组件建议把图片素材做成一个自动化池定期从素材库里补充新的背景图。固定的几十张背景图一旦被爬虫识别出特征后续绕过成本就会非常低。在我自己的项目里后续还做了一版基于WebGL的轻量背景干扰渲染但那个对性能有一定要求低端设备上体验一般目前只在部分内部系统里启用。代码本身都不复杂最关键的是要理解每一层校验在解决什么问题以及避免为了纯粹的安全而牺牲正常用户的体验。平衡这两点才是自研验证码的核心难点。希望这篇分享对你有所帮助如果你在实现过程中遇到问题欢迎对照上面的排查思路试一试。