☰
前端Canvas随机验证码生成实战:从字符集选择到绘制优化
2026/10/17 4:51:24 网站建设 项目流程

写前端的人应该都有过这种感觉:一个功能看起来简单到不行,真正做的时候却总会冒出几个小坑。“随机验证码的生成”就是典型例子——页面打开时自动出现一串验证码,点一下刷新按钮,换一张,逻辑上好像没什么难度。可一旦落到代码里,字符集的选择、Canvas绘制的清晰度、点击事件的绑定、连续刷新时的状态处理,每个细节都会影响最终体验。

这个需求常见于登录页、表单提交、注册流程,核心就两个动作:初始生成,点击刷新。看起来简单,但它把DOM操作、事件绑定、随机算法、Canvas绘制串在了一起,非常适合用来巩固JavaScript基础,也是一个随手就能封装成工具函数的实用组件。我下面按实际开发顺序把整个实现拆开讲,从设计思路到底层绘制,再到问题排查,带注释的完整代码也会放出来,你可以直接抄。

1. 整体设计与思路拆解

1.1 核心需求解析

先把这个需求的本质拆开看,它其实包含两个独立事件:

  • 页面加载完成时,画布上要立即出现一个验证码。
  • 用户点击“刷新”按钮后,当前验证码被替换成一个新的。

第一个动作意味着必须在页面初始化阶段调用一次生成函数,不能只绑定事件而忘记初始渲染;第二个动作是事件处理的核心逻辑,需要重新生成随机字符串并重新绘制。

再往深处想一层,还会涉及验证码的展示形式。是普通文字拼接,还是用Canvas绘制?字符是纯数字还是数字加字母?有没有干扰线或噪点?这些虽然在题目里没有强制要求,但在真实场景中直接决定了这个东西是否“像”一个验证码,以及是否具备基本的防机器识别能力。

从功能角度,我习惯把状态拆成两个:验证码字符串本身,以及它的绘制结果。 字符串状态决定内容,绘制决定用户看到什么。 刷新按钮做的其实就是“重新生成字符串 + 重绘”两件事。

1.2 技术选型:DOM拼接还是Canvas绘制

实现验证码展示有两条路线,很多人第一反应是用多个span拼字符,或者干脆在div里塞一段文本。这样确实能显示文字,也能用CSS控制间距和旋转,但存在几个问题:

  • 字符之间的间距和位置不容易精细控制,很难做到错落有致。
  • 干扰线、噪点这类效果需要额外的DOM元素或背景图配合,代码会变得很脏。
  • 字体旋转需要用CSS transform逐个设置,复用性差。

所以我更推荐用<canvas>来绘制。Canvas本身就是为这类图像生成场景设计的,画噪点、画干扰线、旋转字符都很直接,所有代码集中在一个绘制函数里,封装性更好。后面的完整代码也采用Canvas方案。

用Canvas绘制还有一个额外的好处:验证码的“防识别”能力更强。虽然前端验证码无论如何都不能作为真正的安全手段(这个后面细说),但至少在视觉上,带干扰线和噪点的图形比一串普通文本更像正规验证码,用户也不会觉得这个功能是应付事的。

1.3 核心流程与状态管理

整个流程可以简化成三条链路:

  1. 初始化链路:页面加载 -> 生成随机字符串 -> 调绘制函数 -> 画布显示验证码。
  2. 刷新链路:点击按钮(或点击画布) -> 再次生成随机字符串 -> 清除旧画面 -> 重新绘制。
  3. 绘制链路:清空画布 -> 绘制随机背景色 -> 逐个绘制字符(带随机旋转和位置偏移) -> 添加干扰线和噪点。

这三条链路里最需要注意的是第二条里的“清除旧画面”。如果不清除,新验证码会和旧的重叠在一起,出现“叠影”效果。这也是初学者最容易踩的坑。

我建议把“生成字符串”和“绘制字符串”拆成两个函数。 这样如果以后改成从后端获取验证码,只需要替换生成字符串这一步,绘制部分完全不用动。

2. 核心细节解析与实操要点

2.1 验证码字符集的选取

这部分看起来不起眼,实际上对用户体验影响很大。很多人图省事,直接用Math.random()配合数字范围生成4位数字,或者把大小写字母全塞进去,结果用户经常输错。

问题出在字符的易混淆性。比如数字0和大写字母O,数字1和小写字母l,大写I和小写l,数字8和字母B在某些字体下也容易看混。虽然验证码可以通过刷新换一个,但每次换都让用户卡壳,体验非常糟糕。

我常用的字符集是去掉易混淆字符的大写字母+数字组合:

