
简介一个可直接运行的二维码识别示例项目基于jsQR纯JavaScript库编写面向希望在Web页面中快速集成扫码功能的初学者。压缩包共5个文件2个JS脚本jsQR核心识别库与jQuery辅助、2张二维码测试图片和1个HTML页面整体仅79KB结构精简便于对照学习。jsQR不依赖任何外部API或在线服务离线环境也能正常使用示例完整演示了从用户选择本地图片、用FileReader读取并转为DataURL、在Canvas上绘制、再通过getImageData获取像素数据最终调用jsQR识别并输出二维码内容的完整流程并在页面中展示识别出的文本结果。目前已吸引1819人学习下载。读者可从作者的处理细节中了解图像清晰度对识别率的影响、错误处理与性能优化等注意事项还能直接修改示例代码集成到自己的纯前端应用中非常适合作为纯前端二维码识别功能的入门模板与参考项目。1. 一个本地图片里的二维码为什么还要专门写代码识别手机里随手拍的二维码传到电脑上想提取内容最常见的做法是打开微信扫一扫或者在线识别网站上传图片。但放到实际业务里这两种方式都接不进来识别结果拿不到浏览器上下文里图片数据也得出本机遇到内网环境或者敏感数据就行不通了。jsQR解决的就是这个场景——一个纯 JavaScript 实现的二维码识别库把图片像素数据传入jsQR()函数识别过程完全在浏览器本地完成不请求接口不依赖后端服务。适合刚接触前端图像处理的开发者也适合需要在管理系统、运维后台里临时加一个二维码读取入口的场景。这套方案的核心不是调用一个库那么简单而是要把图片文件、canvas、ImageData 这条像素链路打通本文就把这条链路拆开讲清楚。2. jsQR识别前的像素数据链路ImageData与canvas2.1 jsQR函数签名与返回结果结构jsQR 的核心入口是jsQR(imageData, width, height)三个参数分别对应 RGBA 像素序列、图像宽度、图像高度。很多第一次用的人会直接传入 base64 字符串或者图片路径拿到undefined后开始怀疑库有问题。实际上 jsQR 完全不关心图片是怎么来的它只认Uint8ClampedArray类型的像素数据也就是 canvas 的getImageData()返回结果里那个data字段。看一个最常见的误用// 错误示例直接把 dataURL 传给 jsQR const base64 canvas.toDataURL(image/jpeg); const result jsQR(base64, canvas.width, canvas.height); // result 永远是 null这里jsQR收到的第一个参数是字符串不是像素数组内部逻辑直接因类型不符导致识别失败。正确的数据流是先通过ctx.drawImage()把图片绘入 canvas再用ctx.getImageData()取出像素序列最后才交给jsQR。识别成功时返回对象的结构大致如下{ data: https://example.com/qr/12345, binaryData: Uint8Array(36), chunks: [{ type: 4, text: ... }], version: 3, location: { topLeftCorner: { x: 32, y: 40 }, topRightCorner: { x: 268, y: 36 }, bottomLeftCorner: { x: 30, y: 270 }, bottomRightCorner: { x: 264, y: 268 }, topLeftFinderPattern: { x: 45, y: 53 }, topRightFinderPattern: { x: 255, y: 49 }, bottomLeftFinderPattern: { x: 43, y: 257 } } }data是解码出的字符串内容location里保存了二维码三个定位角点在原图中的坐标。这些坐标对后续在摄像头画面里绘制扫码框、判断二维码是否在画面中央非常有用后面章节会用到。2.2 从 File 到 ImageData 的转换路径浏览器里拿到一个图片File对象要转成ImageData常见路径是FileReaderImagecanvas。项目里的 1.html 用的就是这个思路整体时序可以拆成四个步骤监听input[typefile]的change事件拿到File对象用FileReader.readAsDataURL把文件读成 dataURL创建Image对象把 dataURL 赋给img.src等待onload在onload中设置 canvas 尺寸drawImage绘制getImageData取像素这一步有个常见坑img.onload是异步回调而canvas.getImageData必须在图片完全绘制后才能调用否则取出来的是透明画布jsQR 什么都识别不到。另外canvas 尺寸如果和图片自然尺寸不一致getImageData取到的宽度高度要和drawImage绘制后的画布尺寸严格对应传入jsQR时用imageData.width而不是img.naturalWidth。除了FileReader更新的浏览器支持createImageBitmap(file)可以直接从文件生成ImageBitmap再通过ctx.drawImage(bitmap, 0, 0)绘入 canvas。这种方式避免了 dataURL 字符串的中间转换内存占用更低但 Safari 部分版本支持不完整项目如果面向内部工具可以优先用createImageBitmap否则沿用FileReader兼容性最好。2.3 尺寸、采样与识别成功率的直接关系二维码识别本质上是在二值化后的像素矩阵里寻找定位图形和模块网格。jsQR 内部会对图像做灰度化、阈值分割、特征扫描这些步骤的精度取决于每个模块在图像里占据的像素数。一个 QR 码通常有 21x21 到 177x177 个模块如果图片里整个二维码只有 80x80 像素平均每个模块不到 4 个像素采样点稍微偏移就会读取到错误的模块值识别自然失败。实际测试时把同一张二维码分别缩放到不同尺寸识别结果差异非常明显。下面是一个参考对比二维码在图片中的宽度单个模块约合像素识别表现小于 100px不足 5px极易失败依赖图片本身清晰度100px ~ 200px5 ~ 10px多数情况可识别倾斜或模糊时偶尔失败200px ~ 500px10 ~ 24px识别稳定可容忍轻微失真大于 500px24px 以上对算法最友好但图像过大会拖慢解码速度理想的图片应当让二维码主体至少占图片宽度的三分之一。如果图片分辨率过大例如 4000px 宽的照片直接塞进 canvas 再getImageData一帧 4000x3000 的 RGBA 数据约 48MBjsQR 扫描的时间会明显变长。常见的做法是绘制前用 canvas 先把长边缩放到 1000px 到 1500px 左右既能保留足够的模块像素又不会让解码耗时太夸张。这个缩放逻辑在后续静态识别和摄像头识别里都会用到。3. 搭一个可复用的静态图片二维码识别页面3.1 资源文件与依赖引入这个项目的解压目录里包含jsQR.js、jquery-3.4.1.min.js、1.html、QR.jpg和QR1.jpg。QR.jpg和QR1.jpg是两张测试图片jsQR.js是库本体页面里通过script标签直接引入。jquery-3.4.1.min.js在这个例子中主要用来简化 DOM 操作实际上识别逻辑本身不依赖 jQuery原生document.getElementById同样能完成。HTML 骨架可以这样组织!DOCTYPE html html langzh-CN head meta charsetUTF-8 titlejsQR 识别二维码示例/title script srcjquery-3.4.1.min.js/script script srcjsQR.js/script /head body input typefile idimageInput acceptimage/* / canvas idcanvas styledisplay:none;/canvas p idresult识别结果/p script // 识别逻辑代码 /script /body /html页面加载时先引入 jQuery 和 jsQR 两个库canvas元素不用显示出来它只作为像素数据的临时画布。input的acceptimage/*可以过滤掉非图片文件但用户在文件选择窗口里仍可能手动切到所有文件所以后续代码里最好加上文件类型判断。3.2 核心识别流程实现下面这段代码覆盖了从文件选择到结果展示的完整流程直接放在script标签里即可运行。$(function () { $(#imageInput).on(change, function (event) { // 1. 取文件并校验类型 var file event.target.files[0]; if (!file || file.type.indexOf(image/) ! 0) { $(#result).text(请选择图片文件); return; } // 2. 用 FileReader 将文件读成 dataURL var reader new FileReader(); reader.onload function (e) { var img new Image(); img.onload function () { // 3. 按最大宽度缩放绘制避免超大图片影响性能 var MAX_WIDTH 1200; var scale Math.min(1, MAX_WIDTH / img.width); var canvas document.getElementById(canvas); canvas.width Math.round(img.width * scale); canvas.height Math.round(img.height * scale); var ctx canvas.getContext(2d, { willReadFrequently: true }); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // 4. 取像素数据给 jsQR 识别 var imageData ctx.getImageData(0, 0, canvas.width, canvas.height); var codeResult jsQR(imageData.data, imageData.width, imageData.height); if (codeResult codeResult.data) { $(#result).text(识别结果 codeResult.data); } else { $(#result).text(未识别到二维码请换一张清晰的图片重试); } }; img.src e.target.result; }; reader.readAsDataURL(file); }); });这段代码有四个关键点。第一行file.type.indexOf(image/) ! 0用来拦截非图片文件如果选择了一个 .txt 文件readAsDataURL也可以执行但 canvas 绘制时拿到的图片宽高都是 0getImageData会抛错。第三步的scale计算用的是Math.min(1, ...)意思是图片宽本身小于 1200px 时保持原尺寸不放大。最后getImageData取的是缩放后画布的数据imageData.width和canvas.width必然一致不需要额外记忆尺寸。getContext(2d, { willReadFrequently: true })是 Canvas 2D 的一个可选参数它告诉浏览器这个绘图上下文会频繁调用getImageData内部会切换到更适合像素读取的存储方式。对 jsQR 这种每帧都要取像素的场景性能提升明显静态图片识别里影响不大但摄像头识别阶段必须加上。3.3 返回值字段解析与多码场景说明jsQR返回对象的chunks字段存放的是二维码原始数据段的拆分每个 chunk 有type和text属性。type取值对应不同的字节模式例如 0 表示数字模式1 表示字母数字模式4 表示字节模式。大多数 URL、文本内容都属于字节模式。data字段是 jsQR 内部合并所有 chunk 后得到的完整文本日常使用直接读data即可。这里要特别注意jsQR 一次只返回一个二维码结果即使画面里同时存在两个二维码它也只会返回它认为最可信的那一个。如果需要解析图片中的多个二维码需要在无法识别到单个结果时对图片的不同区域做裁剪分别调用jsQR。例如把图片按九宫格切块每块独立识别// 把图片分成 3x3 九块逐块识别 var cols 3; var rows 3; var blockW Math.floor(canvas.width / cols); var blockH Math.floor(canvas.height / rows); for (var r 0; r rows; r) { for (var c 0; c cols; c) { var blockImageData ctx.getImageData(c * blockW, r * blockH, blockW, blockH); var blockResult jsQR(blockImageData.data, blockImageData.width, blockImageData.height); if (blockResult) { // 记录二维码内容以及它在原图中的大致区域 console.log(第 (r * cols c 1) 块:, blockResult.data, 位置:, { x: c * blockW, y: r * blockH }); } } }这种分块策略的缺点是边界上的二维码可能被切断导致两个分块里都识别不到。位置不固定的场景最好用滑动窗口窗口步长设为窗口宽度的一半牺牲一点性能换取覆盖率。而如果确认图片里只会有一个二维码直接调用一次jsQR就够了。4. 图片质量不够时先做预处理对比度、缩放与重绘4.1 常见识别失败原因与定位思路jsQR 识别失败时返回null但它的算法能力不等于扫不出内容就是图片坏了。根据实际使用情况失败原因通常集中在四类第一类是图片分辨率不足二维码整体像素面积太小模块采样点不够第二类是光照不均匀图片有一部分过亮一部分过暗全局阈值分割后模块边界消失第三类是拍摄角度造成的透视畸变二维码本身是梯形而非矩形定位图形的相对位置变形严重第四类是图片上有遮挡、反光或噪点比如金属表面反光、透明塑料膜上的划痕。定位问题最直接的手段是把识别前的 canvas 数据保存成图片再人工查看。可以用canvas.toDataURL()转成 base64 后塞入一个新窗口确认 canvas 里实际画进去的内容和原始文件是否一致。很多时候用户上传的是右键下载的二维码和原文件一致但手机翻拍屏幕的照片会有摩尔纹这类图片在 canvas 缩放后纹理仍然存在jsQR 的特征扫描会受到干扰。下面这个检查思路在排错时比较高效先在 canvas 里绘制原图并getImageData直接调用jsQR失败时输出 canvas 的宽高和图片自然宽高确认绘制尺寸把 canvas 导出为 PNG人工检查图片内容、亮度、拍摄角度检查图片是否为反色二维码白底黑码变黑底白码jsQR 默认只识别黑模块对亮背景反色需要先做像素反转4.2 在canvas层面完成灰度与对比度增强jsQR 内部虽然会自行做灰度化但轻微的对比度调整能改善阈值分割的效果。Canvas 2D 的ctx.filter属性提供了一条低成本路径在绘制时直接对图像应用contrast和brightness变换// 先设置 filter再执行 drawImage滤镜会作用到绘制结果上 ctx.filter contrast(1.4) brightness(0.95); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // 恢复默认 filter避免影响后续区域截取 ctx.filter none; var imageData ctx.getImageData(0, 0, canvas.width, canvas.height); var result jsQR(imageData.data, imageData.width, imageData.height);contrast(1.4)表示把对比度提高到原来的 1.4 倍brightness(0.95)表示亮度微降适用于拍摄时偏亮的图片。如果图片本身正常不要加 filter因为过强的对比度会把浅灰模块直接推成白块丢失信息。这个参数没有万能值测试图片可以从 1.2 起步每次增 0.1直到识别成功或图像出现明显失真。ctx.filter的兼容性问题需要注意Firefox 和 Chrome 支持良好Safari 18 以下不支持该属性传了也会被忽略不会报错所以可以放心写在代码里大不了不生效不会让程序崩溃。更底层的做法是手动遍历imageData.data做像素运算例如把每个像素的 RGB 按亮度公式0.299R 0.587G 0.114B计算后重新赋值这种方式可控性更强但性能远低于 GPU 加速的 filter 实现静态图片还好摄像头实时处理时每帧几百万次运算会让主线程明显卡顿。4.3 用缩放与居中重绘提高低分辨率二维码的识别率低分辨率图片放大后原本模糊的模块边界更加模糊应该先做一次去噪再做阈值处理。在没有额外图像库的情况下快速去噪的办法是用 canvas 先把图缩小再放大这个操作叫降采样它会把相邻像素的噪声平均掉。具体做法是先把图片绘制到原尺寸四分之一的临时 canvas 上再从这个临时 canvas 绘制回目标尺寸两次缩放中浏览器会执行插值平滑噪声被明显抑制。// 第一步把原图缩到宽高的一半 var tempCanvas document.createElement(canvas); tempCanvas.width Math.max(64, Math.floor(img.width / 2)); tempCanvas.height Math.max(64, Math.floor(img.height / 2)); var tempCtx tempCanvas.getContext(2d, { willReadFrequently: true }); tempCtx.drawImage(img, 0, 0, tempCanvas.width, tempCanvas.height); // 第二步从临时 canvas 绘制回目标 canvas此时有一定平滑效果 var canvas document.getElementById(canvas); canvas.width img.width; canvas.height img.height; var ctx canvas.getContext(2d, { willReadFrequently: true }); ctx.imageSmoothingEnabled true; ctx.imageSmoothingQuality high; ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height);这里imageSmoothingQuality high控制缩放插值质量取值有low、medium、highhigh在高倍放大时效果最好但耗时也最长。静态图片识别一般只处理一张图选high没问题摄像头识别不建议走这条链路除非画面里二维码离镜头特别远。需要强调的是这类预处理不是越重越好。一个拍摄清晰的二维码直接识别成功率最高任何额外的缩放和滤镜都可能引入新的偏差。预处理只建议在初次识别失败后作为重试策略加入而不是默认执行路径。实际项目里可以这样设计识别流程直接识别一次失败后做缩放平滑再识别一次再失败时做对比度增强最多尝试三轮。5. 把静态识别升级成摄像头实时扫码5.1 getUserMedia打开摄像头并抽帧和静态图片识别相比摄像头扫码的差异在于帧的获取方式。getUserMedia返回一个MediaStream把它赋值给video元素后视频流会自动播放但 jsQR 不能直接吃 video仍然要借助 canvas 抽取当前帧。抽取的时机很关键video.readyState为 4 表示HAVE_ENOUGH_DATA此时视频已有足够数据可以绘制。navigator.mediaDevices.getUserMedia({ video: { facingMode: environment, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }).then(function (stream) { var video document.getElementById(video); video.srcObject stream; video.setAttribute(playsinline, true); requestAnimationFrame(tick); }).catch(function (err) { console.error(摄像头打开失败:, err); }); function tick() { if (video.readyState 4) { // 关键绘制宽度要按视频实际尺寸设置 var canvas document.getElementById(canvas); canvas.width video.videoWidth; canvas.height video.videoHeight; var ctx canvas.getContext(2d, { willReadFrequently: true }); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); var imageData ctx.getImageData(0, 0, canvas.width, canvas.height); var result jsQR(imageData.data, imageData.width, imageData.height); if (result result.data) { console.log(扫码成功:, result.data); // 命中后停止识别循环 return; } } requestAnimationFrame(tick); }facingMode: environment指示浏览器优先使用后置摄像头这个参数只对移动设备有意义桌面浏览器通常只有一个摄像头直接忽略。playsinline是 iOS Safari 上必须设置的属性否则视频流会强制进入全屏播放canvas 画出来的画面是黑的。识别成功后在回调里return退出requestAnimationFrame循环同时可以调用stream.getTracks().forEach(t t.stop())关闭摄像头避免页面黑屏后仍占用设备权限。5.2 识别循环与防抖实时识别里最大的问题是结果抖动二维码被扫到后每一帧都可能返回相同内容如果立即触发业务动作页面会在几十毫秒内重复提交。常见的做法是加一个状态锁在真正处理完一次结果之前不再处理新结果。var lastResult null; var processing false; function tick() { if (video.readyState 4 !processing) { canvas.width video.videoWidth; canvas.height video.videoHeight; var ctx canvas.getContext(2d, { willReadFrequently: true }); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); var imageData ctx.getImageData(0, 0, canvas.width, canvas.height); var result jsQR(imageData.data, imageData.width, imageData.height); if (result result.data) { // 和上一次结果不同才触发业务逻辑 if (lastResult ! result.data) { lastResult result.data; processing true; handleScanSuccess(result.data); } } } requestAnimationFrame(tick); }lastResult记录最近一次识别出的内容processing标志位防止异步处理期间继续重复触发。这里注意handleScanSuccess如果涉及页面跳转或接口请求应在该函数里处理完后将processing重置为false。帧率不需要刻意控制requestAnimationFrame本身会跟随屏幕刷新率正常在 60 帧左右canvas 取图和getImageData在同一帧内完成后即使重复绘制也不会造成视频卡顿。对扫码这个场景还可以把video.videoWidth固定在一个合理范围再缩放绘制例如画布宽 640px、高按比例计算既能加快识别速度又对识别精度没有明显损失。加上这一层状态锁后整套识别循环在连续画面中只会响应一次有效内容摄像头扫码功能就可以直接接入到业务流程里。本文还有配套的精品资源点击获取