纯HTML+JS手写滑块拼图验证码:从原理到实现
滑块拼图验证码这个交互前端圈子里几乎人人都见过但真正从零手写一个纯 HTML 纯 JS 版本的人并不多。大多数人第一反应是找现成的组件库或者直接调第三方验证服务。我最初也是这么想的直到有一次项目要求完全离线运行、不能引入任何外部依赖才被迫把这套东西从头撸了一遍。做完之后发现它的核心逻辑其实比想象中简单得多难点全在几个容易被忽略的细节上——缺口位置的随机策略、拖拽轨迹的平滑度、以及验证环节到底该放在前端还是后端。这篇内容适合两类人看一类是刚接触 Canvas 和 DOM 事件、想找一个完整练手项目的前端新手另一类是有一定经验、想搞清楚滑块验证码背后原理、以便自己定制或排查问题的开发者。我会从整体设计思路讲起把每一块的核心代码、参数选择的理由、以及我实际踩过的坑都摊开来说尽量做到你照着做就能跑起来。1. 整体设计与技术选型拆解1.1 为什么选纯 HTML 纯 JS 这条路先说说技术选型。市面上做滑块拼图验证码常见的有三种路子一是用现成的第三方验证服务接入简单但依赖网络、有调用成本、离线场景直接歇菜二是用某个 UI 组件库自带的验证码组件省事但定制性差样式和交互逻辑基本被锁死三是自己用 Canvas 画图、用原生 DOM 事件驱动从零实现。我选第三条路原因很实在。第一零依赖意味着整个验证码就是一个 HTML 文件加一段 JS扔到任何环境里都能跑打包体积几乎可以忽略。第二可控性拉满缺口形状、滑块样式、拖拽手感、验证逻辑全在自己手里产品经理临时要改个颜色或者加个提示文案几分钟就能搞定。第三教学价值高把这套东西写一遍Canvas 绘图、鼠标与触摸事件、坐标换算、动画帧这些知识点基本就串起来了。当然纯前端实现有一个绕不开的现实问题前端验证本质上是不安全的。所有逻辑都在浏览器里跑懂行的人打开开发者工具就能看到缺口坐标甚至能直接改 JS 绕过验证。所以我在设计之初就明确了一个原则——前端这套东西只负责“交互体验”和“初步拦截”真正的验证必须放到后端去做。这一点后面会专门展开讲这里先埋个伏笔。1.2 整体模块划分与数据流整个验证码拆下来其实是四个模块我用一张表把它们的关系理清楚模块职责关键技术点背景图绘制在 Canvas 上画出底图和拼图块Canvas 2D API、图片裁剪缺口生成随机生成缺口位置并绘制缺口形状随机数、路径绘制拖拽交互监听鼠标/触摸事件驱动滑块移动事件绑定、坐标换算结果判定比对滑块位移与缺口位置容差计算、状态管理数据流是这样的页面加载时JS 先准备好底图随机算出一个缺口坐标把底图画到主 Canvas 上同时把拼图块画到一个小 Canvas 上作为“滑块”。用户按住滑块拖动时实时更新拼图块的横向位移。松手那一刻拿拼图块的最终位移和缺口坐标做比对落在容差范围内就算通过。这里有个设计决策值得说一下拼图块的位移和缺口坐标我用的是同一套坐标系。也就是说缺口在底图上的 x 坐标是多少拼图块就需要移动多少像素。这样判定逻辑就退化成一次简单的减法不用做任何缩放换算。代价是底图尺寸必须固定不能随窗口自适应缩放——不过对于验证码这种固定尺寸的组件来说这完全不是问题。1.3 尺寸与容差参数的确定逻辑参数这块我调了好几轮最后定下来的值是这样的底图宽 320px、高 160px拼图块边长 42px缺口容差 ±5px。这些数字不是拍脑袋来的背后有考量。底图 320×160 是 2:1 的比例视觉上比较舒服而且在移动端也不会占太多屏幕。拼图块 42px 的边长大约是底图高度的四分之一多一点既能看清形状又不会大到让缺口太显眼、降低难度。容差 ±5px 是我实测下来的平衡点太小了用户手一抖就失败体验很差太大了随便拖拖就能过失去了验证意义。5px 在 320px 宽的图上大约是 1.5% 的误差范围人手操作基本能稳定命中。提示容差不要设成 0。哪怕用户拖得再准像素级对齐在实际操作中几乎不可能而且不同设备的指针精度也不一样。留 3 到 8px 的容差是行业惯例。2. 核心细节解析与实操要点2.1 Canvas 尺寸与坐标系的坑动手写代码之前有一个坑必须先讲清楚否则后面全是玄学问题Canvas 的 CSS 尺寸和绘图尺寸是两回事。canvas元素有两个尺寸概念。一个是 CSS 样式决定的显示尺寸比如你写stylewidth:320px另一个是width和height属性决定的绘图缓冲区尺寸。如果这两个不一致浏览器会把绘图缓冲区拉伸或压缩到显示尺寸导致图像模糊、坐标错位。我的做法是让两者严格相等HTML 里写canvas width320 height160CSS 里也写width:320px; height:160px。这样绘图坐标和屏幕坐标就是 1:1 的鼠标事件的offsetX可以直接拿来当绘图坐标用省掉一堆换算。如果你要做高清屏适配Retina 屏那就得用另一套方案把绘图缓冲区放大到设备像素比devicePixelRatio倍再用ctx.scale()缩放回来。不过对于验证码这种小尺寸组件我实测下来不放大也完全能接受为了代码简洁就没做这层处理。你要是追求极致清晰度可以自己加上。2.2 缺口形状的绘制与随机策略缺口形状我选的是最经典的拼图块造型——一个带凸起和凹陷的方块。绘制它需要用到 Canvas 的路径 API核心是arc()画圆弧、lineTo()画直线。具体画法是这样的从方块左上角开始顺时针走一圈在四条边中的某一条上“挖”一个半圆凸起或凹陷。为了简单我只在上边和下边做文章——上边画一个凸起下边画一个凹陷这样拼图块和缺口能严丝合缝地咬合。function drawPuzzlePath(ctx, x, y, size, r) { ctx.beginPath(); ctx.moveTo(x, y); // 上边中间凸起 ctx.lineTo(x size / 2 - r, y); ctx.arc(x size / 2, y, r, Math.PI, 0, true); ctx.lineTo(x size, y); // 右边 ctx.lineTo(x size, y size); // 下边中间凹陷 ctx.lineTo(x size / 2 r, y size); ctx.arc(x size / 2, y size, r, 0, Math.PI, false); ctx.lineTo(x, y size); // 左边 ctx.closePath(); }这段代码里r是凸起圆弧的半径我取的是 size 的四分之一左右视觉上比较协调。arc()的最后一个参数控制方向true是逆时针、false是顺时针方向搞反了凸起会变成凹陷形状就错了。我第一次写的时候就在这里卡了半天画出来的缺口像被狗啃过一样。随机策略上缺口位置不能太靠边否则拼图块会超出底图边界。我的约束是x 坐标在[size 10, width - size - 10]之间随机y 坐标在[10, height - size - 10]之间随机。留 10px 的边距是为了视觉上不贴边看起来更自然。注意随机数一定要用Math.random()配合取整别用固定值。我见过有人为了调试方便把缺口位置写死结果上线忘了改等于把答案直接告诉用户。2.3 拼图块的裁剪与独立绘制拼图块不能直接画在主 Canvas 上否则它就和底图融为一体了。正确做法是在主 Canvas 上画底图然后用clip()把拼图块路径裁出来把这块区域的图像“抠”出来画到另一个小 Canvas 上作为可拖动的滑块。这里有个细节抠出来的拼图块背景应该是透明的只保留图像本身。所以小 Canvas 在绘制前要先clearRect()清空再用drawImage()把主 Canvas 对应区域画过来。为了让拼图块有立体感我还会给它描一圈浅色边框视觉上更清晰。// 在主画布上裁剪并绘制拼图块到小画布 puzzleCtx.clearRect(0, 0, size, size); puzzleCtx.save(); drawPuzzlePath(puzzleCtx, 0, 0, size, r); puzzleCtx.clip(); puzzleCtx.drawImage(mainCanvas, gapX, gapY, size, size, 0, 0, size, size); puzzleCtx.restore();drawImage的九参数版本是关键前四个参数指定源图上的裁剪区域缺口位置和大小后四个参数指定目标画布上的绘制区域从 0,0 开始大小相同。这样就把底图上缺口那一小块“搬”到了滑块画布上。2.4 拖拽事件的绑定与坐标换算拖拽交互要同时兼容鼠标和触摸所以事件监听得绑两套。鼠标用mousedown、mousemove、mouseup触摸用touchstart、touchmove、touchend。为了代码不重复我把开始、移动、结束三个动作抽成独立函数两套事件都调同一批函数。坐标换算是个容易出错的地方。鼠标事件里e.clientX是相对视口的坐标得减去滑块元素的左边界才能得到相对滑块的位移。触摸事件里坐标藏在e.touches[0].clientX里结构不一样得单独取。function getClientX(e) { return e.touches ? e.touches[0].clientX : e.clientX; }拖动过程中要限制滑块不能往左拖出边界也不能超过底图右边界。我的处理是给位移加个 clampMath.max(0, Math.min(maxX, moveX))。maxX就是底图宽度减去拼图块宽度保证滑块拖到最右时拼图块刚好贴住底图右边缘。提示mousemove和touchmove里记得调e.preventDefault()否则移动端拖动时页面会跟着滚动体验极差。这个坑我在真机上测的时候才发现的桌面浏览器上完全看不出来。3. 完整实操流程与核心代码实现3.1 页面骨架与样式搭建先把 HTML 结构搭出来。整个组件就三层一个容器包住底图 Canvas、一个滑块轨道、一个可拖动的滑块按钮。div classcaptcha-wrap div classcanvas-box canvas idmainCanvas width320 height160/canvas canvas idpuzzleCanvas width42 height42/canvas /div div classslider-track div classslider-btn idsliderBtn拖我/div /div /div样式上.canvas-box用position: relative两个 Canvas 都绝对定位叠在一起拼图块 Canvas 的left值随拖动动态变化。滑块轨道给个浅灰背景和圆角滑块按钮做成圆形或者圆角矩形加个cursor: grab提示可拖动。这里有个视觉细节拼图块 Canvas 初始位置要放在缺口正上方吗不是的。初始时它应该藏在底图左侧边缘外或者干脆用visibility: hidden藏起来等用户开始拖动再显示。我选择的是初始left: 0但拼图块画布本身是透明的看起来就像底图左上角缺了一块用户拖动时它才跟着动。这样视觉上更连贯。3.2 底图与缺口的初始化绘制初始化函数负责三件事画底图、算缺口位置、画缺口形状。function init() { // 1. 画底图这里用渐变模拟实际可换成图片 const grad mainCtx.createLinearGradient(0, 0, 320, 160); grad.addColorStop(0, #4facfe); grad.addColorStop(1, #00f2fe); mainCtx.fillStyle grad; mainCtx.fillRect(0, 0, 320, 160); // 2. 随机缺口位置 gapX Math.floor(Math.random() * (320 - size - 20)) size 10; gapY Math.floor(Math.random() * (160 - size - 20)) 10; // 3. 在底图上画缺口半透明遮罩 mainCtx.save(); drawPuzzlePath(mainCtx, gapX, gapY, size, r); mainCtx.fillStyle rgba(0,0,0,0.45); mainCtx.fill(); mainCtx.restore(); // 4. 抠出拼图块 renderPuzzle(); }底图我用的是线性渐变实际项目里换成一张图片也完全一样把fillRect换成drawImage就行。缺口用半透明黑色填充视觉上就是底图上被挖掉一块的效果。gapX和gapY存到全局变量里后面判定要用。3.3 拖拽逻辑的完整实现拖拽是整套逻辑的核心我把它拆成三个函数onStart、onMove、onEnd。let isDragging false; let startX 0; let currentX 0; function onStart(e) { isDragging true; startX getClientX(e) - currentX; document.addEventListener(mousemove, onMove); document.addEventListener(mouseup, onEnd); document.addEventListener(touchmove, onMove, { passive: false }); document.addEventListener(touchend, onEnd); } function onMove(e) { if (!isDragging) return; e.preventDefault(); const moveX getClientX(e) - startX; const maxX 320 - size; currentX Math.max(0, Math.min(maxX, moveX)); puzzleCanvas.style.left currentX px; } function onEnd() { isDragging false; document.removeEventListener(mousemove, onMove); document.removeEventListener(mouseup, onEnd); document.removeEventListener(touchmove, onMove); document.removeEventListener(touchend, onEnd); checkResult(); }startX记录的是按下时指针相对滑块起点的偏移这样拖动时滑块不会“跳”到指针位置而是保持相对位移手感更自然。currentX是滑块当前的累计位移onMove里实时更新它并同步到 DOM 的left样式。事件绑定在document上而不是滑块上是为了防止用户拖太快、指针移出滑块范围导致事件丢失。这是拖拽类交互的通用做法新手很容易只在滑块上绑事件结果拖快了就断。3.4 验证判定与结果反馈松手后调checkResult()拿currentX和gapX比对。function checkResult() { const diff Math.abs(currentX - gapX); if (diff 5) { showTip(验证通过, success); // 这里应该把 currentX 发给后端做二次校验 } else { showTip(验证失败请重试, error); reset(); } }判定逻辑就一行减法加绝对值。通过后给个绿色提示失败给红色提示并重置滑块位置。重置时把currentX归零、拼图块left归零同时重新调init()换一张新图和新缺口防止用户记住答案反复试。注意失败后一定要重新生成缺口位置。我见过有的实现失败后只是把滑块弹回去缺口位置不变用户第二次拖到同一个位置就过了等于形同虚设。4. 常见问题与排查技巧实录4.1 拖拽卡顿与事件丢失排查拖拽不跟手、拖快了就断这是最高频的问题。原因通常有两个一是事件绑在了滑块元素上而不是document上指针移出滑块就收不到mousemove二是touchmove没加{ passive: false }导致preventDefault()失效移动端页面跟着滚动。排查方法很简单在onMove里打一行console.log看拖动时日志是否连续输出。如果拖出滑块范围就断那就是事件绑定位置的问题如果移动端日志正常但页面还在滚那就是passive的问题。现象可能原因解决方式拖快了滑块不动事件绑在滑块上改绑到 document移动端页面跟着滚touchmove 未阻止默认行为加 passive: false 并 preventDefault滑块位置偏移坐标未减去元素偏移用 offsetX 或减去 getBoundingClientRect松手后滑块回弹未保存 currentX用全局变量累计位移4.2 缺口位置与拼图块对不上的问题这个问题的表现是明明拖到缺口位置了判定却失败。根源通常是坐标系不一致。比如底图 Canvas 的 CSS 尺寸被拉伸了或者拼图块 Canvas 的left值和gapX用的不是同一套基准。我的排查套路是在checkResult里把currentX和gapX都打印出来看差值是不是稳定偏移一个固定值。如果是固定偏移那就是基准点没对齐检查拼图块 Canvas 的初始left是不是 0、底图有没有被 CSS 缩放。如果差值随机那可能是事件坐标取错了检查getClientX有没有正确处理触摸事件。4.3 前端验证的安全边界与后端配合前面埋的伏笔这里收一下。纯前端验证码只能防君子不防小人。用户打开控制台gapX变量一目了然甚至可以直接改checkResult让它永远返回通过。所以生产环境里前端这套东西的定位是“交互层”真正的验证必须后端做。合理的做法是前端拖动结束后把currentX或者拖动轨迹的采样点发给后端后端根据自己保存的缺口位置做比对。更严谨一点后端可以在生成验证码时把缺口位置存在 session 里前端根本拿不到正确答案只能提交自己的答案让后端判。这样即使有人改了前端 JS也过不了后端那一关。提示如果要做轨迹分析判断是不是真人拖动可以在onMove里按时间采样记录坐标点后端分析轨迹的加速度、抖动特征。不过这是进阶玩法基础版本用位移比对就够了。4.4 移动端适配的几个实操心得移动端上我踩过的坑比桌面端多得多。第一个是300ms点击延迟虽然现代浏览器基本没了但保险起见滑块按钮上可以加touch-action: none彻底禁用浏览器的默认手势。第二个是拖动时页面选中文字加个user-select: none就解决了。第三个是滑块按钮太小不好按我把可点击区域做到了 44px 见方符合移动端触控的最小尺寸建议。还有一个细节移动端拖动时手指往往会遮住拼图块用户看不见自己对没对准。我的处理是在滑块轨道上方加一条细的指示线实时显示当前位移对应的位置相当于给用户一个“瞄准辅助”。这个不是必须的但加上之后通过率明显提升。5. 可扩展方向与个人实践体会基础版本跑通之后其实还有很多可以往上加的东西。比如把底图换成从一组图片里随机选增加破解难度比如给拼图块加个旋转角度用户除了拖动还得转一下再比如记录拖动轨迹做行为分析。这些都属于锦上添花核心的位移比对逻辑是不变的。我自己在实际项目里用这套东西的时候最大的体会是别把前端验证码当成安全措施把它当成体验优化。它的价值在于用很低的成本拦住大部分无脑脚本同时给真实用户一个轻量的交互而不是真的指望它挡住有心人。想清楚这个定位很多设计取舍就顺了——比如容差可以放宽一点、失败重试可以给多几次、视觉反馈可以做得友好一点因为你的目标本来就不是“绝对安全”而是“别让正常用户难受”。另外一个小技巧调试阶段可以在页面上临时显示gapX的值方便你快速验证判定逻辑对不对。上线前记得删掉或者用一个开关控制。我吃过这个亏测试环境忘了关结果被同事截图发群里调侃了半天。代码这东西看十遍不如自己敲一遍。上面这些片段拼起来就是一个能跑的完整版本建议你直接复制到本地 HTML 文件里改改参数、调调样式感受一下每个变量对最终效果的影响。等你亲手把容差从 5 改成 50 再改回 5就真正理解它为什么是这个值了。