const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';

这里去掉了I、L、O、Z、0、1、2(Z和2容易混),只保留了辨识度较高的字符。实际测试下来,输入错误率会明显下降。

生成随机索引时,不要直接对字符串长度取随机数就完事,要注意随机数的起始范围:

function randomChar() { const index = Math.floor(Math.random() * chars.length); return chars[index]; }

chars.length是字符串长度,Math.random()返回0到1之间的数(含0不含1),相乘后取整,结果范围正好是0到length-1,刚好对应所有字符的下标。这块基础但关键,写错了会导致某些字符永远不出现。

验证码长度方面,我用4位做标准配置。4位在安全性上够用,用户输入负担也不大。6位更安全但输入体验明显变差,除非业务场景安全要求很高,否则不建议默认用6位。

2.2 Canvas绘制关键技术

绘制验证码时,有几个坐标和状态的细节必须要处理好。

画布尺寸与字符布局

画布如果只有40x20这种小尺寸,4个字符挤在一起基本没法看。我建议至少128x40,这个尺寸下4个字符能均匀分布,加上干扰线后也不会显得拥挤。

字符绘制采用逐个控制的方式,每个字符的x坐标按步进均匀分布,再叠加一个随机偏移,让字符位置有错落感:

for (let i = 0; i < code.length; i++) { ctx.save(); ctx.translate(20 + i * 24 + randomOffset(), 28 + randomOffset()); ctx.rotate(getRandomAngle()); ctx.font = `bold ${fontSize}px Arial`; ctx.fillStyle = randomColor(); ctx.fillText(code[i], 0, 0); ctx.restore(); }

这里关键在于save()和restore()。translate移动了画布原点,rotate改变了坐标系角度,如果没有save/restore包住,这些状态会一直累积,导致后面的字符位置越来越偏、角度越来越大。

画布必须先清空再绘制

重新生成验证码时,要把上一版的内容完全擦掉。clearRect是标准做法:

ctx.clearRect(0, 0, canvas.width, canvas.height);

注意clearRect清除的范围必须覆盖整个画布,如果只写了clearRect(0, 0, 100, 40)而画布实际是128宽,左边就会残留旧图形。

字体与颜色的随机策略

字体尽量用系统自带的Arial或sans-serif,不要依赖外部字体。外部字体没加载完时,Canvas会先用备用字体渲染,导致验证码忽大忽小。

颜色方面,背景用浅色,字符用深色,干扰线颜色要介于两者之间。如果字符颜色和背景颜色对比度太低,用户看不清,就需要反复点击刷新,交互体验大打折扣。我一般把字符颜色控制在深色调里随机,背景控制在浅色调里随机:

function randomColor(min, max) { const r = Math.floor(Math.random() * (max - min) + min); const g = Math.floor(Math.random() * (max - min) + min); const b = Math.floor(Math.random() * (max - min) + min); return `rgb(${r},${g},${b})`; }

2.3 安全性与后端校验

这里必须说清楚一个概念:纯前端生成的验证码,并不具备真正的安全防护能力。它的字符和逻辑都在浏览器端,用户打开控制台就能看到随机字符的生成过程,甚至可以手动构造请求绕过验证。

所以前端验证码的实际作用是:提升自动化脚本的调用成本,阻止无脑的批量请求。如果业务对安全性有硬性要求,比如登录、支付、注册,正确做法是让后端生成验证码对应的唯一标识(比如将其存入会话),前端只负责展示和提交用户输入,后端来校验结果。

在实际开发中,我的做法是:项目联调阶段用前端生成验证码跑通流程,上线前再替换成后端接口返回的验证码图片。 这样前端结构不变,只改变数据来源,改动成本很低。

3. 实操过程与核心环节实现

3.1 完整代码实现

