滑块拼图验证码这个交互,前端圈子里几乎没人陌生——拖动滑块让缺口对齐,松手即验证。但很多人第一次动手实现时才发现,看似简单的“拖一拖、对一对”,背后涉及canvas绘图、随机缺口生成、鼠标与触摸事件统一、轨迹采集、防作弊校验等一连串细节。纯HTML加纯JavaScript实现一套可用的滑块拼图验证,不依赖任何第三方组件,既能作为登录页的人机校验手段,也是一次把前端事件模型、Canvas API、坐标换算练透的好机会。
这篇内容面向有一定HTML/CSS/JavaScript基础、想自己从零撸一套滑块拼图验证的前端开发者,也适合刚入行想找一个完整小项目练手的朋友。我会把整体设计思路、缺口随机生成算法、事件处理与坐标换算、轨迹采集与校验逻辑、常见坑与排查方法全部拆开讲清楚,代码可直接参考复现。文中所有示例均为模拟项目,不涉及任何真实业务系统。
1. 整体设计与技术选型拆解
1.1 为什么选纯Canvas加原生事件,而不是DOM拼图
实现滑块拼图,第一条岔路口就是“拼图用什么画”。常见方案有两种:一种是把背景图切成若干DOM元素,用CSS的background-position做偏移;另一种是用Canvas把背景图和缺口一次性绘制出来。我两种都写过,最后稳定用Canvas,原因很实在。
DOM方案的问题在于缺口形状。滑块拼图的缺口通常不是矩形,而是带凹凸的拼图形状,用CSS做这种不规则裁剪要么依赖clip-path写一堆坐标点,要么用多层遮罩叠加,代码冗长且不同浏览器渲染细节有差异。Canvas则天然支持路径绘制,缺口形状用几段贝塞尔曲线或圆弧就能描述,绘制和擦除都在一张画布上完成,视觉一致性有保障。
另一个原因是性能与可控性。Canvas绘制一次即可,拖动过程中只需要移动滑块和拼图块的位置,不需要反复触发DOM重排。对于验证码这种“一次加载、短暂交互”的组件,Canvas的开销完全可以接受,而且我们能精确控制每一个像素的绘制时机,方便后续做缺口位置校验。
原生事件方面,我坚持不引入任何手势库。滑块拖动的核心事件无非mousedown、mousemove、mouseup,移动端对应touchstart、touchmove、touchend,再补一套pointer events做统一也可以。自己写事件处理的好处是逻辑透明,轨迹采集的每一个采样点都在自己手里,后面做行为校验时不用去猜第三方库的内部状态。
提示:如果你的项目需要兼容非常老的浏览器,Canvas的兼容性要提前确认。现代浏览器对Canvas 2D的支持已经很完整,这块基本不用担心。
1.2 组件拆解:一张画布、一个滑块、一条轨迹
把整个组件拆开,其实就三块东西。第一块是画布区域,负责绘制背景图和缺口拼图块;第二块是滑块轨道与滑块按钮,负责接收用户拖动;第三块是隐藏的轨迹记录器,负责在拖动过程中采样坐标和时间戳。
画布的尺寸设计有个小技巧:不要直接用图片原始尺寸,而是固定一个逻辑尺寸,比如宽320、高160,然后通过CSS缩放适配容器。这样坐标换算简单,缺口位置的计算也基于固定逻辑坐标,不会因为不同屏幕尺寸导致校验逻辑分叉。图片加载后用drawImage按逻辑尺寸绘制,超出部分自然裁剪。
滑块轨道和画布同宽,滑块按钮宽度固定,比如40像素。滑块可移动的最大距离等于画布宽度减去滑块宽度,这个值在初始化时算好存起来,拖动时用它做边界约束。轨迹记录器则是一个数组,每次mousemove或touchmove时push一个包含x、y、timestamp的对象,松手时把整个数组交给校验函数。
这种拆法的好处是职责清晰:画布只管画,滑块只管拖,轨迹只管记。任何一块出问题,排查范围都很明确。我见过一些实现把所有逻辑塞进一个mousemove回调里,改一处牵动全身,维护成本很高。
1.3 校验策略:前端比对加后端复核的双层设计
滑块拼图的校验分两层。前端层负责“位置对不对”,即用户松手时滑块位移与缺口真实位置的距离差是否在容差范围内。后端层负责“行为像不像人”,即轨迹的采样点、时间间隔、加速度变化是否符合正常人的拖动特征。
前端比对很简单:缺口生成时记录一个目标偏移量targetX,用户拖动结束时拿到实际位移currentX,计算绝对差值,小于容差阈值(常见5到10像素)就判定位置通过。容差不能设太小,否则触屏用户手指抖动会导致误判;也不能太大,否则暴力枚举几次就能蒙对。我一般取8像素作为默认值,实际项目可根据滑块宽度微调。
后端复核是防作弊的关键。纯前端校验有个致命问题:攻击者可以直接读源码找到targetX,或者干脆绕过前端直接调接口。所以生产环境必须把轨迹数据传到后端,由后端判断轨迹是否自然。常见的判断维度包括:总耗时是否过短(比如小于300毫秒基本是脚本)、采样点是否过于均匀(真人拖动速度有快有慢)、是否存在明显的直线匀速运动(脚本特征)。这部分逻辑放在后端,前端只负责采集和上报。
注意:本文聚焦纯前端实现,后端校验只讲思路不展开代码。但你要清楚,只做前端比对的验证码在安全上是纸糊的,上线前务必补上服务端复核。
2. 缺口随机生成与画布绘制细节
2.1 缺口位置的随机范围怎么定才合理
缺口位置不能随便随机,否则会出现缺口贴着画布边缘、拼图块画不全的情况。我的做法是给缺口中心点划定一个安全区域:水平方向从画布宽度的30%到85%,垂直方向从画布高度的20%到80%。下限30%是为了给滑块留出足够的拖动距离,避免用户一拖就到位、失去验证意义;上限85%是防止缺口太靠右,拼图块绘制时超出画布被裁掉。
垂直方向的限制主要考虑视觉美观和拼图块完整性。缺口高度加上拼图块的凸起部分,不能超出画布上下边界。假设拼图块边长40像素、凸起半径8像素,那么缺口中心y坐标至少要大于48、小于画布高度减48。把这些约束写成常量,随机时用Math.random在区间内取值,再取整即可。
这里有个容易忽略的点:缺口位置每次刷新都应该重新随机,而不是页面加载时随机一次就固定。否则同一用户多次尝试时缺口不变,等于把答案送出去了。刷新时机可以绑定在验证失败、验证成功、用户主动点击刷新按钮三个场景。
2.2 拼图块形状的路径绘制方法
拼图块的经典形状是“方形加一个圆形凸起”。绘制时先画一个正方形路径,然后在某一条边上叠加一个圆弧凸起。以右侧凸起为例,路径顺序是:从左上角开始,画上边到右上角,画右边到凸起起点,画圆弧到凸起终点,继续画右边到下边,画下边到左下角,闭合路径。
圆弧的绘制用ctx.arc,圆心在边的中点向外偏移一个半径的距离,起始角和结束角根据凸起方向确定。凸起半径一般取拼图块边长的20%左右,太大显得突兀,太小视觉上不明显。我实测下来,边长40像素配半径8像素的比例比较协调。
绘制拼图块时要注意,背景图上的缺口和滑块上的拼图块必须用同一套路径参数,否则会出现“缺口形状和拼图块对不上”的尴尬。我的做法是把路径绘制封装成一个函数,接收画布上下文、起点坐标、边长、凸起方向四个参数,缺口和拼图块都调这个函数,保证形状完全一致。
2.3 背景图加载与绘制的时序问题
背景图是异步加载的,而画布绘制依赖图片就绪。如果时序处理不好,会出现画布空白或者缺口画在错误位置的问题。标准做法是创建一个Image对象,设置onload回调,在回调里执行绘制逻辑,同时把图片的naturalWidth和naturalHeight读出来做等比缩放计算。
等比缩放的计算值得展开说。假设逻辑画布是320乘160,图片原始尺寸是800乘600,直接drawImage会拉伸变形。正确做法是计算图片宽高比与画布宽高比的差异,取较大的缩放系数,让图片铺满画布并居中裁剪。具体来说,先算scaleX等于画布宽除以图片宽、scaleY等于画布高除以图片高,取两者较大值作为统一缩放系数,然后计算绘制起点偏移,用drawImage的九参数版本完成裁剪绘制。
图片加载失败也要有兜底。onerror回调里可以切换到一张本地生成的渐变背景,保证组件不至于白屏。虽然验证码场景下图片一般由后端下发、失败概率低,但兜底逻辑是工程习惯,写上不亏。
2.4 缺口“挖空”效果的两种实现
缺口在视觉上要呈现“被挖掉一块”的效果,常见两种做法。第一种是绘制背景图后,用globalCompositeOperation设为destination-out,再用拼图路径绘制一次,把该区域擦成透明,露出画布底层的颜色。第二种是直接用半透明黑色填充拼图路径,模拟阴影缺口。
第一种效果更真实,但要求画布底层有背景色,否则透明区域会露出页面背景,视觉上不统一。我的处理是给画布容器设一个浅灰底色,擦除后自然呈现浅灰缺口。第二种实现简单,但视觉上像“贴了一块半透明补丁”,不如第一种自然。
两种方案我都用过,最终倾向第一种,因为它在深色和浅色背景图上表现都稳定。实现时注意destination-out会擦除该区域所有已绘制内容,所以擦除操作必须放在背景图绘制之后、其他装饰绘制之前,顺序错了会把不该擦的东西也擦掉。
3. 事件处理与坐标换算的实操要点
3.1 鼠标与触摸事件的统一封装
桌面端用鼠标、移动端用触摸,两套事件如果各写一份逻辑,代码会翻倍且容易不同步。我的做法是写一个统一的事件绑定函数,内部根据是否支持touch事件决定绑定哪一套,但对外暴露的回调签名一致,都是接收一个包含clientX、clientY的对象。
具体实现上,mousedown和touchstart都映射到onStart,mousemove和touchmove映射到onMove,mouseup和touchend映射到onEnd。触摸事件要从event.touches[0]里取坐标,鼠标事件直接从event取。有个细节:touchmove默认会触发页面滚动,必须在回调里调用preventDefault阻止,否则拖动滑块时页面跟着滚,体验很差。对应的CSS也要加touch-action: none,双保险。
pointer events是更现代的统一方案,一套pointerdown、pointermove、pointerup就能覆盖鼠标、触摸、触控笔。如果你的目标浏览器支持良好,我推荐直接用pointer events,代码更干净。但要注意pointer events在部分旧版移动浏览器上有兼容问题,做兼容判断时可以用window.PointerEvent是否存在来决定走哪套。
3.2 坐标换算:从页面坐标到画布逻辑坐标
这是最容易出错的地方。用户拖动时拿到的是页面坐标(clientX),而画布内部用的是逻辑坐标,两者之间隔着画布的CSS尺寸与逻辑尺寸的比例关系。换算公式是:逻辑坐标等于页面坐标减去画布左上角的页面坐标,再乘以逻辑尺寸与CSS尺寸的比值。
画布左上角的页面坐标用getBoundingClientRect获取,这个API返回的是相对视口的位置,滚动页面后值会变,所以每次拖动开始时重新获取一次,不要缓存。比值计算则是逻辑宽度除以rect.width,高度同理。如果画布CSS尺寸和逻辑尺寸一致(比如都是320乘160),比值就是1,换算退化为简单减法,但代码里还是保留通用公式,方便后续调整尺寸。
滑块位移的计算同理。滑块按钮的初始左偏移是0,拖动时用当前指针的逻辑x坐标减去按下时的逻辑x坐标,得到位移量,再约束在0到最大可移动距离之间。最大可移动距离等于画布逻辑宽度减去滑块宽度,这个值在初始化时算好。
3.3 拖动过程中的边界约束与视觉反馈
边界约束有两层。第一层是位移量不能小于0、不能大于最大值,超出时直接钳制到边界值。第二层是拼图块的位置要跟随滑块实时更新,让用户看到“拼图块跟着滑块走”的视觉效果。
视觉反馈方面,滑块按钮在按下时可以加一个按下态样式(比如阴影变化),拖动时拼图块绘制在画布上对应位置。拼图块的绘制时机是每次onMove回调里,先清空画布重绘背景和缺口,再把拼图块画到当前位移对应的位置。这里有个性能细节:每次move都重绘整张画布,在低端设备上可能有轻微卡顿。优化思路是把背景和缺口预先绘制到一张离屏canvas,move时只做一次drawImage加拼图块绘制,减少重复绘制开销。
我实测过,320乘160的画布每次全量重绘在现代设备上完全无感,离屏优化属于锦上添花。但如果你的画布尺寸更大或者要支持低端机型,离屏canvas值得加上。
3.4 松手判定与结果反馈的时机控制
松手时要做三件事:停止轨迹记录、计算位移差值、给出结果反馈。顺序不能乱,先停记录再算差值,避免把松手瞬间的坐标也算进轨迹。
结果反馈分成功和失败两种。成功时滑块变绿、拼图块吸附到缺口位置、显示成功提示;失败时滑块变红、拼图块回弹到起点、显示失败提示并允许重试。回弹动画用CSS transition做,给滑块按钮的left属性加0.3秒过渡即可,不需要写requestAnimationFrame。
有个体验细节:失败后不要立刻刷新缺口,而是等用户点击“重试”或自动延时一秒再刷新。立刻刷新会让用户来不及看清自己错在哪,体验突兀。成功后的处理则取决于业务,通常是拿到一个临时凭证去调登录接口。
4. 轨迹采集与防作弊校验的落地方法
4.1 轨迹采样点的数据结构设计
轨迹数据我设计成一个对象数组,每个对象包含四个字段:x是相对起点的水平位移,y是相对起点的垂直位移,t是相对拖动开始的时间戳(毫秒)。用相对值而不是绝对页面坐标,是为了后端校验时不受设备分辨率和页面布局影响。
采样时机是每次onMove回调触发时push一条记录。注意mousemove的触发频率在不同浏览器和设备上差异很大,有的设备每秒触发60次,有的可能只有30次。这个差异本身也是行为特征的一部分,不需要人为节流或补点。但要在拖动开始时记录一个起始时间戳,每条记录的t用当前时间减去起始时间。
数组长度不需要限制,一次正常拖动通常产生几十到一百多条记录,数据量很小。松手时把整个数组序列化,随验证请求一起提交。如果担心数据被篡改,可以在前端做一次简单哈希,但真正的防篡改要靠后端校验,前端哈希只是增加一点成本。
4.2 真人轨迹与脚本轨迹的特征差异
后端判断轨迹是否自然,核心是找“机器不像人”的地方。我总结了几条实践中比较有效的特征。第一是总耗时,真人拖动320像素宽的滑块,正常耗时在500毫秒到3秒之间,低于300毫秒基本可以判定为脚本。第二是速度变化,真人拖动是先加速后减速,中间有细微的抖动和停顿,脚本往往是匀速直线运动。
第三是采样点的y轴波动。真人手指或鼠标拖动时,y坐标会有几个像素的自然抖动,完全水平的直线反而是异常。第四是时间间隔的分布,真人操作的时间间隔不均匀,脚本如果用了固定间隔的定时器,间隔会高度一致。
这些特征单独看都可能误判,组合起来判断准确率就高了。比如“耗时过短且匀速且y轴无波动”三个条件同时满足,基本可以拦截大部分简单脚本。具体阈值需要根据实际用户数据调,没有放之四海皆准的数字。
4.3 前端可做的轻量校验与降级策略
纯前端能做的校验有限,但也不是完全没有。位置比对是基础,此外可以做一次“轨迹点数下限”检查:如果轨迹数组长度小于5,说明用户几乎是瞬间拖到位,大概率是脚本,直接判失败。还可以检查是否存在负位移或超界位移,正常拖动不会出现这些异常值。
降级策略指的是验证码加载失败或校验接口异常时的处理。我的做法是:图片加载失败时降级为“点击式”简单验证(比如点击指定文字),校验接口超时时允许用户重试而不是直接放行。直接放行是安全大忌,宁可让用户多试一次,也不能在异常时敞开大门。
提示:前端校验的所有逻辑都在用户可控范围内,任何前端判断都只能作为“提高攻击成本”的手段,不能作为安全边界。真正的安全边界必须在服务端。
4.4 刷新与重试机制的状态管理
组件需要维护几个状态:当前缺口目标位置、当前是否正在拖动、当前验证结果、剩余重试次数。用一个简单的状态对象管理,每次状态变化时更新UI。
刷新逻辑要区分“主动刷新”和“失败后刷新”。主动刷新是用户点击刷新按钮,直接重新生成缺口并重置滑块;失败后刷新是验证失败后自动触发,可以加一个短暂延时让用户看到失败反馈。重试次数建议限制在3到5次,超过后锁定一段时间或要求走其他验证方式,防止暴力枚举。
状态管理最容易出的bug是“拖动中途触发刷新”。比如用户在拖动时点了刷新按钮,或者失败回调在拖动未结束时触发,会导致滑块位置和缺口位置错乱。解决办法是在刷新函数入口检查isDragging状态,如果正在拖动则忽略本次刷新,或者强制结束拖动后再刷新。
5. 常见问题排查与避坑经验实录
5.1 拖动时页面跟着滚动或选中文字
这是移动端和桌面端都常见的问题。移动端的原因是touchmove默认行为会滚动页面,桌面端的原因是拖动时触发了文本选中。解决办法分两步:CSS上给画布和滑块容器加touch-action: none和user-select: none;JS上在touchmove回调里调用preventDefault。
有个细节要注意:preventDefault必须加在非 passive 的监听器上。现代浏览器对touchmove默认使用passive监听,回调里调preventDefault会失效并报警告。绑定事件时显式传{ passive: false }即可解决。
5.2 高DPI屏幕下画布模糊
Canvas在视网膜屏上如果不做处理,绘制内容会被放大导致模糊。解决办法是根据window.devicePixelRatio调整画布的物理像素尺寸:canvas.width设为逻辑宽度乘以dpr,canvas.height同理,然后用ctx.scale(dpr, dpr)把绘图坐标系缩放回逻辑尺寸。这样绘制代码不用改,视觉上清晰度提升明显。
注意调整物理尺寸后,CSS尺寸要保持逻辑尺寸不变,否则画布会被拉伸。这个处理在初始化时做一次即可,不需要每次重绘都做。
5.3 缺口位置与拼图块对不上的排查思路
这个问题的根源通常是坐标基准不一致。排查时按这个顺序检查:第一,缺口生成时用的坐标系和拼图块绘制时用的坐标系是否一致,有没有一个用了页面坐标一个用了逻辑坐标;第二,拼图块的绘制起点是否等于滑块位移加上某个固定偏移,这个固定偏移有没有算错;第三,画布的CSS尺寸和逻辑尺寸比例是否在坐标换算时正确应用。
我踩过的一个坑是:拼图块绘制时用了滑块的CSS像素位移,但画布是逻辑坐标,两者比例不是1时拼图块位置就偏了。修正方法是在绘制前把滑块位移换算成逻辑坐标,或者干脆全程用逻辑坐标计算位移。
5.4 验证成功率忽高忽低的调参经验
容差阈值是影响成功率最直接的参数。设太小,触屏用户手指粗、抖动大,误判率高;设太大,安全性下降。我的经验是先用8像素起步,观察一周的实际数据,如果失败率明显偏高且集中在移动端,可以放宽到10像素;如果发现有人用脚本轻易通过,再收紧。
另一个影响成功率的因素是缺口位置的随机范围。如果缺口经常出现在很靠左的位置,用户拖动距离短,容差相对占比大,容易通过;反之靠右时拖动距离长,同样的容差相对更严格。所以随机范围要均衡,避免缺口集中在某一侧。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 画布空白 | 图片未加载完成就绘制 | 把绘制逻辑放进Image的onload回调 |
| 拖动时页面滚动 | touchmove未阻止默认行为 | 加touch-action: none并在回调preventDefault |
| 画布模糊 | 未处理devicePixelRatio | 按dpr放大物理尺寸并scale坐标系 |
| 拼图块位置偏移 | 坐标基准不一致 | 统一用逻辑坐标计算位移 |
| 验证总是失败 | 容差过小或目标位置算错 | 打印targetX与currentX对比,调容差 |
| 失败后无法重试 | 状态未重置 | 刷新函数里重置滑块位置和拖动状态 |
| 移动端拖不动 | 事件绑定遗漏touch系列 | 补touchstart/touchmove/touchend |
这张表是我自己排查时整理的,实际遇到问题时按现象对号入座,能省不少时间。表格之外再补一条:如果所有现象都正常但就是验证不通过,先检查后端下发的图片和前端记录的targetX是否来自同一次请求,多请求并发时容易串数据。
6. 组件封装与复用的一些实践建议
6.1 把验证码封装成可配置的构造函数
一次性页面里写死逻辑没问题,但如果多个页面都要用,封装成可复用的组件更划算。我的做法是写一个构造函数,接收一个配置对象,包含容器选择器、画布宽高、拼图块边长、容差阈值、图片地址或图片获取函数、验证成功与失败的回调。
构造函数内部完成初始化:创建canvas元素、绑定事件、加载首张图片、生成缺口。对外暴露refresh方法用于手动刷新,暴露destroy方法用于销毁时解绑事件、清理定时器。这样调用方只需要new一个实例,传配置和回调,其余细节全部封装。
配置项里图片获取函数设计成可传入,是为了适配不同后端接口。有的项目图片由接口返回base64,有的返回URL,封装层不关心来源,只要求传入一个返回Promise的函数,内部统一处理。
6.2 样式与逻辑分离的注意点
样式全部通过CSS类控制,JS只负责切换类名,不直接改style属性(除了滑块位移这种必须动态计算的)。这样换肤或调整视觉时只改CSS,不动JS。滑块的成功态、失败态、按下态都定义成独立的类,JS里用classList切换。
画布的尺寸相关样式要注意,CSS里设置的宽高必须和JS里读取的rect一致,否则坐标换算会出错。建议把画布的逻辑尺寸作为配置项,CSS宽高通过内联样式或CSS变量同步设置,避免两处硬编码不一致。
6.3 无障碍与键盘操作的补充考虑
滑块拼图对键盘用户和视障用户不友好,这是这类验证码的通病。如果项目有无障碍要求,可以补充键盘操作:滑块按钮设tabindex,监听方向键调整位移,回车键提交验证。同时在画布上提供文字描述,说明缺口的大致方位。
完全的无障碍支持很难做到,因为拼图验证本质上依赖视觉。折中方案是提供“切换验证方式”的入口,让无法完成拼图验证的用户走其他验证路径。这个入口在合规要求较高的项目里通常是必需的。
6.4 性能与资源加载的优化点
图片资源建议压缩到合适尺寸再下发,逻辑画布320乘160,图片宽度控制在640左右足够,再大就是浪费带宽。图片格式优先用WebP,兼容性不足时降级JPEG。如果一次验证需要多张候选图,可以预加载下一张,减少用户等待。
事件监听器在destroy时务必解绑,尤其是绑定在document上的mousemove和mouseup(拖动时为了处理鼠标移出画布的情况,通常会把move和up绑在document上)。不解绑会导致组件销毁后仍然响应事件,造成内存泄漏和逻辑错乱。
我在实际项目里还遇到过一个资源相关的问题:同一页面存在多个验证码实例时,图片缓存和canvas上下文要各自独立,不能共用全局变量。封装成构造函数天然解决了这个问题,每个实例有自己的闭包作用域。
最后分享一个调试小技巧:开发阶段在画布上把targetX对应的缺口位置用一条竖线标出来,方便肉眼核对拼图块是否对齐。上线前把这条调试线去掉即可。这个技巧帮我快速定位过好几次坐标换算的偏差,比反复打印日志直观得多。