简介:这是一款基于原生网页技术实现的手势密码解锁插件,模拟手机九宫格手势绘制逻辑,面向需要为网页添加安全解锁或交互验证功能的前端开发者。插件密码由三乘三点阵组成,支持九位数字密码验证,代码独立、不依赖复杂框架,可直接嵌入现有项目或独立运行,适合快速实现图形密码锁场景。压缩包共二十三个文件,包含样式表、示例页面、脚本逻辑,以及字体图标、演示图片、说明文档等辅助资源。包体仅三点七六兆,结构清晰,便于按需修改样式或扩展功能。目前已有八百三十八人学习下载,实用性得到初步验证。附完整源码和可直接预览的演示页面,同时提供基础脚本及精简压缩版本,开发者可对照示例快速理解功能调用方式,轻松定制连线颜色、节点样式和验证规则,节省从零搭建手势密码模块的时间。
1. 手势密码解锁不是轮子,是移动端 H5 的入场券
HTML 手势密码解锁插件的价值,不在于“画 9 个点连成线”这个动作本身,而在于它把“九宫格坐标计算、触摸事件序列、Canvas 动态绘制、结果校验”四件事一次性封装好,让前端工程师不必从零写一套手写轨迹识别逻辑。这解释了一个常见的错觉:很多人以为找插件就是找一段现成代码贴上去,实际上这类插件的真正成本在“事件时序的正确性”上——手指按下、移动、松开三个阶段,每个阶段在 iOS Safari 和 Android WebView 上的表现都不同。
如果你正在做移动端 H5 的登录保护、个人中心二次验证、或者后台管理系统的锁屏界面,这个插件能直接省掉 2 到 3 天的联调时间。插件源码通常包含核心解锁面板的 JavaScript 逻辑、CSS 样式,以及一个可运行的 HTML 示例页面,适合那些不想依赖 jQuery 等大库、希望用原生 JavaScript 实现并便于二次开发的场景。
2. 先看清插件源码的主干结构与核心接口
2.1 从 index.html 读起:插件的最简调用方式
拿到源码后先别急着看 JavaScript 文件,从 index.html 入口读是最快的理解路径。一个标准的手势密码插件,在 HTML 层面往往只有寥寥几行核心代码:
<!-- index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>手势密码解锁示例</title> <link rel="stylesheet" href="css/pattern-lock.css"> </head> <body> <div id="lock-container"></div> <script src="js/pattern-lock.js"></script> <script> var lock = new PatternLock('#lock-container', { width: 300, height: 300, onComplete: function(pattern) { // pattern 是用户绘制轨迹对应的数字序列 if (pattern.join('') === '01345678') { console.log('密码正确,解锁成功'); } else { console.log('密码错误,请重试'); } } }); </script> </body> </html>这段代码的核心价值在于揭示了插件的基本协作模式:容器选择器驱动初始化进而回调函数输出结果。#lock-container是插件挂载的 DOM 容器,PatternLock是构造函数,onComplete在所有触摸抬起后触发(touchup 后调用),并把绘制轨迹转成数字数组。
参数方面,width 和 height 决定解锁面板的物理尺寸,数字序列的映射规则通常以九宫格左上角为 0、右下角为 8。理解这段调用代码后,后续读源码的逻辑就不容易绕晕。
2.2 追踪 PatternLock 构造函数:Canvas 初始化与事件绑定如何协作
深入源码里的 pattern-lock.js,构造函数是核心部分:
// pattern-lock.js function PatternLock(container, options) { // 合并默认配置 this.options = Object.assign({ width: 300, height: 300, radius: 20, // 圆点的半径 connectColor: '#3498db', // 连线颜色 borderColor: '#aaa', // 边界颜色 enableCache: true, // 开启 Canvas 缓存,提升重绘性能 onComplete: function() {} // 绘制完成的回调 }, options); this.container = typeof container === 'string' ? document.querySelector(container) : container; // 标准化 3x3 九个圆点的坐标(单位:px) this.points = []; this.initCanvas(); this.bindEvents(); this.cacheCanvas = null; if (this.options.enableCache) { this.initCacheCanvas(); } }从这段源码中能读出两个关键工程决策:坐标与尺寸解耦、Canvas 缓存与事件机制分离。坐标初始化在 initCanvas 里做,事件的绑定在 bindEvents 中单独声明,这种分离让后续扩展手势位数、点的密度变得容易。
enableCache是个值得注意的参数——把它打开后,插件会预先绘制一份静止状态(全部圆点)的 Canvas 缓存,只在用户交互阶段重绘连线轨迹。这在低端 Android WebView 上能明显降低绘制开销,省掉每次 touchmove 全量重绘导致的掉帧。
2.3 解析 0-8 编号密码的坐标换算算法
PatternLock.prototype.initCanvas = function() { this.canvas = document.createElement('canvas'); this.ctx = this.canvas.getContext('2d'); this.container.appendChild(this.canvas); // 适配设备像素比,防止 Canvas 在高 DPR 屏上模糊 var dpr = window.devicePixelRatio || 1; var rect = this.container.getBoundingClientRect(); var width = rect.width || this.options.width; var height = rect.height || this.options.height; this.canvas.width = width * dpr; this.canvas.height = height * dpr; this.canvas.style.width = width + 'px'; this.canvas.style.height = height + 'px'; this.ctx.scale(dpr, dpr); this.width = width; this.height = height; // 计算圆心坐标,留出边界 padding var midX = width / 2; var midY = height / 2; var spacing = Math.min(width, height) / 4; // 相邻圆点间距 var radius = this.options.radius; this.points = []; for (var row = 0; row < 3; row++) { for (var col = 0; col < 3; col++) { this.points.push({ x: midX + (col - 1) * spacing, y: midY + (row - 1) * spacing, id: row * 3 + col, // 编号:0-8 radius: radius }); } } };坐标换算的逻辑核心是归一化布局:midX + (col - 1) * spacing让三列圆点围绕容器中心对称展开。无论容器是 280px 还是 350px,圆点都会自动居中,间距按容器短边四等分,保证九个圆点的分布不随屏幕变化。
devicePixelRatio的判断处理很容易被忽略,但手势密码这类以“像素级”精确触摸判定为主的交互,如果忽略它,实际触摸区域与绘制区域之间的偏差会直接导致用户轨迹被误判。代码中先放大 Canvas 绘图缓冲,再用 CSS 样式缩放回布局尺寸,配合ctx.scale(dpr, dpr)让图形与触摸区域完全对齐——这个做法值得替换到任何 Canvas 插件上。
3. 解锁交互全流程:从 touchstart 到轨迹序列输出
3.1 touchstart 捕获候选点:命中检测的两层判断
React 事件机制不适用于这里的精确坐标命中检测,源码在手势密码插件里的通常做法是直接订阅原生触摸事件。在 bindEvents 方法中可以看到这样的绑定:
PatternLock.prototype.bindEvents = function() { var self = this; this.canvas.addEventListener('touchstart', function(e) { e.preventDefault(); var pos = self.getEventPosition(e); var point = self.findPoint(pos.x, pos.y); if (point) { self.startPoint = point; self.currentPath = [point]; self.drawLine(point.x, point.y, point.x, point.y); } }, { passive: false }); this.canvas.addEventListener('touchmove', function(e) { e.preventDefault(); if (!self.startPoint) return; var pos = self.getEventPosition(e); var point = self.findPoint(pos.x, pos.y); if (point && self.currentPath.indexOf(point) === -1) { self.currentPath.push(point); } self.drawLine(self.currentPath, pos.x, pos.y); }, { passive: false }); this.canvas.addEventListener('touchend', function(e) { e.preventDefault(); if (!self.startPoint) return; self.finish(); }, { passive: false }); };事件监听里{ passive: false }必须定义清楚,移动端浏览器从 Chrome 56 开始默认把 touchstart/touchmove 视为passive: true,即不阻塞主线程的页面滚动——但这意味着preventDefault()会直接不生效。手势密码面板在页面滚动容器内部出现时,如果该参数缺失,会出现手指在屏幕上拖动时页面跟着滚动的“交互撕裂”。源码里显式声明passive: false,说明对移动端兼容性是充分考虑的。
命中检测的 findPoint 方法在这里承担了正确找出手指当前触达圆点的任务:
PatternLock.prototype.findPoint = function(x, y) { var self = this; // 先把坐标转换为相对 canvas 左上角的偏移 // getEventPosition 里已经处理了 getBoundingClientRect 差值 for (var i = 0; i < this.points.length; i++) { var p = this.points[i]; var dx = x - p.x; var dy = y - p.y; var distance = Math.sqrt(dx * dx + dy * dy); // 命中条件:距离小于圆的半径 + 15px 的容错 if (distance < p.radius + 15) { return p; } } return null; };注意那额外的+15容错值,作用比表面看起来更重要。人在快速滑动时手指的实际触点波动范围不小,半径 20px 的圆点对应直径 40px 的命中区域,加上 15px 后命中区域扩大到 70px,这个参数值的具体设定影响了“流畅感”与“误触率”的平衡——太大容易出现跨点误连,太小则用户需要精准经过圆点中心才可被识别。源码通常允许通过 options 暴露这个hitSlop参数,让你根据目标设备的屏幕密度做微调。这里我建议把它理解成关键技术参数,在真机调试时调试它往往是一次接入体验的关键分水岭。
3.2 touchmove 补充轨迹:绘制实时连线的性能取舍
轨迹绘制的核心代码通常会这样组织:
PatternLock.prototype.drawLine = function(path, moveX, moveY) { var ctx = this.ctx; ctx.clearRect(0, 0, this.width, this.height); // 先绘制缓存底图,避免重绘全部圆点 if (this.options.enableCache && this.cacheCanvas) { ctx.drawImage(this.cacheCanvas, 0, 0, this.width, this.height); } else { this.drawPoints(); // 绘制静态圆点(非缓存模式才走这里) } // 绘制连线段 ctx.beginPath(); ctx.strokeStyle = this.options.connectColor; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (var i = 0; i < path.length; i++) { var p = path[i]; if (i === 0) { ctx.moveTo(p.x, p.y); } else { ctx.lineTo(p.x, p.y); } } // 如果移动位置超过最后一个点,则追加一条到当前手指位置的线段 if (moveX !== undefined && moveY !== undefined) { ctx.lineTo(moveX, moveY); } ctx.stroke(); // 绘制已选中圆点的填充效果 ctx.fillStyle = this.options.connectColor; for (var j = 0; j < path.length; j++) { ctx.beginPath(); ctx.arc(path[j].x, path[j].y, path[j].radius - 5, 0, Math.PI * 2); ctx.fill(); } };一个高效的性能策略在这里体现——缓存静态层 + 只重绘变化层。手势密码的静止状态(九个圆点与边框)在整个绘制过程中不变化,如果每次 touchmove 都重绘九个圆点和它们的圆的边框样式,会产生大量不必要的 Canvas 状态切换。缓存 Canvas 的初始化方式如下:
PatternLock.prototype.initCacheCanvas = function() { this.cacheCanvas = document.createElement('canvas'); this.cacheCanvas.width = this.canvas.width; this.cacheCanvas.height = this.canvas.height; var cacheCtx = this.cacheCanvas.getContext('2d'); cacheCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); // 绘制静态底图(圆点边框与灰色圆圈) for (var i = 0; i < this.points.length; i++) { var p = this.points[i]; cacheCtx.beginPath(); cacheCtx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); cacheCtx.fillStyle = '#fff'; cacheCtx.fill(); cacheCtx.strokeStyle = this.options.borderColor; cacheCtx.lineWidth = 2; cacheCtx.stroke(); } };延时对比:例如九宫格场景下,一次性绘制九圆点加九个描边的指令数大约是 4 条/点,36 条指令在 60Hz 的刷新率下其实开销不恐怖,但加上每次 drawLine 时的弧形路径构建与状态切换,持续绘制时 WebView 的帧时间会迅速升高。若在低端安卓机上表现更明显——Canvas 绘制背景图的开销小于重绘矢量圆点,这也是插件值得一提的工程收口。
3.3 touchend 输出序列:判重与间隔合法性检验
touchend 触发的 finish 方法里有一条容易忽略的代码走向:
PatternLock.prototype.finish = function() { var sequence = []; for (var i = 0; i < this.currentPath.length; i++) { sequence.push(this.currentPath[i].id); } // 安全检查:最少连接数必须大于等于4,否则视为无效 if (sequence.length < 4) { this.reset(); return; } this.onComplete(sequence); this.reset(); };把“至少连接四个点”的判断放在回调触发之前是业内惯例,因为 Android 原生锁屏的规则即为至少四点。但在 Web 插件中,很多实现为了演示效果放弃了这个约束,导致用户画两个点也触发回调。源码如果你的使用场景需要兼容“至少 N 个点”,可以把这里的4改为在 options 中声明minPoints: 4并从这个配置读取。
另一个容易忽略但真正影响密码强度的点是判重逻辑——在 touchmove 的self.currentPath.indexOf(point) === -1检查中,用户手指经过同一点两次时,路径数组不会重复记录。这一行为结果使手势密码的“无向性”暴露出来:从点 0 画到点 2 与从点 2 画回点 0 的序列是相同的,这在语义上等同于密码丢失了方向信息。若你的业务对安全等级要求更高,需要考虑记录包含方向的坐标变换,不过主流的九宫格解锁设计本身已经接受这一限制。
4. 把插件接进业务:配置项调优与实用拓展
4.1 可配置参数速查表与推荐值
阅读源码时把散落在各处的 options 默认值聚合起来,能得到一份可直接用于业务接入的配置映射表。依据常见插件的实现,表结构大致如下:
| 配置项 | 类型 | 默认值 | 说明 | 推荐场景值 |
|---|---|---|---|---|
| width | Number | 容器宽度 | 面板宽度(px) | 移动端设 300-320 |
| height | Number | 容器高度 | 面板高度(px) | 与 width 同步 |
| radius | Number | 20 | 圆点外圆半径 | 高密度屏调至 22-25 |
| hitSlop | Number | 15 | 触摸命中容错半径 | 老人模式调至 20 |
| connectColor | String | '#3498db' | 连线的颜色 | 与主题色统一 |
| borderColor | String | '#aaa' | 圆点边界颜色 | 深色主题调亮 |
| enableCache | Boolean | true | 是否启用静态层缓存 | 低端安卓保留 true |
| minPoints | Number | 4 | 最少连接节点数 | 高安全场景设 5 |
| onComplete | Function | 空函数 | 绘制结束回调 | 业务必须覆盖 |
需要注意 width/height 与 CSS 的关系——如果容器本身设置了固定宽高,插件内getBoundingClientRect的返回值将取代 options.width 生效。很多接入方忘记了这一点,在容器上加了 padding 或百分比宽度后,发现绘制出来的面板尺寸与预期不一致,误以为是插件有 Bug,实则是对优先级的理解错位。
4.2 校验逻辑与手势密码持久化存储方案
onComplete 回调里拿到的 pattern 是一组有序数字数组,比如[0, 3, 4, 5, 2]。在业务联调中,推荐把校验逻辑封装成独立模块,与插件层解耦:
// password-service.js var PatternService = (function() { // 存储 key 区分不同业务场景 var STORAGE_KEY = 'app_pattern_lock_v1'; function digest(pattern) { // 简单做一次字符拼接,实际项目应使用加密摘要 return pattern.join('-'); } return { save: function(pattern) { var value = digest(pattern); localStorage.setItem(STORAGE_KEY, value); }, verify: function(pattern, callback) { var stored = localStorage.getItem(STORAGE_KEY); if (!stored) { callback({ valid: false, message: '未设置手势密码' }); return; } var input = digest(pattern); callback({ valid: stored === input, message: stored === input ? '验证通过' : '密码错误' }); }, clear: function() { localStorage.removeItem(STORAGE_KEY); } }; })();存储时的摘要字段不可省。手势密码的输入空间是 9 个数字的排列组合(不重复、最少 4 位),理论组合数在万级——直接明文存入 localStorage 的话,任何 XSS 注入或其他可访问本地存储的脚本都能直接读取密码序列,后续为这个选项付出成倍的代价。
使用时机也不容忽视:onComplete回调里断言“第一设置/验证”状态来决定是调用 save 还是 verify。建议在回调中先判断当前业务态是“创建密码”还是“输入密码”,再对应调用服务层方法,不要在回调内部堆业务逻辑。
4.3 二次开发扩展:换个格子数、换个图案样式的改法
阅读源码如果只停在“会调用”层面,遇到设计还原度略高的需求还是会卡住。插件常见的二次开发切入点有三个。
第一个是修改格子数。原本的 3x3 九宫格结构由initCanvas中的双重循环硬编码为 0/1/2。要改成 4x4 十六宫格,需要把循环边界从 3 改为 4,再加上spacing的计算逻辑调整:
var rowCount = 4; var colCount = 4; var spacingX = this.width / (colCount + 1); // 各留一个间距,圆点落在等分线上 var spacingY = this.height / (rowCount + 1); for (var row = 0; row < rowCount; row++) { for (var col = 0; col < colCount; col++) { this.points.push({ x: spacingX * (col + 1), y: spacingY * (row + 1), id: row * colCount + col, radius: radius }); } }这种等分间距算法比使用mid + (col - 1) * spacing的写法更通用,换行列数时不需额外调整公式。
第二个是圆点视觉样式。默认的圆形边框 + 填充色可以通过替换initCacheCanvas中的绘制函数改成多边形、图标或图片。例如换成圆角方形时,用ctx.roundRect()替代ctx.arc(),注意 Safari 12 以下不支持 roundRect 方法,需自己用 lineTo 拼。视觉改版的边界条件是 DPR 适配逻辑保持不变。
第三个是加入时间戳校验。部分业务需要手势密码带时效性,比如“3 秒内完成绘制才有效”。可以在 finish 方法中记录touchend时间与touchstart时间差:
PatternLock.prototype.finish = function() { var elapsed = Date.now() - this.startTime; if (elapsed > this.options.maxDuration) { this.reset(); return; } // ... 原有逻辑 };再在touchstart事件里补上this.startTime = Date.now()。这种做法在保护数据敏感的业务中很常见,源码在此基础上改动的成本只有几行。
5. 跨端兼容性排查与验证技巧
5.1 iOS Safari 与 Android WebView 的 3 类典型差异
手势密码插件在真机上的问题几乎都源自触摸事件与坐标计算的差异。第一类差异是touchmove的触发频率,iOS 在 Safari 中 touchmove 对preventDefault()的响应存在一种“首次拖动延迟”,视觉表现为手指移动后线段不跟手,约 50ms 后才开始出现轨迹。解决方案上,前端常规的做法是在 touchstart 时调用canvas.focus()并手动触发scroll事件的 preventDefault,或者在 CSS 上给容器加touch-action: none:
#lock-container { touch-action: none; }touch-action: none同时覆盖了 Safari 与 Chrome 对触摸滚动的响应,是接入时最坚实的基础配置。
第二类差异是坐标计算中的视觉视口偏移。iOS Safari 的地址栏收起、展开时 viewport 尺寸变化,getBoundingClientRect()返回的坐标会随视觉视口变化而偏移。推荐在 getEventPosition 方法中用e.touches[0].clientX直接做定位,而不用pageX/pageY:
PatternLock.prototype.getEventPosition = function(e) { var touch = e.touches[0]; var rect = this.canvas.getBoundingClientRect(); return { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; };clientX 相对浏览器可视区域的左上角,与 getBoundingClientRect 基于同一坐标标准,避免了滚动偏移之后的换算问题。
第三类差异是 Android WebView 的 click 事件 300ms 延迟。依赖触摸事件而不是 click 的手势密码插件本身不受 300ms 延迟影响,但如果接入方在面板容器外嵌套了点击触发的按钮,在部分老版本 WebView(Android 7 及以下)中按钮的响应会明显拖沓。这种情况下给容器加<meta name="viewport" content="width=device-width, initial-scale=1">可以自动消除双击缩放导致的延迟。
5.2 自动化回放验证手势轨迹的有效做法
手势密码是纯人工交互功能,但回归测试时不能靠人画几百次。用脚本在 Canvas 上派发模拟触摸事件可以自动验证轨迹生成的正确性:
// test-pattern-lock.js function simulateGesture(canvas, points) { var rect = canvas.getBoundingClientRect(); var dpr = window.devicePixelRatio || 1; // 读取插件内部 points 数组(假设挂载在实例上) var internalPoints = lock.points; var coords = points.map(function(id) { return { x: rect.left + internalPoints[id].x, y: rect.top + internalPoints[id].y }; }); var opts = { bubbles: true, cancelable: true, touches: [] }; opts.touches = [{ clientX: coords[0].x, clientY: coords[0].y }]; canvas.dispatchEvent(new TouchEvent('touchstart', opts)); for (var i = 1; i < coords.length; i++) { opts.touches = [{ clientX: coords[i].x, clientY: coords[i].y }]; canvas.dispatchEvent(new TouchEvent('touchmove', opts)); } opts.touches = []; canvas.dispatchEvent(new TouchEvent('touchend', opts)); }记忆技巧在于事件的touches属性在派发时必须以 Touch 对象填充,而不是简单的 JS 对象。标准浏览器构造器需要调用new Touch({ identifier: 1, target: canvas, clientX: x, clientY: y }),上述缩略写法在 Chrome DevTools 的模拟触摸环境下可运行。如果使用 Playwright,可以直接调用page.touchscreen.tap结合坐标,但同样需要计算坐标偏移。
自动化验证的价值在于把“绘制轨迹为 01345678 时回调收到的序列也是 01345678”这条逻辑固化为回归基线。实际操作中发现 grep 验证回调参数最直接:在测试体里包一层:
var receivedPattern = null; lock.options.onComplete = function(p) { receivedPattern = p; }; // 触发 simulateGesture 后断言 receivedPattern 的预期值5.3 常见报错与提示对照
黑盒排查场景汇总成一张表,方便接入时对照处理:
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| Canvas 未渲染,容器内空白 | 容器宽高为 0 | 给容器设显式宽高,或使用绝对定位撑开 |
| 连线轨迹与手指不同步 | 缺少touch-action: none | 在容器 CSS 中加入该声明 |
| 点几个点后报错 touchend 不触发 | Canvas 外接的按钮挡住了 touch 事件 | 检查 z-index 与 pointer-events |
| 回调返回的数组长度总是 1 | touchmove 事件未收到 | 确认监听器没有用 passive: false 短路 |
| iOS 上面板模糊 | 缺少 DPR 适配 | 检查 initCanvas 中 scale 是否执行 |
| 缓存底图偏色 | cacheCanvas 的 scale 与主 canvas 不一致 | 两处都设置ctx.scale(dpr, dpr) |
排错时的第一动作建议别看控制台日志,而是先查canvas.width和getBoundingClientRect().width是否相等。如果 2 倍缩放的显示尺寸与逻辑尺寸不符,后面所有的坐标计算都是错位的地基。这类问题在热词里常被讨论成“白屏/点不上”,本质几乎都出在这一步。
本文还有配套的精品资源,点击获取