十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue滑动拼图验证码实现:Canvas绘制与拖动校验

Vue滑动拼图验证码实现:Canvas绘制与拖动校验 简介这份资源面向具备一定 Vue 与前端基础、希望深入理解验证码交互实现的开发者围绕滑动拼图验证码这一典型安全交互场景展开。内容从弹窗组件与元素定位入手逐步拆解元素拖动、canvas 绘图与基础校验逻辑涵盖背景图与滑块的绘制、鼠标事件监听、偏移量计算及验证反馈等关键环节适合想提升前端综合实战能力、研究人机校验方案的人群参考。资源包共 1 个文件为 1 份 pdf 文档压缩包约 86KB体量轻便便于快速查阅与本地留存。目前已有 4724 人学习下载说明该主题在前端学习社区中具有较高关注度。读者可从中获得一套相对完整的实现思路与代码拆解笔记理解 canvas 路径绘制、clip 与 fill 的差异、拖动范围限制以及验证成功与否的判定方式并借助 Vue 的响应式系统与生命周期钩子将各步骤封装为可复用组件为后续在登录、注册等场景中落地滑动验证提供参考。1. 从哔哩哔哩登录框说起滑动拼图验证码到底难在哪第一次在 B 站登录页看到那个缺口拼图时多数前端的第一反应是「不就是拖个滑块吗」真动手写才发现坑集中在三处一是缺口和滑块必须来自同一张图、同一套坐标否则拼上去永远对不齐二是 canvas 的clip和fill配合globalCompositeOperation才能抠出「被挖掉一块的背景」和「独立出来的滑块」三是鼠标快速甩动时mousemove会丢事件监听必须挂在document上而不是滑块元素本身。这篇就把这套 Vue 滑动拼图验证码从弹窗、canvas 绘制到拖动校验完整拆一遍代码可以直接抄进项目跑。适合已经会 Vue 基础语法、想搞懂 canvas 图形裁剪和鼠标事件边界处理的同学也适合想给登录页换掉传统字符验证码的团队参考。2. 弹窗容器选型与 canvas 双图层结构搭建2.1 为什么用 el-popover 而不是自己写弹窗验证码弹窗的本质是「相对登录按钮定位的浮层」它需要跟随触发元素、点击外部关闭、层级可控。自己基于 popper.js 写当然可以但要处理定位翻转、滚动重算、z-index冲突成本不低。项目里直接用了 Element UI 的el-popover把trigger设成manual用v-model手动控制显隐这样点击登录按钮时先校验表单、再决定要不要弹验证码逻辑更顺。el-popover popper-classslidingPictures refpopover triggermanual v-modelvisible div classsliding-pictures div classvimg canvas idsliderBlock/canvas canvas idcodeImg/canvas /div !-- 滑块轨道与按钮 -- /div /el-popovertriggermanual是关键参数它让弹窗不再响应 click/hover 自动开关完全交给visible数据控制。popper-class用来给浮层加自定义类名因为 popover 默认是插入到 body 下的scoped 样式打不进去必须靠这个类名做全局样式覆盖。2.2 两个 canvas 的分工背景层与滑块层拼图验证码需要两块画布一块画完整背景并在缺口处「挖洞」另一块只画被抠出来的滑块形状。它们共用同一张图片、同一套坐标才能保证拼合时严丝合缝。draw(mx 200, bx 20, y 50) { let mainDom document.querySelector(#codeImg); let bg mainDom.getContext(2d); let width mainDom.width; let height mainDom.height; let blockDom document.querySelector(#sliderBlock); let block blockDom.getContext(2d); // 重新赋值 height强制 canvas 清空重绘否则会叠加旧图形 blockDom.height height; mainDom.height height; let imgsrc require(../assets/images/back.jpg); let img document.createElement(img); img.style.objectFit scale-down; img.src imgsrc; img.onload function () { bg.drawImage(img, 0, 0, width, height); block.drawImage(img, 0, 0, width, height); }; let mainxy { x: mx, y: y, r: 9 }; let blockxy { x: bx, y: y, r: 9 }; this.drawBlock(bg, mainxy, fill); // 背景层填充出缺口 this.drawBlock(block, blockxy, clip); // 滑块层裁剪出滑块 }这里有个容易忽略的点blockDom.height height这行不是赋值那么简单给 canvas 的 width/height 重新赋值会清空整个画布。刷新验证码时如果不做这一步新旧图形会叠在一起。img.onload保证图片加载完成后再绘制否则drawImage拿到的是空图。mainxy和blockxy的y相同、x不同意味着缺口和滑块在同一水平线上只是横向错开——这正是拼图验证码的视觉基础。2.3 坐标参数与随机区间设计初始化时随机生成三个值缺口 x 坐标mx、滑块起始 x 坐标bx、纵向 y 坐标。区间设计直接决定难度和可玩性。参数含义取值范围说明mx缺口在背景图上的 x127 ~ 244太靠左滑块没移动空间太靠右超出画布bx滑块初始 x10 ~ 128必须小于 mx保证有拖动距离y缺口与滑块的纵向位置10 ~ 99画布高 170留出上下边距r圆弧半径9决定拼图凸起的大小w拼图边长40与 r 配合形成凹凸形状canvasInit() { const random (min, max) { return Math.floor(Math.random() * (max - min 1) min); }; let mx random(127, 244), bx random(10, 128), y random(10, 99); this.slider { mx, bx }; this.draw(mx, bx, y); }mx下限取 127 是因为滑块本身宽 40 加凸起太靠左会和滑块初始位置重叠上限 244 是画布宽 300 减去拼图宽度和边距的结果。bx上限 128 保证它始终小于mx下限 127 附近拖动距离为正。这套区间不是拍脑袋是让每次刷新都有足够拖动空间又不越界。3. drawBlock 拼图形状绘制arc 与 globalCompositeOperation 配合3.1 用 moveTo/arc/lineTo 描出带凹凸的拼图路径拼图形状不是矩形而是四条边中有一条带圆形凸起、一条带圆形凹陷。绘制思路是从左上角出发依次画上边带凸起圆弧、右边、下边带凹陷圆弧、左边最后闭合。drawBlock(ctx, xy { x: 254, y: 109, r: 9 }, type) { let x xy.x, y xy.y, r xy.r, w 40; let PI Math.PI; ctx.beginPath(); // 左边起点 ctx.moveTo(x, y); // 上边向外凸起的圆弧 ctx.arc(x (w 5) / 2, y, r, -PI, 0, true); ctx.lineTo(x w 5, y); // 右边直线 ctx.arc(x w 5, y w / 2, r, 1.5 * PI, 0.5 * PI, false); ctx.lineTo(x w 5, y w); // 下边向内凹陷的圆弧 ctx.arc(x (w 5) / 2, y w, r, 0, PI, false); ctx.lineTo(x, y w); // 左边向内凹陷的圆弧 ctx.arc(x, y w / 2, r, 0.5 * PI, 1.5 * PI, true); ctx.lineTo(x, y); // 描边与填充样式 ctx.lineWidth 1; ctx.fillStyle rgba(255, 255, 255, 0.5); ctx.strokeStyle rgba(255, 255, 255, 0.5); ctx.stroke(); ctx[type](); // type 为 fill 或 clip ctx.globalCompositeOperation xor; }理解这段的关键是arc的五个参数圆心 x、圆心 y、半径、起始角、结束角、是否逆时针。-PI到0逆时针画的是上半圆形成上边凸起0到PI顺时针画的是下半圆形成下边凹陷。moveTo定起点后每次arc或lineTo都会从当前点连到新位置最后lineTo(x, y)回到起点闭合路径。3.2 fill 与 clip 的差异一个挖洞一个取块同一个drawBlock函数传入fill和clip产生完全不同的效果这是整个验证码最巧妙的地方。ctx.fill()用当前路径填充颜色。在背景层调用时配合fillStyle的半透明白色会在缺口位置盖上一层半透明遮罩视觉上像「挖掉一块」。ctx.clip()把当前路径设为裁剪区域之后所有绘制只在这个区域内生效。在滑块层调用时先clip再drawImage就只保留拼图形状内的图像其余被裁掉。// 背景层先画完整图再 fill 出缺口遮罩 bg.drawImage(img, 0, 0, width, height); this.drawBlock(bg, mainxy, fill); // 滑块层先 clip 出形状再画图只留形状内部分 this.drawBlock(block, blockxy, clip); block.drawImage(img, 0, 0, width, height);注意执行顺序背景层是「先画图后 fill」滑块层是「先 clip 后画图」。顺序反了效果就完全不对。globalCompositeOperation xor放在最后用于处理重叠区域的合成避免边缘出现硬边。提示clip一旦设置后续所有绘制都被限制在裁剪区内直到调用ctx.restore()或重新beginPath。如果发现滑块画不出来先检查是不是 clip 区域设错了。3.3 常见绘制失败排查现象可能原因排查方向滑块全白或全透明clip 在 drawImage 之后调用调整为先 clip 再 drawImage缺口和滑块对不齐两层用了不同的 y 或 r检查 mainxy 与 blockxy 的 y、r 是否一致刷新后图形叠加没重置 canvas 的 height每次绘制前canvas.height height图片不显示img.onload 未触发就绘制确认绘制逻辑写在 onload 回调内边缘有锯齿未设置 lineWidth 或 stroke补上 stroke 并设 lineWidth4. 鼠标拖动与校验document 级事件监听与误差容错4.1 mousedown 记录起点mousemove 实时更新 left拖动逻辑本身不复杂按下时记录鼠标起点移动时算出位移量赋给滑块的left。真正的坑在于监听对象的选择。drag(e) { let dom e.target; let slider document.querySelector(#sliderBlock); const downCoordinate { x: e.x, y: e.y }; let checkx Number(this.slider.mx) - Number(this.slider.bx); let x 0; const move moveEV { x moveEV.x - downCoordinate.x; if (x 251 || x 0) return false; // 边界限制 dom.style.left x px; slider.style.left x px; }; const up () { document.removeEventListener(mousemove, move); document.removeEventListener(mouseup, up); dom.style.left ; // 校验逻辑见下节 }; document.addEventListener(mousemove, move); document.addEventListener(mouseup, up); }checkx mx - bx是滑块需要移动的正确距离。x是实际位移。边界x 251对应画布宽度减去滑块宽度防止拖出画布。dom.style.left 在松手后清空按钮的临时位移让它弹回原位而slider的 left 保留作为校验依据。4.2 为什么监听必须挂在 document 上如果mousemove挂在滑块元素上鼠标快速移动时指针会瞬间离开滑块区域事件不再触发滑块就跟丢了。这是拖动类交互的经典问题。把mousemove和mouseup挂到document上无论指针移到哪里都能捕获松手也能正确结束。代价是要在up里手动removeEventListener否则监听会累积。注意mousedown上加了.prevent修饰符阻止默认的文本选中行为否则拖动时会选中页面文字体验很差。4.3 误差容错与校验分支校验不是精确相等而是允许一定误差因为用户不可能拖到像素级精确。const up () { document.removeEventListener(mousemove, move); document.removeEventListener(mouseup, up); dom.style.left ; let max checkx - 5; let min checkx - 10; // 允许正负误差 if ((max x x min) || x checkx) { this.puzzle true; this.tips 验证成功; setTimeout(() { this.visible false; }, 500); } else { this.tips 验证失败请重试; this.puzzle false; this.canvasInit(); // 失败后刷新验证码 } };max checkx - 5、min checkx - 10构成一个 5 像素的容错窗口加上x checkx的精确命中分支。这个窗口偏左是因为用户拖动时习惯性会稍微拖过头再回拉偏左容错更符合手感。失败后调用canvasInit()重新随机生成缺口位置防止暴力试错。校验参数值作用maxcheckx - 5容错窗口上界mincheckx - 10容错窗口下界精确分支x checkx完全对齐直接通过失败动作canvasInit()重置缺口防试错4.4 与后端校验的衔接思路前端这套逻辑只能算「交互层校验」真正的安全校验必须在后端做。常见做法是前端把用户拖动的最终位移x和本次验证码的mx、bx一起发给后端后端比对差值是否在容错范围内并校验这次验证码是否已被使用过、是否在有效期内。前端校验只负责体验后端校验负责安全两者缺一不可。如果只做前端校验攻击者直接改puzzle变量就能绕过。5. 刷新、复位与可复用的验证码组件封装技巧5.1 用 watch 监听 visible 实现打开即初始化弹窗每次打开都应该是一张全新的验证码否则用户上次失败后重新打开还是旧图体验割裂。用watch监听visible是最省事的做法。watch: { visible(e) { if (e true) { this.canvasInit(); // 打开时重新生成 this.puzzle false; // 重置校验状态 } } }canvasInit内部会重新随机mx、bx、y并重绘puzzle重置为 false 保证状态干净。这样无论用户是首次打开还是失败后重开拿到的都是新题。5.2 刷新按钮与关闭按钮的事件绑定弹窗底部两个图标分别对应刷新和关闭绑定在operation区域。div classoperation span title关闭验证码 clickvisible false classel-icon-circle-close/span span title刷新验证码 clickcanvasInit classel-icon-refresh-left/span /div刷新直接调canvasInit关闭把visible置 false。这里没有额外逻辑但要注意刷新时如果用户已经拖动过滑块slider的 left 样式需要一并复位否则新图配旧位移会错位。稳妥做法是在canvasInit里同时重置滑块 DOM 的 left。5.3 抽成独立组件的接口设计当前代码把验证码逻辑和登录页混在一个组件里复用性差。抽成独立组件时对外暴露的接口建议这样设计接口类型说明visibleprop / v-model控制弹窗显隐successemit校验通过时触发父组件据此放行登录failemit校验失败时触发可用于埋点统计refresh方法父组件可主动刷新验证码父组件监听success事件后再执行真正的登录请求验证码组件本身不碰业务逻辑。这样同一个组件能用在登录、注册、改密等多个场景也方便后续把前端校验换成后端校验而不动业务代码。5.4 一个容易被忽略的细节canvas 尺寸与 CSS 尺寸分离canvas 有两套尺寸HTML 属性width/height决定绘图坐标系CSS 的width/height决定显示大小。项目里 CSS 设了width: inherit; height: inherit而绘图用的是mainDom.width和mainDom.height。如果两者不一致图形会被拉伸变形缺口和滑块虽然逻辑对齐视觉上却对不上。调试时如果发现「明明坐标对了但看起来没对齐」先检查这两套尺寸是否一致。常见做法是让 canvas 属性尺寸等于图片原始尺寸CSS 尺寸按容器缩放并在绘制时按比例换算坐标。本文还有配套的精品资源点击获取
返回列表