说在前面

今天我们纯前端来实现一个「曲线滑块验证 」组件:页面上有一条白色主曲线 和一条目标背景曲线 ,用户拖动底部滑块,让白曲线的弯曲程度与目标曲线重合 ,重合且拖动轨迹的操作过程「像个人」就算通过。
实现关键点:
- 如何用三段三次贝塞尔曲线造出一条曲线;
- 滑块进度如何实时驱动 SVG path 重绘;
- 如何加入干扰曲线防止「看答案抄作业」;
- 如何用拖动轨迹采样做一个简单的人机行为打分;
- 失败后的回弹动画和无障碍细节怎么写。
一、先搞清楚我们要做什么
总结一下整个组件的运转逻辑:
- 页面加载时,随机生成一个
target值(0~1 之间),它决定目标曲线弯多深; - 用户拖动滑块,滑块进度
progress(也是 0~1)决定白色主曲线弯多深; - 松手时比较两者差距
|progress - target|:偏差 ≤ 2.6% 且行为打分达标 → 通过;否则抖动提示并回弹重来。
对应代码里的状态对象:
javascript
const state = {
progress: 0, // 当前滑块进度(0~1)
target: 0.54, // 目标曲线的弯曲参数
distractors: [0.22, 0.82], // 两条干扰曲线的参数
dragging: false, // 是否正在拖动
verified: false, // 是否已通过
startedAt: 0, // 开始拖动的时间戳
samples: [], // 拖动轨迹采样点
animationFrame: 0, // 回弹动画的 rAF 句柄
};
所有状态收在一个 state 里,后续的绘制、校验、重置全都围绕它转,改起来不会满地找变量。
二、贝塞尔曲线绘制
整条曲线的灵魂在这个 makeCurve 函数里。它接收一个 0~1 的 value,返回一段 SVG path 的 d 字符串:
javascript
const left = { x: 62, y: 66 }; // 左端点(固定)
const right = { x: 596, y: 30 }; // 右端点(固定)
function makeCurve(value) {
const bottomY = 215 + value * 156; // 谷底深度:value 越大垂得越低
const centerX = 325 + (value - 0.5) * 48; // 谷底随 value 轻微左右偏移
const leftDrop = 118 + value * 92;
const rightDrop = 118 + value * 120;
const leftPull = 120 + value * 44;
const rightPull = 126 + value * 42;
return [
`M ${left.x} ${left.y}`,
`C ${left.x + 24} ${left.y + leftDrop}, ${centerX - leftPull} ${bottomY}, ${centerX} ${bottomY}`,
`C ${centerX + rightPull} ${bottomY}, ${right.x - 45} ${right.y + rightDrop}, ${right.x} ${right.y}`,
].join(" ");
}
两个端点钉死不动,中间用**两段 C 命令(三次贝塞尔曲线)**拼出一个先下坠、再上扬的「微笑曲线」。value 就是那个「弯腰程度」的旋钮------value 越大,bottomY 越大,谷底垂得越低,整条曲线弯得越夸张。

