做后台管理系统这两年,Vue3 项目的登录页里基本都会挂一个滑动验证组件。这东西看着简单,不就是按住滑块拖到右边嘛,但真要在 Vue3 里自己写一个稳定、可复用、能对接后端校验的版本,坑其实不少。我最早也是直接找第三方库,后来发现定制需求一多就开始别扭,索性自己封装了一个,顺便把整个交互逻辑、安全边界、常见坑都整理了一遍。这篇文章就从零开始讲,怎么用 Vue3 组合式 API 手写一个滑动验证组件,适合正在做 Vue3 后台管理系统、登录模块,或者想深入了解组件封装思路的同学参考。
1. 动手前先想清楚:滑动验证到底在验证什么
1.1 防的不是人,是自动化脚本
很多人对滑动验证有误解,以为它像密码一样用来证明“身份”,其实它防的是机器,准确说是防自动化脚本。正常用户拖动滑块时,轨迹带有加速、减速、微小的抖动,这些特征很难被简单脚本模拟;而脚本通常只能做匀速直线运动,或者直接设置元素位置、调用原生点击事件。滑动验证组件通过记录拖动轨迹、总耗时、起点终点坐标差,结合服务端判断,把非人类操作挡在门外。
从产品角度看,滑动验证比图形验证码体验好。用户不需要眯着眼睛读扭曲字母,只需要拖一下,所以现在 Vue3 后台管理系统、注册页、短信发送前触发的校验,大多选择这种形式。
1.2 组件选型:自己写还是用第三方
市面上的方案不少,比如连验证码厂商提供的全套服务,还有各种开源的 Vue3 滑动验证组件。选择自己写,主要原因有这么几点:
- 第三方服务需要引入 SDK,登录页加载一堆外部脚本,首屏性能受影响;
- 某些项目部署在内网,根本没有外网资源可以加载;
- 开源组件功能固定,想改样式、改文案、调整校验逻辑,需要读源码甚至改源码,维护成本隐性变高;
- 还有安全性的考虑:验证逻辑如果完全开放在前端,不做后端校验,组件本身再好看也是摆设。
自己写组件,核心业务流程完全可控,代码量其实不大,也就一个 Vue 单文件组件的量级。只需要把交互逻辑、事件接口、后端对接协议设计好,后续维护起来非常轻松。
1.3 组件设计目标与接口边界
在写代码之前,我习惯先画一个接口边界图。滑动验证组件对外应该暴露什么能力?我梳理后基本集中在这几个方面:
| 能力 | 说明 |
|---|---|
| 展示背景图 | 传入图片 URL,组件内部负责渲染缺口和滑块 |
| 拖动校验 | 用户拖拽滑块到达目标位置后,判定成功或失败 |
| 事件通知 | 成功、失败、验证重置三种事件必须暴露给父组件 |
| 重置能力 | 验证失败或业务方需要重新校验时,可以主动重置 |
| 参数可配 | 尺寸、容差值、提示文案、背景图等通过 props 传入 |
这里有一个关键边界:组件内部只负责“前端交互判定”,不负责真正的安全性。前端判定结果只是用户体验层面,真正决定是否放行,必须在后端再做一次校验。后面我会专门讲这部分设计。
2. 核心原理拆解与开发环境准备
2.1 滑动验证的交互模型
我用一个生活化的类比来解释滑动验证的原理:你手里有一片拼图,底部有一个滑轨,滑轨上有个滑块。目标是把拼图滑到背景图上预留的缺口位置,两边对上了就算通过。
拆解成技术模型就是三件事:
- 在背景图上随机生成一个缺口位置,这个位置用 x 坐标表示;
- 用户拖动滑块,滑块水平移动的距离也映射成一个 x 坐标;
- 比较“滑块移动距离”和“缺口位置 x 坐标”的差值,如果差值在容差范围内,判定通过。
这里最容易踩的坑是坐标系不一致。背景图宽度和滑块轨道宽度可能不同,直接用像素距离做对比,一旦尺寸不匹配就会判定异常。所以在设计时,我直接让轨道宽度和背景图宽度保持一致,这样滑块移动多少像素,缺口也移动多少像素,省去了换算的麻烦。
2.2 开发环境与项目结构
我使用 Vite 搭建的 Vue3 项目,开启 script setup 语法。相关依赖如下:
{ "dependencies": { "vue": "^3.4.0" }, "devDependencies": { "vite": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0" } }项目的目录结构是这样:
src/ components/ SliderCaptcha/ SliderCaptcha.vue use-slider.ts把逻辑抽到use-slider.ts里,组件文件只管模板和样式。这样做的好处是,如果以后要在 React 或者原生 JS 里复用同一套滑动逻辑,只需要改视图层,逻辑层可以原封不动搬走。
2.3 Props 与事件设计
组件对外接收的参数,我定为这样:
interface SliderCaptchaProps { bgImage: string; // 背景图 URL width?: number; // 组件宽度,默认 320 height?: number; // 组件高度,默认 160 tolerance?: number; // 误差容忍度,默认 10 tips?: string; // 提示文案 }事件设计:
interface Emits { (e: 'success'): void; (e: 'fail'): void; (e: 'reset'): void; }success事件在用户松手且位置匹配时触发,fail在位置不匹配时触发,reset在组件自动或手动重置时触发。
这里我特意没有把success事件设计成携带前端判定结果,原因很简单:前端判定结果不可信。如果需要后端校验,应该由父组件在接收到success时主动调用后端接口,后端返回真正的校验结果后再决定是否放行。组件本身不关心后端逻辑,职责更纯粹。
3. 完整实现:一个可直接使用的滑动验证组件
3.1 模板结构与状态定义
先把模板写出来。组件分为三块区域:背景图区域、轨道区域、滑块手柄。
<template> <div class="slider-captcha" :style="{ width: width + 'px' }" :class="{ 'is-success': state === 'success' }" > <div class="slider-captcha__bg" :style="{ height: height + 'px' }"> <img :src="bgImage" :width="width" :height="height" @load="reset" /> <div class="slider-captcha__puzzle" :style="puzzleStyle" ></div> <div class="slider-captcha__placeholder" :style="placeholderStyle" ></div> </div> <div class="slider-captcha__track" ref="trackRef"> <div class="slider-captcha__track-fill" :style="trackFillStyle"></div> <div class="slider-captcha__handle" ref="handleRef" :style="handleStyle" @pointerdown="onPointerDown" ></div> <span class="slider-captcha__tip">{{ tips }}</span> </div> </div> </template>对应的状态定义:
import { ref, computed } from 'vue'; type CaptchaState = 'ready' | 'dragging' | 'success' | 'fail'; const state = ref<CaptchaState>('ready'); // 当前滑块移动的像素距离 const slideX = ref(0); // 背景图上缺口的目标 x 坐标 const targetX = ref(0); // 是否正在拖动 const isDragging = ref(false); // 按下瞬间的起始 clientX const startClientX = ref(0); // 拖动开始前已累计的 slideX const startSlideX = ref(0); const trackRef = ref<HTMLElement | null>(null); const handleRef = ref<HTMLElement | null>(null);3.2 拖动逻辑与坐标计算
拖动的核心是事件处理。我一直推荐用 Pointer Events 而不是 Mouse 事件加 Touch 事件,因为 Pointer Events 统一了鼠标、触摸和触控笔,一套代码通吃所有设备,不需要做事件兼容判断。
初始化时做几件事:
import { onMounted, onUnmounted } from 'vue'; onMounted(() => { reset(); }); onUnmounted(() => { releasePointerCapture(); }); function onPointerDown(e: PointerEvent) { if (state.value === 'success') return; e.preventDefault(); isDragging.value = true; state.value = 'dragging'; startClientX.value = e.clientX; startSlideX.value = slideX.value; // 设置指针捕获,保证拖动过程中鼠标移出组件也能继续收到事件 (e.target as HTMLElement).setPointerCapture(e.pointerId); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); } function onPointerMove(e: PointerEvent) { if (!isDragging.value) return; const diff = e.clientX - startClientX.value; slideX.value = Math.min( Math.max(startSlideX.value + diff, 0), getMaxDistance() ); } function getMaxDistance() { const track = trackRef.value; const handle = handleRef.value; if (!track || !handle) return 0; return track.offsetWidth - handle.offsetWidth; } function onPointerUp() { if (!isDragging.value) return; isDragging.value = false; window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); validate(); }为什么监听要挂在window上?这里有一个很多人忽视的问题:如果把pointermove挂在滑块手柄上,用户拖到一半鼠标稍微移出手柄,事件突然停了,滑块就卡住不动了,体验非常奇怪。挂到window上,加上setPointerCapture,鼠标在屏幕任何位置都能继续追踪。
拖动过程中,slideX被限制在 0 到getMaxDistance()之间,这个最大值等于轨道宽度减去滑块手柄宽度,防止滑块被拖出轨道。
3.3 拼图缺口与拼图块的绘制
这里有两个视觉元素:背景图上那个缺口(叫 placeholder),以及跟随滑块移动的拼图块(叫 puzzle)。
拼图块初始位置我直接放到了和缺口一样的 x 坐标,然后用transform: translateX(slideX)让它跟随滑块移动。这样视觉比较自然——用户拖动滑块时,拼图块从缺口位置开始,跟着滑动。
const puzzleStyle = computed(() => ({ left: targetX.value + 'px', transform: `translateX(${slideX.value}px)` })); const placeholderStyle = computed(() => ({ left: targetX.value + 'px' }));缺口位置的生成,我建议在背景图宽度的 20% 到 80% 之间随机取值,避免缺口太靠边导致视觉不协调:
function generateTargetX() { const max = getMaxDistance(); const min = Math.floor(max * 0.2); const maxValid = Math.floor(max * 0.8); return Math.floor(Math.random() * (maxValid - min + 1)) + min; }拼图块本身的形状,简单做法是矩形,本质上就是一块带边框阴影的 div。如果要更逼真的锯齿边缘,可以用 canvas 来画,这里先给一个简单版本,后面会给 canvas 的增强思路:
.slider-captcha__puzzle { position: absolute; top: 0; width: 50px; height: 100%; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(255, 255, 255, 0.8); box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); pointer-events: none; }需要注意的是,这个 div 要设置pointer-events: none,否则它会挡住底部的拖拽事件的触发。
puzzleStyle里的left用targetX,再用translateX叠加slideX,这实际上实现了“拼图块初始位于缺口上”的效果。图片加载完成后调用reset(),重新生成缺口位置。
3.4 校验判定与动画反馈
校验逻辑在松手时触发,比较滑块位移和缺口位置:
function validate() { const tolerance = props.tolerance ?? 8; if (Math.abs(slideX.value - targetX.value) <= tolerance) { state.value = 'success'; emit('success'); } else { state.value = 'fail'; emit('fail'); setTimeout(() => { reset(); }, 800); } } function reset() { state.value = 'ready'; slideX.value = 0; // 如果有图片再生成缺口 if (props.bgImage) { targetX.value = generateTargetX(); } emit('reset'); }容差值很关键。太小了用户怎么拖都对不上,体验极差;太大了就容易误判。参考大多数成熟组件的测试结果,50px 宽的滑块、300px 宽轨道,容差设置为 8px 到 12px 比较合适,既能保证明显的操作感,又不至于太苛刻。
滑块本身也要加上成功和失败的视觉反馈:
- 成功:滑块变成绿色,拼图块淡出,显示对勾,整个组件变成不可交互状态;
- 失败:滑块抖动一下,然后自动回弹到起点。
.slider-captcha.is-success .slider-captcha__handle { background: #52c41a; } .slider-captcha.is-success .slider-captcha__placeholder, .slider-captcha.is-success .slider-captcha__puzzle { opacity: 0; transition: opacity 0.3s; }失败抖动可以用 CSS 关键帧:
.slider-captcha.state--fail .slider-captcha__handle { animation: shake 0.4s; }把完整组件代码贴出来大概是这样的框架,约 200 行左右,已经可以直接接入项目使用。样式细节按自己项目的 UI 风格调整就行。
4. 安全边界与服务端配合
4.1 纯前端校验的局限
如果只靠前端判断“滑块位置是否接近目标点”,那这个验证码基本是装饰品。稍微懂点前端的人打开控制台,把滑块元素的transform改一下,或者直接操作组件内部的targetX,再模拟一个pointerup事件,就能绕过校验。
所以我们必须正视这一点:组件内部的所有状态,包括缺口位置、滑块位移、容差值,都运行在浏览器里,用户都能看到甚至修改。前端校验的作用只是提供体验层的互动反馈,真正的安全防线必须放在后端。
4.2 后端二次校验的通用方案
推荐的做法是:前端滑动成功后,把以下几个数据传给后端:
| 参数 | 说明 |
|---|---|
captchaId | 本次验证会话 ID,由后端在获取验证码时下发 |
x | 缺口位置 x 坐标(像素) |
slideX | 用户松手时滑块所在位置 x 坐标(像素) |
duration | 从按下到松手的总耗时(毫秒) |
trail | 拖动轨迹采样点数组,格式为[[x, y, t], ...] |
后端拿到这些参数后,先检查duration是否太短(正常人不可能 0.1 秒完成拖动),再检查trail的轨迹是否过于直线、是否包含物理上不可能的突变,最后校验x和slideX的差值。
核心校验逻辑可以用一行判断:
const passed = Math.abs(slideX - x) <= tolerance && duration >= 300;这里的tolerance由后端决定,比如 10px,前端和后端必须保持一致,否则会出现前端觉得成功、后端觉得失败的情况。
4.3 轨迹特征采集
拖动轨迹采集,是在pointermove回调里,把坐标和时间戳记录下来:
const trail = ref<number[][]>([]); function onPointerMove(e: PointerEvent) { if (!isDragging.value) return; trail.value.push([e.clientX, e.clientY, Date.now()]); // ...原有位移逻辑 }后端拿到轨迹后可以做一些简单的行为分析:
- 轨迹点总数是否合理(理论上拖动时间越长,采样点越多);
- 相邻采样点的时间间隔是否均匀(脚本模拟可能是均匀间隔,真人拖动会有随机间隔);
- 轨迹在水平方向是否有轻微上下波动(真人手抖,波动不可避免)。
把这些特征综合起来,做一个分数制判定,综合分超过阈值才放行。这套方案不需要大数据,后端用几百行代码就能实现,但已经能拦截掉绝大多数脚本攻击。
5. 常见问题与排查技巧实录
5.1 拖拽不跟手、滑块跳动
这个问题我最早做的时候也遇到过,现象是滑块拖到一半会突然“跳”回原点,或者位置感觉不对。
排查方向有两个:
第一,确认是否使用了e.preventDefault()阻止触摸默认行为。在触屏设备上,不阻止默认行为,浏览器会滚动页面或触发缩放,滑块自然表现异常。在pointerdown里必须执行e.preventDefault()。
第二,检查浮点数和舍入问题。clientX返回的是浮点数,直接用会累积误差,可以统一用Math.floor取整。
第三,确认pointermove监听的元素。如果监听在滑块上,滑块移动后,元素位置变化了,后续事件可能找不到目标,必须监听在window上,同时配合setPointerCapture。
5.2 在弹窗或懒加载场景下的尺寸问题
滑动验证组件常常出现在登录弹窗、安全验证弹窗里。弹窗的显示通常伴随动画,组件挂载时图片可能还在加载中。如果此时读取轨道宽度,可能得到 0 或者错误的数值,导致最大滑动距离计算错误。
我的解决方法是:
- 图片加载完成事件里重新调用
reset(); - 如果组件在弹窗里,弹窗动画结束前先不渲染组件,或者通过
v-show控制,保证组件挂载时轨道已有真实尺寸; - 用
requestAnimationFrame在挂载后的下一帧读取宽度,避免布局未完成时读到旧数据。
onMounted(() => { requestAnimationFrame(() => { targetX.value = generateTargetX(); }); });5.3 移动端兼容性
如果只在pointer事件层面处理,移动端大部分情况没问题。但还有一个容易忘记的样式:touch-action。
滑块手柄和轨道必须设置:
.slider-captcha__handle, .slider-captcha__track { touch-action: none; }不加这一句,触摸拖动滑块时,浏览器会默认接管手势,表现为页面跟随手指滚动,滑块的移动事件根本触发不了。加了touch-action: none之后,浏览器不再接管当前元素上的触摸手势,所有事件都交给 JS。
另外在 iOS 上,双击可能存在缩放问题,建议在viewpointmeta 标签里设置user-scalable=no或使用maximum-scale=1,但要注意这会影响整个页面的可用性,只在验证弹窗场景下使用,或者在组件外层动态添加类名来控制。
5.4 图片跨域与缓存问题
如果背景图来自 CDN 或者跨域接口,canvas 读取像素会报跨域错误。虽然这个组件没有直接用 canvas,但后续做锯齿拼图块时,用 canvas 加载跨域图片就一定需要后端配合Access-Control-Allow-Origin。
另外,验证码图片如果被浏览器缓存,用户每次刷新验证码拿到同一张图,就失去了随机性。解决方法是接口层面禁用缓存,或者给 URL 加时间戳参数:
const bgUrl = `/api/captcha/image?t=${Date.now()}`;如果后端接口允许,更建议直接返回 base64 字符串作为bgImage,彻底绕开缓存问题。
排查表格汇总如下:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 点击滑块无反应 | 未阻止默认行为 | pointerdown加e.preventDefault() |
| 滑块拖出去就断 | 监听挂在滑块上 | 改到window并setPointerCapture |
| 移动端页面跟着滚动 | 缺少touch-action | 轨道和手柄加touch-action: none |
| 滑块无法拖满 | 轨道宽度读取为 0 | 图片加载后再requestAnimationFrame重置 |
| 横坐标偏差很大 | 坐标系不一致 | 轨道宽度与图片宽度保持一致 |
| 拼图块挡住拖拽 | 拼图块未指定pointer-events | 拼图块和缺口区域加pointer-events: none |
6. 组件化的进一步优化
6.1 支持受控状态与 v-model
有些业务场景需要业务方主动重置验证码,比如用户点击“更换验证码”按钮,或者表单提交失败后重置。组件可以通过expose暴露reset方法:
defineExpose({ reset, getState: () => state.value });如果你的项目倾向于数据驱动,也可以扩展一个modelValue,让父组件通过v-model:status控制组件状态:
const props = defineProps<{ status?: CaptchaState; }>(); watch( () => props.status, (val) => { if (val === 'ready') reset(); } );6.2 主题定制与扩展
实际项目中,滑动验证组件需要和不同风格的后台管理系统融合。建议在样式层面预留 CSS 变量:
.slider-captcha { --captcha-primary: #409eff; --captcha-success: #52c41a; --captcha-danger: #f56c6c; }这样业务方可以在外层覆盖这些变量,不用改组件内部代码。
如果要扩展成带锯齿边缘的更复杂拼图块,可以在use-slider.ts之外多加一个use-canvas-captcha.ts,用 canvas 绘制不规则形状,因为 canvas 的clip、drawImage、globalCompositeOperation组合可以做出各种复杂效果。这个进阶版本代码量稍大,但思路和这个基础版完全一致,核心仍然是“目标位置与移动距离的匹配”。
对我个人来说,滑动验证这个组件写完之后,最大的收获不是那两百行代码,而是理解了“前端交互 + 后端安全”各司其职的设计思路。前端负责把交互做得顺滑自然,后端负责把安全把关落到实处。两者配合好了,才是真正可用的验证方案,也顺便避开了不少常规文档里根本不会提的坑。