
1. 为什么要在登录页放一道算术题方案选型与整体设计前段时间帮朋友看一个后台系统登录接口被人用脚本按字典刷了两天日志里全是间隔 300 毫秒的请求。他们原来的方案是一个纯前端的滑块滑过去就放行——脚本连滑块的 DOM 都不用找直接调fetch把登录接口打穿。这种场景下最划算的止血办法往往不是上全套风控而是在登录表单前面加一道数字运算验证码屏幕上出现类似7 × 3 ?的式子用户填个 21 就能过。数字运算验证码说白了就是把传统的“照抄四个歪歪扭扭的字母”换成“算一道小学算术题”。它属于图形验证码的一个变种核心价值在于用极低的理解成本换取一次有效的人工交互。对用户来说看懂8 5比辨认8和B难不了多少甚至更轻松对机器来说要稳定识别带旋转、带干扰线的手写风格算式再算对结果成本比识别纯字符高出一截。这篇文章适合三类人看正在被垃圾注册、撞库脚本骚扰想找个轻量方案顶一下的前端要给自己项目做一个可复用验证码组件的工程师以及想搞清楚“验证码到底防得住什么、防不住什么”避免把安全责任全压在前端的人。下面我会从选型逻辑、参数设计、代码实现、踩坑排查一路讲到上线自检代码给的是可直接跑的版本参数也给了推荐区间你可以照着改。1.1 从字符验证码到算术验证码中间差的是什么传统字符验证码的痛点是公认的l和1、0和O、rn和m用户输错三次就开始骂人转化率肉眼可见地掉。后来越来越多的产品转向“行为型”验证比如滑块、点选文字体验确实好但实现复杂度和依赖成本也上去了——你得有图库、有轨迹分析、有风控打分小项目根本背不动。算术验证码刚好卡在中间。它的题目空间是有限的、可穷举的比如两个 1 到 20 的数加减乘也就一千多种组合这意味着它天然不适合承担强安全职责但它的生成成本几乎为零不需要图库、不需要第三方服务、不需要模型一个 Canvas 加几十行 JS 就能跑起来。我用它最多的场景是内部管理后台登录、表单提交防重复、评论区防灌水、以及一些低频接口的轻量人机判别。关键判断标准其实就一条你的接口被脚本打的价值高不高。如果对方只是无差别扫全网算术验证码能把 90% 的“顺手脚本”挡在门外性价比极高如果是有人盯着你的业务专门写绕过逻辑那前端验证码再怎么设计都是纸糊的得往服务端限流、设备指纹、行为风控上走。把这两件事分清楚后面的设计才不会跑偏。1.2 四段式链路生成、绘制、交互、校验我在做这类组件时习惯把它拆成四段每段职责清晰替换成本低阶段触发位置核心职责关键约束生成服务端接口产出题目、题目 ID、有效期答案绝不下发绘制前端 Canvas把题目渲染成带干扰的图形可读性优先于防识别交互前端组件输入、刷新、错误提示、倒计时状态单一来源避免竞态校验服务端接口比对答案、一次性作废、计失败次数幂等 防重放这个拆法的好处是前端只管“看起来像什么”服务端管“到底对不对”。很多人图省事把a、b和答案一起塞进前端变量里用户提交时前端自己判断对错——这种写法我见过不止一次F12 打开控制台就能看到answer: 21等于没做。注意只要答案是前端能拿到的这道题就只是“防误触”不是“防脚本”。如果业务上确实需要前端先做一次预判比如减少无谓请求那服务端必须再做一次同样的校验前端的判断只能当体验优化。1.3 先把安全边界说清楚别背不该背的锅我在评审里最常纠正的一个认知偏差验证码不是身份认证它只是提高批量请求的单位成本。算术验证码尤其如此它的题目空间小理论上脚本拿到题图后做 OCR、识别出“7×3”再算个乘法一次请求也就几十毫秒的事。所以它的真实定位应该是“过滤器”而不是“门锁”。真正让脚本难受的是配合它一起上的几件事同一 IP 或同一会话的失败次数限制比如连续 5 次错误锁定 60 秒、验证码一次性使用用过即焚防止同一个 token 重放、题目短有效期2 到 5 分钟、以及登录接口本身的账号维度限流。这四件事里验证码只占一环剩下三环都在服务端。还有个容易忽略的点验证码接口本身也会被打。如果/captcha/generate没有任何频率控制脚本可以疯狂拉题把内存撑爆。我一般会按 IP 加一个 10 秒 20 次的软限制超了就返回 429同时记录来源方便后续判断。2. 核心细节解析一道题背后藏着多少参数一道看起来随机的6 9 ?实际上每个数字、每根线条都是被约束过的。这一块是最容易写成“能用但难用”的地方——参数拍脑袋定结果要么是题目太简单全是 1 到 5 的加法要么是干扰太重用户自己都看不清。我按题目生成、绘制、校验三块拆开讲。2.1 题目生成器运算符、数值范围与结果约束生成器的第一原则是结果必须对普通人心算友好。具体来说结果保持非负整数最好在 0 到 100 之间减法必须保证被减数大于减数不然出现负数用户会犹豫乘法两个因子控制在 2 到 9避免出现13 × 17这种需要动笔的除法要么不做要么强制整除且除数不能为 1太送分也不能为 0。我给的一个实用配比是加法 45%、减法 30%、乘法 25%除法默认关闭。加法的和控制在 5 到 30减法的差控制在 1 到 20乘法结果不超过 81。这个区间是试出来的——再大中年用户和年纪大的运营同事会抱怨再小脚本枚举一千多次就能把所有组合撞完。// 题目生成服务端执行返回 {id, expression} 给前端answer 存服务端 const OPERATORS [, -, ×]; function pickInt(min, max) { return Math.floor(Math.random() * (max - min 1)) min; } function generateQuestion() { const op OPERATORS[pickInt(0, 2)]; let a, b, answer, expression; if (op ) { a pickInt(2, 18); b pickInt(1, 12); answer a b; expression ${a} ${b} ?; } else if (op -) { a pickInt(8, 25); b pickInt(1, a - 1); // 保证被减数大于减数 answer a - b; expression ${a} - ${b} ?; } else { a pickInt(2, 9); b pickInt(2, 9); answer a * b; expression ${a} × ${b} ?; } return { expression, answer }; }这里有个细节值得说×我用的是乘号字符而不是字母x因为字母x在小字号下容易和×混淆反而坑了用户。同理减号用标准的-别用全角破折号渲染出来太长会挤占画布。实操心得题目里不要出现0作为加数或减数比如9 0。用户会怀疑是不是渲染出错反馈里有一半“验证码显示不全”的投诉都来自这种题。2.2 Canvas 绘制的坐标系与干扰元素配比绘制这块我踩过的坑比生成多得多。Canvas 的坐标是逻辑像素但在高分屏devicePixelRatio 为 2 或 3上如果不做缩放画出来的文字会糊成一团用户第一反应是“你这验证码坏了”。正确做法是先按 CSS 尺寸设样式再把画布的物理像素放大 dpr 倍最后用ctx.scale(dpr, dpr)把坐标系拉回来function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; canvas.style.width cssWidth px; canvas.style.height cssHeight px; canvas.width Math.round(cssWidth * dpr); canvas.height Math.round(cssHeight * dpr); const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }干扰元素的配比我一般按这个基准走画布 120×40文字字号 22 到 24字符间距在基准值上随机 ±3整体旋转 ±6 度单字旋转可以再放宽到 ±8 度干扰线 2 到 3 条颜色取与文字同色系但透明度 0.3 左右噪点 25 到 35 个半径 1 到 1.5 像素。这里的关键取舍是干扰要有但不能影响“等式结构”的识别。因为算术验证码的难点对用户来说不是认字而是认出这是个什么运算。如果线条直接从等号上穿过去用户会读成7 - 3那就彻底失败了。我的做法是让干扰线避开文字基线区域只在上下留白处活动。function drawCaptcha(ctx, expression, width, height) { // 背景极浅的随机色纯白反而让噪点太扎眼 ctx.fillStyle hsl(${pickInt(0, 360)}, 30%, 97%); ctx.fillRect(0, 0, width, height); // 干扰线限制在文字上下两侧 for (let i 0; i 3; i) { ctx.beginPath(); ctx.strokeStyle hsla(${pickInt(0, 360)}, 50%, 60%, 0.35); ctx.lineWidth 1; const y i % 2 0 ? pickInt(0, 8) : pickInt(height - 8, height); ctx.moveTo(0, y); ctx.bezierCurveTo(width / 3, y pickInt(-6, 6), (2 * width) / 3, y pickInt(-6, 6), width, y); ctx.stroke(); } // 文字逐字绘制各自随机旋转偏移 const chars expression.split(); const fontSize 23; const gap width / (chars.length 1); ctx.textBaseline middle; chars.forEach((ch, idx) { ctx.save(); const x gap * (idx 1); const y height / 2 pickInt(-2, 2); ctx.translate(x, y); ctx.rotate(((pickInt(-8, 8)) * Math.PI) / 180); ctx.font bold ${fontSize}px system-ui, sans-serif; ctx.fillStyle hsl(${pickInt(0, 360)}, 65%, 35%); ctx.textAlign center; ctx.fillText(ch, 0, 0); ctx.restore(); }); // 噪点避开中心带减少对等式的干扰 for (let i 0; i 30; i) { ctx.beginPath(); ctx.fillStyle hsla(0, 0%, ${pickInt(40, 80)}%, 0.4); ctx.arc(pickInt(0, width), pickInt(0, 6) (i % 2 ? height - 12 : 0) 3, 1.2, 0, Math.PI * 2); ctx.fill(); } }注意最后噪点的写法我把它们的 y 坐标压在了上下 12 像素的边带里。这是一个很小的改动但用户投诉率明显下降——之前噪点随机撒在中间很多人反馈“那个点是不是小数点”。2.3 校验逻辑答案为什么不能下发到前端校验这块的写法直接决定了这套方案是不是白做。我见过的最离谱实现是接口返回{question: 7 × 3 ?, answer: 21}前端拿来存着用户提交时前端比对比对通过才发登录请求。这种实现连抓包都不用直接在Network面板里看一眼响应就全懂了。正确链路应该是服务端生成题目时算出一个随机captchaId把captchaId → answer存进缓存Redis 或进程内的 LRU 都行设置 120 到 300 秒过期前端拿到的只有captchaId和expression用户提交时带上captchaId和用户填的value服务端取出答案比对比完立刻删除这个 key无论对错。“比完立刻删除”这一步是为了防重放。假设不删用户拿到captchaId和正确答案他自己算出来的可以在有效期内重复使用这个 ID 提交一百次请求每次都通过。加上一用即焚同一个 ID 只有一次机会。// 服务端校验Node 环境示意 async function verifyCaptcha(cache, captchaId, userValue) { if (!captchaId || userValue undefined || userValue null) { return { ok: false, reason: EMPTY_INPUT }; } const key captcha:${captchaId}; const record await cache.get(key); // 先删除再判断天然防重放和并发穿透 await cache.del(key); if (!record) return { ok: false, reason: EXPIRED }; const attempt Number(String(userValue).trim()); if (!Number.isInteger(attempt)) return { ok: false, reason: INVALID_FORMAT }; if (attempt ! record.answer) return { ok: false, reason: MISMATCH }; return { ok: true }; }把del放在判断之前是我特意做的。如果先判断再删除在并发场景下两个请求可能同时读到记录、同时通过等于一次题目用了两次。先删再加一个“删除成功与否”的判断就能保证只有一个请求拿到记录另一个直接走EXPIRED分支。注意错误提示不要区分MISMATCH和EXPIRED在前端显示的具体文案统一提示“验证码错误或已过期请刷新重试”。区分得太细等于告诉脚本“你这个 ID 有效只是答案算错了”反而帮对方缩小了搜索空间。3. 实操过程从零把一个算术验证码组件写出来这一块我按真实落地顺序走一遍从目录结构到组件封装再到与后端的接口约定。代码以原生 JS 为主样板足够清晰你想迁到 Vue 或 React 里把生命周期钩子换一下就行。3.1 结构划分与接口约定我习惯这么拆src/ captcha/ generator.js # 题目生成服务端复用 renderer.js # Canvas 绘制前端 captcha.js # 组件封装、状态管理 captcha.css # 样式 api/ captcha.js # 请求封装接口我固定成两条参数少、语义明确接口方法请求响应/api/captcha/newGET无或带 scene 标识{ captchaId, expression, expiresIn }/api/captcha/verifyPOST{ captchaId, value }{ ok, reason }scene这个可选参数有用如果同一个站点有登录、注册、评论三处都用验证码带上场景标识可以分开统计失败率也方便后续针对某一处单独调难度。组件封装时我强调一个原则状态单一来源。当前题目、加载状态、错误信息、剩余有效时间全部挂在一个对象上任何一次刷新都整体替换而不是零散地改某个字段。这样能避免“题目已经换了但错误提示还停在上一轮”的经典 bug。3.2 组件骨架与渲染入口export function createCaptcha(options) { const { container, canvasSize { width: 120, height: 40 }, onVerify, // 外部传入的校验函数返回 Promise{ok, reason} refreshOnError true // 失败后是否自动换题 } options; const state { captchaId: null, expression: , loading: false, error: , verified: false }; const input document.createElement(input); input.type text; input.inputMode numeric; // 移动端唤起数字键盘 input.autocomplete off; input.maxLength 3; input.placeholder 计算结果; input.className captcha-input; input.setAttribute(aria-label, 请输入算式计算结果); const canvas document.createElement(canvas); canvas.className captcha-canvas; canvas.setAttribute(role, img); const refreshBtn document.createElement(button); refreshBtn.type button; refreshBtn.textContent 换一题; refreshBtn.className captcha-refresh; container.append(input, canvas, refreshBtn); return { state, input, canvas, refreshBtn, refresh }; }input.type这里我特意用text而不是number。number类型在桌面端会带上下箭头鼠标滚轮滚一下数字就变了用户会莫名其妙而且number在部分浏览器里允许输入e和-校验时还得额外过滤。用text加inputModenumeric键盘体验一样脏数据更好控。maxLength设 3 是因为我们的答案上限是 81两位数字就够留一位容错。如果答案位数不固定就按最大可能的位数设。3.3 刷新、提交与竞态处理这是整个组件最容易出问题的地方。想象这个场景用户点了“换一题”请求还在路上他又点了第二次第一个请求后回来把captchaId覆盖了此时画布上显示的是题 B但绑定的 ID 是题 A 的用户怎么填都不对。解决办法是加一个请求序号只有最新一次请求能写入状态let requestSeq 0; async function refresh() { const seq requestSeq; state.loading true; state.error ; renderLoading(); try { const res await fetch(/api/captcha/new).then(r r.json()); if (seq ! requestSeq) return; // 已有更新的请求本次结果丢弃 state.captchaId res.captchaId; state.expression res.expression; state.loading false; state.verified false; drawCaptcha(canvas.getContext(2d), res.expression, canvasSize.width, canvasSize.height); canvas.setAttribute(aria-label, 验证码算式${res.expression}); } catch (e) { if (seq ! requestSeq) return; state.loading false; state.error 加载失败请重试; renderError(); } } async function submit() { const raw input.value.trim(); if (!raw) { showError(请先填写计算结果); return; } if (!state.captchaId) { showError(验证码已失效请刷新); return; } if (state.verified) return; // 防连点重复提交 state.loading true; const result await onVerify({ captchaId: state.captchaId, value: raw }); state.loading false; if (result.ok) { state.verified true; showSuccess(); return; } showError(验证码错误或已过期请刷新重试); if (refreshOnError) refresh(); // 失败即换题杜绝穷举 }state.verified这个锁很关键。没有它用户手快连点两次提交第一次通过后captchaId已经被服务端删了第二次必然失败页面上就会闪现一个“验证码错误”体验极差。refreshOnError我默认给true。原因是如果不换题同一个captchaId已经被服务端删了用户再填也是过期只能引导手动刷新不如自动换来得干脆。代价是网络慢的时候会闪一下所以我配了个 300 毫秒的过渡动画。实操心得刷新按钮一定要加typebutton。如果组件是放在form里的登录页基本都是不写这个属性浏览器会把它当成提交按钮点一下整个表单就提交了用户一脸懵地看到“密码错误”。3.4 服务端侧的缓存与限流细节缓存这块如果你的部署环境有 Redis就用SET key value EX 240过期时间我建议 240 秒比前端展示的倒计时略长一点避免网络延迟导致的“倒计时还有 5 秒但其实已经过期”。没有 Redis 的话进程内 Map 也能顶但必须自己加清理逻辑否则内存会慢慢涨const store new Map(); const TTL 240 * 1000; function set(id, answer) { store.set(id, { answer, expireAt: Date.now() TTL }); } function take(id) { // take 语义取出即删除 const item store.get(id); if (item) store.delete(id); if (!item) return null; if (item.expireAt Date.now()) return null; return item; } // 每 60 秒扫一次过期键 setInterval(() { const now Date.now(); for (const [k, v] of store) { if (v.expireAt now) store.delete(k); } }, 60 * 1000).unref?.();多实例部署时进程内 Map 会出问题请求打到了 A 实例生成的题校验时落到 B 实例B 查不到直接判定过期。这个坑我踩过表现是“验证码随机性失败”大约一半的请求会报过期。判断方法很简单看失败率是不是接近(实例数 - 1) / 实例数。解决办法要么上集中缓存要么在负载均衡上做会话粘滞前者更推荐。限流我一般分两层生成接口按 IP 限制10 秒 20 次校验接口按 IP 和账号双维度限制10 分钟内 5 次失败锁定 60 秒锁定期间即使算对也不放行。第二层的锁定要有明确的解除逻辑别做成永久封禁否则用户换个网络环境还是进不来客服压力全在你这边。4. 常见问题与排查技巧实录写到这儿我想把这两年攒下来的问题清单摊开说说。算术验证码的坑一半在渲染一半在交互细节真正涉及算法的反而少。4.1 高频问题速查表现象大概率原因排查方法处理方式文字发虚、边缘模糊未按 dpr 缩放画布在高分屏下对比 canvas.width 与 CSS 宽度按 2.2 的分辨率适配写法重设验证码区域点击无反应上层有透明遮罩或 z-index 冲突用开发者工具逐个隐藏兄弟元素调整层叠顺序或把输入框放进同一容器移动端键盘弹出后页面跳动输入框聚焦触发滚动观察布局视口变化容器加固定高度聚焦时不做布局重排输对了仍提示错误captchaId 被重复刷新覆盖在提交前打印 captchaId 与画布文案引入请求序号丢弃过期响应多实例部署时随机失败缓存未集中统计失败率是否接近 (n-1)/n换用集中缓存或做会话粘滞用户反馈算式看不清干扰过重或颜色对比不足缩小窗口、调低亮度复现降低干扰密度保证文字与背景对比度提交后无任何反馈校验函数 Promise 未 catch看控制台是否有未捕获异常补 try/catch 并统一错误提示这张表我基本是照着客服记录反过来整理的其中“输对了仍提示错误”出现频率最高八成都是竞态问题。4.2 三个我亲自踩过的坑第一个是点击穿透。当时把 canvas 和输入框做成绝对定位叠在一起结果移动端上输入框聚焦后键盘弹起页面被压缩输入框被挤到 canvas 下面用户点输入框的时候实际点到了 canvas光标死活不出现。后来改成正常文档流布局canvas 和输入框并排问题一次解决。结论是别为了好看硬叠绝对定位验证码区域本身也不大横向排版更稳。第二个是输入法。测试同事用中文输入法在输入框里打了个21但输入法还在候选状态value拿到的是空字符串提交时提示“请先填写”。用户在候选框里按了回车候选词上屏看起来数字已经在那儿了其实没提交。处理方式是在compositionstart和compositionend上做标记compositionend时再读取值更省事的做法是直接给输入框加pattern\d*并在提交时用Number(raw)转换输入法的问题在数字场景下影响很小。第三个是颜色对比度。有一版为了“好看”文字用了浅色调干扰线用了深色结果运营的同事在会议室投影上根本看不清。后来我把文字统一压到hsl(色相, 65%, 35%)亮度固定在 35% 附近背景用 97% 的浅色对比度稳稳落在 7:1 以上。这个参数调整看着不起眼但它是用户投诉下降最明显的一次改动。4.3 给看不清的人留条路这一点经常被忽略。算术验证码对普通人就是一眼的事但对色弱用户、视力不佳的用户、以及在强光环境下看手机的人就是折磨。我一般会做三件事第一给 canvas 加aria-label把算式文字直接写进去读屏软件可以念出来用户听题作答。第二提供“换一题”按钮并保证这个按钮不需要先算对才能点有些实现把刷新按钮放在校验通过之后才可用这完全是反人类。第三如果业务允许给一个降级通道比如同一账号连续失败 3 次后切换到更简单的题型或者走人工审核。还有个小细节aria-label里的乘号要写清楚。7 × 3 ?读屏可能念成“七三”更友好的写法是7乘以3等于多少。这个字符串在前端做一次替换就行成本极低。5. 性能、兼容与上线前的自检最后一节聊聊收尾的事。这类组件不复杂但上线前该看的还是得看一遍尤其是兼容性和体积。5.1 体积与渲染开销整套东西的核心代码压缩后不到 3KB没有第三方依赖Canvas 绘制一次耗时在 1 到 3 毫秒之间对首屏几乎无影响。真正需要注意的是刷新频率如果用户狂点“换一题”每次都触发网络请求和重绘移动端上会有轻微卡顿。我的做法是给刷新按钮加 500 毫秒节流同时请求序号机制已经保证了结果不会错乱。内存方面如果组件在单页应用里频繁挂载卸载记得清定时器。倒计时用的setInterval一定要在组件销毁时clearInterval否则每进一次页面就多一个定时器在跑久了页面会明显变慢。注意Canvas 元素本身不会自动回收绘图上下文但只要你不在每次重绘时反复getContext创建新的上下文对象就没有额外开销。我在代码里把 ctx 缓存在变量里复用这一点在小页面上看不出差别但在长列表里每个条目都放验证码的场景下很关键。5.2 上线前的自检清单检查项通过标准答案是否只在服务端抓包看/new响应不含答案字段是否一次性使用同一 captchaId 提交两次第二次必须失败是否有过期机制等待超过有效期后提交返回过期失败是否换题答错一次后画布内容必须变化高分屏是否清晰手机和 Mac 上查看文字边缘移动端键盘是否正常点击输入框能唤起数字键盘表单内按钮类型刷新按钮为 button不会误提交表单限流是否生效连续快速请求生成接口能拿到 429无障碍属性读屏能念出算式输入框有标签错误提示是否统一前端不同分支提示文案一致不泄露细节这张清单我每次上线前都会过一遍尤其是“是否一次性使用”和“失败是否换题”这两条最容易在赶工时被漏掉。6. 一点个人体会做了这么多版验证码我最大的感受是它的价值不在“多难”而在“多合适”。数字运算验证码不是最安全的方案甚至算不上安全方案但它在成本、体验、防护力度这三者之间的平衡点是很多中小项目真正需要的。我见过有人为了防脚本上了很重的方案结果自家运营同事每天登录都要滑三次最后不得不加白名单绕了一圈回到原点。如果你现在正准备给自己的项目加一道这样的关卡我的建议是先算笔账你的接口被打的频率有多高、造成的损失有多大、加这道题会让正常用户多花几秒。如果损失明显大于体验代价那就上如果不能先去做服务端限流和日志告警收益比前端验证码高得多。真要做就按上面这套来答案放服务端、一次一用、失败换题、配套限流四件事做到位挡住大部分顺手脚本完全够用。至于更狠的攻击那就不是一道算术题该操心的事了。