目标曲线和主曲线用的是同一个函数 ,只是一个传 state.target、一个传 state.progress:
javascript
const targetPath = makeCurve(state.target); // 背景里的目标曲线
const progressPath = makeCurve(state.progress); // 用户控制的白色曲线
所以说,「匹配成功」这件事本质上就是------让两个 value 相等,两条曲线自然重合。妙不妙?
三、干扰曲线,别让答案太明显
如果画面上只有两条曲线,用户一眼就能对齐,脚本更是一行搞定。于是我们可以加入两条干扰曲线浑水摸鱼:
javascript
function makeDistractors(target) {
return [
clamp(target - (0.16 + Math.random() * 0.12), 0.08, 0.9), // 比目标浅一点
clamp(target + (0.16 + Math.random() * 0.12), 0.08, 0.9), // 比目标深一点
];
}
干扰曲线的形状由 makeDistractorCurve 单独生成,起点、终点、弯曲位置都和主曲线错开,再配上半透明的暖色、冷色描边:
| 曲线 | 颜色/样式 | 作用 |
|---|---|---|
| 目标曲线 ×3 层 | 深色投影 + 金色 + 青色描边 | 用户要对齐的「标准答案」 |
| 主曲线 ×3 层 | 阴影 + 白色主体 + 高光 | 用户拖动的曲线 |
| 干扰曲线 ×2 组 | 暖橙 / 青蓝半透明 | 干扰视线,增加破解成本 |
效果说明:目标曲线用「深色投影 + 金 + 青」三层叠出霓虹感,干扰曲线则半透明退居二线------视觉上主次分明,但形状上足够混淆,脚本想靠截图识别答案就得多费点劲。
四、滑块进度,实时驱动曲线重绘
拖动滑块时,指针位置先换算成 0~1 的进度:
javascript
function setProgressFromPointer(event) {
const rect = slider.getBoundingClientRect();
const handleOffset = sliderThumb.offsetWidth / 2;
const usableWidth = Math.max(1, rect.width - sliderThumb.offsetWidth);
const raw = (event.clientX - rect.left - handleOffset) / usableWidth;
setProgress(raw); // clamp 到 0~1 后触发 draw()
}
然后 draw() 函数一口气把所有 SVG 元素全部重绘:
javascript
function draw() {
const targetPath = makeCurve(state.target);
const progressPath = makeCurve(state.progress);
// 目标三层、干扰两组、主曲线三层,全部更新 d 属性
targetDark.setAttribute("d", targetPath);
targetGold.setAttribute("d", targetPath);
targetCyan.setAttribute("d", targetPath);
// ......(干扰曲线与主曲线同理)
mainCurve.setAttribute("d", progressPath);
// 滑块手柄位置同步
const thumbX = handleOffset + state.progress * usableWidth;
slider.style.setProperty("--thumb-x", `${thumbX}px`);
sliderThumb.setAttribute("aria-valuenow", String(progressPercent));
}
这里有个细节:滑块手柄的定位没有用 transform 硬算,而是写入 CSS 变量 --thumb-x,样式侧 left: var(--thumb-x) 直接消费。JS 管数据,CSS 管表现,各司其职。
效果说明:每次 pointermove 都重算 path 并更新,贝塞尔曲线本身只是几个数值的变化,性能开销极小,拖动跟手不掉帧。
五、拖动轨迹校验
位置对上了还不算完,还得过拖动轨迹这一关:
javascript
function scoreHumanDrag() {
if (state.samples.length < 8) return 0; // 样本太少,直接判脚本
const duration = performance.now() - state.startedAt;
const distance = Math.abs(state.progress - state.samples[0].progress);
let directionChanges = 0;
let lastVelocity = 0;
for (let index = 1; index < state.samples.length; index += 1) {
const previous = state.samples[index - 1];
const current = state.samples[index];
const velocity = (current.progress - previous.progress)
/ Math.max(1, current.time - previous.time);
if (index > 1 && Math.abs(velocity - lastVelocity) > 0.00035) {
directionChanges += 1; // 速度有波动,像人手的微调
}
lastVelocity = velocity;
}
const durationScore = clamp(duration / 520, 0, 1); // 拖得太快 → 低分
const distanceScore = clamp(distance / 0.32, 0, 1); // 拖得太短 → 低分
const motionScore = clamp(directionChanges / 4, 0, 1); // 毫无速度波动 → 低分
return durationScore * 0.45 + distanceScore * 0.35 + motionScore * 0.2;
}

效果说明:脚本拖动往往「瞬移 + 匀速 + 零抖动」,三个维度一打加权分,机器人当场现形。
采样点在拖动过程中持续收集,最多保留最近 54 个:
javascript
function moveDrag(event) {
if (!state.dragging || state.verified) return;
setProgressFromPointer(event);
state.samples.push({ progress: state.progress, time: performance.now() });
if (state.samples.length > 54) state.samples.shift();
}
最终在 verify() 里双管齐下:
javascript
const diff = Math.abs(state.progress - state.target);
const humanScore = scoreHumanDrag();
if (diff <= 0.026 && humanScore >= 0.42) {
// ✅ 通过:吸附到目标位置,变绿、打勾
state.verified = true;
setProgress(state.target);
captcha.classList.add("is-success");
}
效果说明:diff 管「对不对」,humanScore 管「像不像人」。脚本就算算出了 target 直接瞬移过去,也会栽在「拖动轨迹过短」上。
六、失败了?优雅地弹回去
验证失败不能直接把人晾在那儿,写个 requestAnimationFrame 回弹动画,让曲线带着缓动滑回起点:
javascript
function springBack() {
const start = state.progress;
const started = performance.now();
const duration = 420;
function step(now) {
const t = clamp((now - started) / duration, 0, 1);
const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic:先快后慢
setProgress(start * (1 - eased));
if (t < 1) {
state.animationFrame = requestAnimationFrame(step);
}
}
state.animationFrame = requestAnimationFrame(step);
}

效果说明:easeOutCubic 让回弹「先快后慢」,配合卡片的 shake 抖动动画,失败的反馈既明确又不生硬。
同时注意这个细节------开始新一次拖动时,要先取消上一段回弹动画:
javascript
function startDrag(event) {
if (state.verified) return;
cancelAnimationFrame(state.animationFrame); // 先停掉回弹
state.dragging = true;
// ......
slider.setPointerCapture(event.pointerId); // 锁定指针,拖出滑块也不丢事件
}
总结
说句实话,这套方案作为前端侧的行为验证雏形 已经很能打了,但要真上生产环境,别忘了验证码的最终校验一定要放到服务端,前端的 diff 和打分结果只能作为参考信号哦~
源码地址
gitee
源码地址 :gitee.com/zheng_yongt...

github
源码地址 :github.com/yongtaozhen...

🌟 觉得有帮助的可以点个 star~
🖊 有什么问题或错误可以指出,欢迎 pr~
📬 有什么想要实现的功能或想法可以联系我~
公众号
关注公众号『 前端也能这么有趣 』,获取更多有趣内容。 发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。