实现一个「曲线滑块验证」功能

说在前面

今天我们纯前端来实现一个「曲线滑块验证 」组件:页面上有一条白色主曲线 和一条目标背景曲线 ,用户拖动底部滑块,让白曲线的弯曲程度与目标曲线重合 ,重合且拖动轨迹的操作过程「像个人」就算通过。

实现关键点:

  • 如何用三段三次贝塞尔曲线造出一条曲线;
  • 滑块进度如何实时驱动 SVG path 重绘;
  • 如何加入干扰曲线防止「看答案抄作业」;
  • 如何用拖动轨迹采样做一个简单的人机行为打分;
  • 失败后的回弹动画和无障碍细节怎么写。

一、先搞清楚我们要做什么

总结一下整个组件的运转逻辑:

  1. 页面加载时,随机生成一个 target 值(0~1 之间),它决定目标曲线弯多深;
  2. 用户拖动滑块,滑块进度 progress(也是 0~1)决定白色主曲线弯多深;
  3. 松手时比较两者差距 |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,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

相关推荐
Csvn1 小时前
组件库(从 API 契约到 Headless 内核)
前端
deli0071 小时前
为什么最近邻路线总在绕远?2-opt 把 50 城 TSP 缩短了 20.9%
前端
guslegend1 小时前
底层数据设计:读写分离、分库分表与热点数据隔离
前端
Southern Wind1 小时前
AIAgent——第一章:本地 Agent 与 Vue 3 流式全栈实战
前端·javascript·vue.js
两万五千个小时2 小时前
从零给 DSH 写一个 Webhook 通知插件
javascript·人工智能·架构
可乐鸡翅yeah_2 小时前
HLS 业务 Referer‑Policy 网页元标签引发播放异常排错
前端·javascript·网络·ffmpeg·php·音视频
jj_ccwgw2 小时前
从 0 到 1 搭建 pnpm + Workspaces + Turborepo Monorepo 保姆级指南
前端·node.js
智驭未来掌门人2 小时前
AI编程时代的设计系统落地指南:从分类选型到项目实战
前端
vipxieliang2 小时前
深入理解 JavaScript 数据类型与类型转换
前端·javascript