下面是一份可以直接运行的完整示例,把HTML、CSS和JavaScript放在一个文件里,方便你本地测试。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>随机验证码生成</title> <style> .captcha-wrapper { display: flex; align-items: center; gap: 12px; font-family: Arial, sans-serif; } .captcha-box { border: 1px solid #ddd; border-radius: 4px; cursor: pointer; background: #fff; } .refresh-btn { padding: 8px 16px; border: 1px solid #2d8cf0; border-radius: 4px; background: #fff; color: #2d8cf0; font-size: 14px; cursor: pointer; transition: background 0.2s; } .refresh-btn:hover { background: #ecf5ff; } .tips { font-size: 12px; color: #999; margin-top: 6px; } </style> </head> <body> <div class="captcha-wrapper"> <canvas id="captchaCanvas" class="captcha-box" width="128" height="40"></canvas> <button id="refreshBtn" class="refresh-btn">刷新</button> </div> <p class="tips">看不清?点击图片或按钮刷新验证码</p> <script> (function () { const canvas = document.getElementById('captchaCanvas'); const ctx = canvas.getContext('2d'); const refreshBtn = document.getElementById('refreshBtn'); // 去掉了易混淆字符(0、O、1、I、L、Z、2等) const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; const codeLength = 4; const fontSize = 28; let currentCode = ''; // 生成随机字符串 function generateCode() { let code = ''; for (let i = 0; i < codeLength; i++) { const index = Math.floor(Math.random() * chars.length); code += chars[index]; } return code; } // 生成指定亮度的随机颜色 function randomColor(min, max) { // 将 r/g/b 控制在 min~max 之间,保证整体色调一致 const r = Math.floor(Math.random() * (max - min) + min); const g = Math.floor(Math.random() * (max - min) + min); const b = Math.floor(Math.random() * (max - min) + min); return `rgb(${r},${g},${b})`; } // 生成随机角度(-30度到30度之间) function randomAngle() { return (Math.random() - 0.5) * (Math.PI / 6); } // 绘制验证码 function drawCode(code) { // 清除旧画面 ctx.clearRect(0, 0, canvas.width, canvas.height); // 浅色随机背景 ctx.fillStyle = randomColor(220, 250); ctx.fillRect(0, 0, canvas.width, canvas.height); // 噪点:随机画30个小圆点 for (let i = 0; i < 30; i++) { const x = Math.random() * canvas.width; const y = Math.random() * canvas.height; ctx.fillStyle = randomColor(50, 180); ctx.beginPath(); ctx.arc(x, y, 1, 0, Math.PI * 2); ctx.fill(); } // 干扰线:画3条随机曲线,这里简化成直线 for (let i = 0; i < 3; i++) { ctx.strokeStyle = randomColor(80, 200); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height); ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height); ctx.stroke(); } // 绘制字符 for (let i = 0; i < code.length; i++) { ctx.save(); // 每个字符在水平方向按固定步进分布,再叠加随机偏移 ctx.translate(18 + i * 26 + (Math.random() * 6 - 3), 28 + (Math.random() * 6 - 3)); ctx.rotate(randomAngle()); ctx.font = `bold ${fontSize}px Arial`; ctx.fillStyle = randomColor(10, 120); ctx.fillText(code[i], 0, 0); ctx.restore(); } } // 刷新验证码:生成 + 重绘 function refreshCode() { currentCode = generateCode(); drawCode(currentCode); } // 绑定事件:按钮刷新 refreshBtn.addEventListener('click', refreshCode); // 绑定事件:点击画布本身也刷新(常见交互习惯) canvas.addEventListener('click', refreshCode); // 初始化:页面加载完成后立即生成第一个验证码 refreshCode(); })(); </script> </body> </html>

3.2 关键函数逐行解读

先看generateCode,它用循环拼出4个字符。每次循环通过随机索引从字符集里取一个字符,最终拼成字符串。这里有人会把Math.random() * chars.length的结果直接取整,写成parseInt(Math.random() * chars.length),效果等价,但用Math.floor语意更清晰。

再看drawCode,第一句clearRect是整个函数的地基。如果漏了这行,第二次刷新时旧字符还会留在画布上。画布不像普通DOM元素,设置新内容不会自动覆盖旧内容,必须手动清除。

save/restore和translate/rotate的组合是绘制字符的骨架。这里我做了两步偏移:

  • 基础位置按18 + i * 26排列,保证4个字符不会挤在一起。
  • 随机偏移(Math.random() * 6 - 3)让每个字符在基础位置附近有轻微错位。

角度控制在正负30度范围内。角度太大字符会横过来,识别困难;太小又看不出错落感。这个范围是我调过的,视觉上比较舒服。

噪点和干扰线的数量也需要注意。30个噪点加3条干扰线,在视觉上已经能产生足够干扰,但不会把字符盖住。如果干扰线太密或噪点颜色过深,用户连字符是什么都看不清,刷新按钮就形同虚设了。

3.3 参数调优建议

参数推荐值说明
画布宽度128px4位验证码下,字符分布空间充足
画布高度40px比字体高度(28px)多出约半行,留足旋转空间
验证码长度4位安全性与输入体验的平衡点
字体大小28px与40px画布高度搭配合适
字符水平步进26px每个字符占据26px宽度,不会重叠
干扰线数量3条能产生干扰,又不会遮盖字符
噪点数量30个默认参数,可按画布大小等比调整
旋转角度范围±30度视觉错落感与识别度的平衡

这些参数不是死的。如果你把画布改成160x50,字符步进也要相应加大;如果验证码位数改成6位,画布宽度和字体大小都要重新计算。一般经验是:画布宽度 = 字符数 * 步进 + 左右边距,步进 = 字号 + 4~6px。

4. 常见问题与排查技巧实录

4.1 验证码不刷新或总是旧图

这个现象最常见。原因一般出在三个方面:

  • 按钮的click事件没有绑定成功。检查addEventListener是否在DOM元素之后执行,如果脚本放在了<head>里且没有包裹DOMContentLoaded,元素还没渲染出来,绑定就会失败。
  • 点击事件触发了,generateCode也执行了,但drawCode没有清空画布。旧字符加上新字符叠在一起,看起来像没刷新,其实是叠影。
  • 浏览器缓存了图片。虽然在Canvas绘制场景下很少见,但如果验证码是用<img>标签展示的,图片地址不变时浏览器可能直接用缓存,这时需要在URL后面拼随机参数。

4.2 Canvas绘制出来的字很模糊

画布跨设备显示时,模糊是高频问题。原因在于Canvas的位图尺寸和CSS显示尺寸不一致。如果你设置了canvas.width="128" height="40",同时又在CSS里写了width:128px;height:40px,在高分屏设备上,显卡渲染时会把这个128x40的位图拉伸到2倍或3倍分辨率,结果就是发虚。

解决办法是让Canvas的位图尺寸跟随设备的像素比:

const dpr = window.devicePixelRatio || 1; canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.scale(dpr, dpr);

注意设置完canvas.width后,Canvas上下文会被重置,需要重新获取或重新设置字体、旋转等属性。

4.3 字符重叠或超出画布边界

字符重叠通常是因为字符水平步进太小。比如画布128px宽,4个字符平均分布时步进应该是26px左右,如果你的步进小于18px,字符就会挤在一起。

字符超出画布边界则是因为translate的y坐标设置不合理。ctx.translate(x, 28)表示字符基线在画布下方28px处,28px的字体基本能完整落在40px高的画布内,但如果字体加粗或者旋转角度过大,字符顶端可能会被裁掉。可以把y坐标从28改到30,或者将画布高度从40增加到44。

4.4 点击刷新按钮没反应但控制台不报错

这种情况多数是事件绑定的作用域问题。如果你把代码封装在函数或对象里,却把事件绑定写在了外部,而外部拿不到元素引用,就会出现“控制台不报错,但交互完全无效”的现象。

解决办法是打开浏览器开发者工具,在Elements面板确认元素ID是否存在,再在Console里手动执行document.getElementById('refreshBtn'),如果返回null,说明脚本加载时机不对。另一个实用技巧是在点击事件里加一行console.log('clicked'),用最简单的日志判断事件到底有没有触发。

遥个排查一步步来,熟练之后这类问题基本一分钟就能定位。

5. 功能扩展与实践心得

5.1 验证码组件化封装

上面的代码直接运行没问题,但如果项目里多个页面都用验证码,我建议把这段逻辑封装成一个独立模块,支持配置项:

class Captcha { constructor(options) { this.canvas = document.getElementById(options.canvasId); this.length = options.length || 4; this.type = options.type || 'mixed'; this.init(); } // ... }

这样不同页面可以传不同的长度、字符类型、画布尺寸,逻辑只维护一份。日常开发里我习惯把这类复用性强的功能都往模块化方向封装,后续维护成本低很多。

5.2 结合倒计时和业务校验

很多登录页的验证码旁边会有一个“60秒后重新发送”的按钮,这种倒计时其实和验证码刷新是两套逻辑:一个控制请求频率,一个控制图形变更。把两者拆开处理,代码会清晰很多。

另外,如果要跟后端联调,可以只需要改generateCode函数,改成异步请求,把后端返回的图片绘制或展示在画布上。前端原有的刷新交互逻辑和样式都不用动。

5.3 个人实践中的几点体会

做这个功能看起来是个小任务,但它很考验代码组织的条理性。连续实现几遍之后,我对“初始化逻辑”和“事件逻辑”分离这件事有了很深的体会:不要把初始渲染和事件触发合在一个函数里,否则每次刷新都要做两件事,容易产生副作用。

另外参数调优时,不要一次性把所有随机范围都改掉。改一个参数,刷新十次,观察效果,再改下一个。否则你很难分清到底是哪个参数导致了字符模糊或者重叠。

还有一个小技巧:每次刷新后,在控制台把当前验证码打印出来,方便调试。正式上线时记得删掉这行日志,否则等于把验证码白送给用户了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询