简介:一份面向初学者的jsQR二维码识别示例资源包,适合需要在Web页面中快速接入二维码扫描与解析功能的前端开发者。包内演示了如何通过纯JavaScript读取本地图片中的二维码,并输出识别结果;配套的HTML文件、jQuery库与两张测试二维码图片可帮助直接运行和验证效果。资源共包含5个文件,其中2个JavaScript脚本负责核心逻辑与依赖支撑,1个HTML页面提供可视化示例,另有2张JPG二维码测试图用于识别演练,整体压缩包仅79KB,轻巧易部署。目前已有1821人学习下载。通过对照示例代码,读者能了解jsQR的调用方式、Canvas图像数据处理流程以及常见识别问题的处理思路,可作为入门Web端二维码功能的参考模板。
1. 简单jsQR识别二维码例子:为什么说它是纯前端扫码最省事的一条路
打开电脑摄像头扫二维码,或者把一张带二维码的图片拖进网页就能识别出内容——这个需求听起来简单,但如果你打算用原生 JavaScript 写,会发现坑比想象的多。jsQR 是目前纯前端二维码识别方案里少有的「拿过来就能用」的库,它不需要编译、不需要后端参与,一个静态页面就能跑通全部逻辑。很多人第一反应是用微信内置的 JSSDK 或者接了第三方云识别服务,但这些方案要么依赖特定环境,要么会上传图片到服务器,在隐私敏感和离线场景里根本走不通。
这个例子真正解决的是「网页端本地识别二维码」这件事:摄像头扫码可以直接在 H5 页面里做,图片扫码只需要一个<input type="file">就能完成。适合的场景包括内部工具、移动端 H5 页面、需要自定义扫码界面的业务系统,以及不希望图片出本机的任何场景。后文我会从 jsQR 的原理讲起,然后分别给出图片识别和摄像头实时识别两套最小代码,最后把我在调试过程中遇到的红灯、白屏和玄学参数全部交代清楚。
2. jsQR 识别二维码的底层逻辑:灰度矩阵、定位与解码容错
2.1 为什么是 jsQR:纯 JavaScript 解码器与 ZXing 的取舍
jsQR 是一个纯 JavaScript 实现的二维码解码器,它接收的是图像数据而不是 DOM 元素。很多人刚开始会把 jsQR 和 ZXing、Quagga 混淆,其实它们的定位完全不同:ZXing 是 Java 生态的老牌库,虽然也有 JS 移植版,但体积和复杂度都偏高;Quagga 更偏向一维条码,二维码支持力度不稳定;而 jsQR 专门针对 QR Code 做了优化,API 只有一个jsQR(imageData, width, height, options),输入输出都极其简单。
选 jsQR 还有一个现实原因:它不依赖 WebAssembly,不需要处理跨域加载 wasm 文件的问题,直接引入一个 JS 文件就行。在微信内置浏览器、钉钉 WebView、普通 PC 浏览器里都能跑,兼容性比带 wasm 的方案好很多。缺点是解码速度比原生 wasm 方案慢一点,但在大多数场景下,200ms 级别的延迟完全够用。
2.2 图像数据从哪来:Canvas 的 getImageData 是唯一入口
jsQR 无法直接吃src或File对象,它只认ImageData。这意味着无论图片来自摄像头还是文件,你都必须先把图像画到 Canvas 上,再通过ctx.getImageData()拿到像素数组。这一步是整个流程里最容易翻车的地方,因为 Canvas 有同源限制和跨域污染问题,后面专门讲。
代码上,最典型的图片识别流程是这样:
const input = document.getElementById('fileInput'); input.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const result = jsQR(imageData.data, imageData.width, imageData.height); if (result) { console.log('识别结果:', result.data); } else { console.log('未识别到二维码'); } }; img.src = URL.createObjectURL(file); });这里有两个关键的参数说明。第一,getContext('2d', { willReadFrequently: true })是给浏览器一个提示:这个 Canvas 会频繁读取像素,让 Canvas 使用 CPU 后端而不是 GPU 后端,避免getImageData产生额外的拷贝开销。第二,URL.createObjectURL(file)必须配合revokeObjectURL使用,否则会把内存耗尽,我一般会在img.onload之后立即调用URL.revokeObjectURL(img.src)释放。
2.3 解码器的参数与容错:inversionAttempts 到底要不要开
jsQR 的第四个参数是options,其中最常见的配置项是inversionAttempts,它控制解码器是否尝试反色识别。默认值是"attemptBoth",也就是说在扫码结果为空时,会自动把图像反色再试一次。这个选项在浅色二维码、深色背景,或者摄像头拍到的反光二维码上非常有用,很多场景下能救命。
const result = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: "dontInvert", // 默认是 attemptBoth });参数取值有三个:dontInvert表示不反色,速度最快;attemptBoth先正常试一遍再反色试一遍,成功率最高;onlyInvert只反色识别,适用于确定是反色的图。我的建议是,性能敏感的摄像头场景用dontInvert,图片上传场景用默认的attemptBoth。因为反色识别等于做了两遍完整解码,帧率会明显下降。
另外还有一个容易被忽略的细节:jsQR 返回的是result.data字符串,但二维码内容有时是 URL,有时是纯文本,有时是 JSON。如果业务方给的码里带中文,要确认result.data的编码正常,jsQR 对 UTF-8 的支持没有大问题,但个别 GBK 编码的码可能乱码,这个没有银弹,只能在业务层做编码探测。
3. 用摄像头实时扫码:getUserMedia 与 requestAnimationFrame 的配合
3.1 最小摄像头扫码页面:一页 HTML 跑通所有逻辑
如果你的使用场景是「扫桌面上的二维码卡片」或「扫设备屏幕上的码」,摄像头实时识别是刚需。这里的最小实现只需要一个<video>标签、一个隐藏的 Canvas,再加一个requestAnimationFrame循环把每一帧画面喂给 jsQR。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>jsQR 摄像头扫码</title> </head> <body> <video id="video" style="width: 100%; max-width: 600px;" playsinline></video> <div id="result">等待识别...</div> <script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script> <script> const video = document.getElementById('video'); const resultDiv = document.getElementById('result'); async function startCamera() { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); video.srcObject = stream; await video.play(); requestAnimationFrame(tick); } function tick() { if (video.readyState === video.HAVE_ENOUGH_DATA) { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' }); if (code && code.data) { resultDiv.textContent = '识别成功: ' + code.data; } } requestAnimationFrame(tick); } startCamera().catch(err => { resultDiv.textContent = '摄像头错误: ' + err.message; }); </script> </body> </html>这里有几个参数值得专门说。facingMode: 'environment'是手机摄像头切换到后置的必需品,如果不设这个值,iPhone 上默认打开前置摄像头,体验直接崩。playsinline属性是给 iOS Safari 用的,不加上 iPhone 上 video 会强制全屏播放,导致摄像头画面在页面里看不见。video.readyState === HAVE_ENOUGH_DATA是判断当前帧是否已经完整渲染的经典手法,避免拿到半帧图像导致 jsQR 识别失败。
3.2 性能调优与画质取舍:为什么分辨率不是越高越好
把整个video的原始分辨率传给 jsQR 是最省事的写法,但也是最浪费性能的写法。摄像头输出往往是 1280x720 甚至 1920x1080,getImageData要处理几百万个像素,每一帧都要跑一遍,普通手机很容易发热掉帧。我在实际项目里的方案是:先用一个小 Canvas 把视频帧缩小到宽 480 像素,再交给 jsQR。
function tick() { if (video.readyState !== video.HAVE_ENOUGH_DATA) { requestAnimationFrame(tick); return; } const scale = 480 / video.videoWidth; const drawWidth = 480; const drawHeight = Math.round(video.videoHeight * scale); if (!scanCanvas) { scanCanvas = document.createElement('canvas'); scanCanvas.width = drawWidth; scanCanvas.height = drawHeight; } const ctx = scanCanvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(video, 0, 0, drawWidth, drawHeight); const imageData = ctx.getImageData(0, 0, drawWidth, drawHeight); const code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' }); if (code && code.data) { resultDiv.textContent = '识别成功: ' + code.data; } requestAnimationFrame(tick); }注意我把 Canvas 放在了tick外面声明,复用同一个 Canvas 而不是每帧新建。新建 Canvas 对象会在短时间内触发大量垃圾回收,表现就是页面周期性卡顿。缩小分辨率到 480 宽会不会影响识别精度?实际结论是,二维码距离摄像头适当时,480 宽已经足够让 jsQR 的定位算法找到三个角点;反而分辨率太高时画面噪点多、解码耗时暴涨,掉帧后用户手一抖码就出了画面,体验更差。
3.3 摄像头扫码的权限与降级处理
getUserMedia在 HTTP 非 localhost 环境下会被浏览器拒绝,这是个大前提。如果你在局域网部署调试页面,必须配 HTTPS 证书,或者用localhost访问才能调起摄像头。移动端微信内置浏览器比较特殊,部分版本不允许网页直接调摄像头,我遇到这种情况的解决办法是降级为「相册选择图片」模式,让用户先拍好照再选图识别。授权被拒后,getUserMedia会抛一个NotAllowedError,要捕捉这个错误并提示用户去设置里开权限。
另外,桌面浏览器在多个页面同时调用摄像头时会冲突。如果用户先开了另一个扫码页面,你的页面再调getUserMedia会拿到一个NotReadableError。这种错误要提示用户关闭其他占用摄像头的标签页,而不是直接报「浏览器不支持」。
4. 微信内置浏览器识别二维码的特殊处理:图片选择、长按识别与 H5 适配
4.1 微信内 H5 的摄像头限制与替代方案
微信内置浏览器(包括安卓微信和 iOS 微信)对getUserMedia的支持很不稳定。iOS 微信里,navigator.mediaDevices.getUserMedia存在但实际调用时会静默失败;安卓微信虽然能调起摄像头,但受到 X5 内核版本影响,兼容性差异极大。因此,在微信生态里做扫码,最常见的做法是让用户通过<input type="file" accept="image/*">打开相册选择或拍摄一张照片,再做静态图片识别。
<input type="file" accept="image/*" capture="environment" id="wxFileInput">capture="environment"这个属性在大多数安卓微信里会直接调起后置相机,iOS 微信里则会弹出选择菜单(相册/拍照),用户可以自己选。这个属性不是所有浏览器都支持,不支持时就自动退化为普通文件选择,不会报错。用这个方案替代摄像头扫码,代价是用户多一步操作,但成功率反而更高,因为静态图片通常清晰、无抖动、无反光。
4.2 用 jsQR 识别微信里选中的 H5 图片:EXIF 方向的问题
在微信 H5 里选图片识别,最容易踩的坑是图片方向。用 iPhone 拍的照片有时会带 EXIF 方向信息,比如你竖着拍的照片实际上存储为横向,靠 EXIF 里的Orientation字段来旋转显示。如果你直接把File对象转成Image再画到 Canvas,浏览器会自动应用 EXIF 方向,但image.width和image.height是原始像素尺寸,画出来的图像方向是正确的——这个方向问题看起来是解决的,但坑在于部分安卓手机的浏览器不会自动应用 EXIF,画到 Canvas 上的图就是躺着或倒着的,jsQR 对旋转 90 度的二维码识别率会下降很多。
解决这个问题有两个方向:一是用createImageBitmap配合imageOrientation: 'from-image'让浏览器帮你旋转,二是用现有的 EXIF 库读取方向后手动旋转 Canvas 绘制。前者是主流做法,代码量最小:
const file = e.target.files[0]; const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(bitmap, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const result = jsQR(imageData.data, imageData.width, imageData.height); bitmap.close();createImageBitmap的兼容性在 PC 现代浏览器上没问题,但在老版本安卓微信里可能不存在。我实际项目里的做法是先判断typeof createImageBitmap === 'function',存在就用它,不存在就回退到Image+drawImage,两套逻辑都不会让流程中断。
4.3 微信里识别失败时的提示策略
微信内选图识别还有一个用户体验上的隐性坑:用户从相册里选的图可能像素极高(现在的手机动不动就是 4000x6000)。直接把这么大一张图喂给 jsQR 会卡死。我一般会在画到 Canvas 前做一个降采样:如果长边超过 1500 像素,就等比缩小到 1500,这样既能保证识别速度,也不会因为压缩过度丢失二维码细节。同时,微信内置浏览器的canvas.toDataURL在部分版本里会有安全问题(getImageData 被 isPointInPath 污染),所以如果遇到「getImageData 报错」的情况,检查一下是不是 Canvas 被跨域图片污染了,解决办法是把图片先转成 data URL 再加载,或者直接用crossOrigin='anonymous'属性。
5. jsQR 识别二维码的避坑清单:方向、反色、模糊与多码问题
5.1 图片方向:横着放的二维码识别率骤降
现象:手机相册里横着拍的二维码图片,直接拖进网页识别经常失败。
原因:jsQR 内部的定位算法是基于二维码三个角点的几何关系做的,如果二维码整体旋转了 90 度或 180 度,角点之间的相对位置关系变了,解码器需要用额外的时间去尝试不同的方向,某些模糊不清的码在这种情况下就直接失败了。
解决:在喂给 jsQR 之前手动旋转图片,把方向修正为正立。最稳妥的办法是借助 EXIF 库读取方向,或者直接用createImageBitmap的from-image选项。如果没有条件做方向修正,至少要把inversionAttempts打开,因为方向错误时反色尝试有时能碰巧对齐。
5.2 反色二维码:白码黑底是 jsQR 的默认盲区
现象:二维码是白色图案、深色背景,jsQR 返回null。
原因:二维码标准本身是黑码白底,白码黑底属于「反色」变体,jsQR 默认会先按正常色识别,再按反色识别,之所以失败是因为部分反色码在反色后仍然会有对比度不足的问题。
解决:把inversionAttempts设置为onlyInvert强制反色识别。如果这样还是失败,问题就不在反色上,而是图像本身的对比度不够或者背景噪点太多,需要先做灰度增强再识别。我见过最典型的失败场景是用户拍了一张深色桌面上的白色二维码,手机自动 HDR 把背景提亮了,反色识别也没救回来,这种只能重新拍。
5.3 边缘不完整与模糊:识别失败的第一大原因
现象:打印的二维码贴在弧面上,扫码时边缘阴影遮挡了一部分,jsQR 一直null。
原因:二维码的三个定位角点必须清晰可见,任何一个角点被遮挡或模糊,解码器都无法建立坐标映射。jsQR 不会像 ZXing 那样输出「疑似二维码」的调试信息,它失败就是失败,没有任何中间态。
解决:调整距离让二维码整体进入画面,并且保证镜头对焦正确。模糊是二维码识别最大的杀手,哪怕分辨率足够高、角点完整,只要图像有轻微的运动模糊,jsQR 就可能抽风。我在摄像头方案里做过一个简单的清晰度判断:用ctx.getImageData取中间一块区域计算相邻像素灰度方差,方差低于阈值就跳过这一帧不识别,直接降低无效计算量。
5.4 一张图里有多个二维码:jsQR 只返回第一个
现象:一张海报上同时有主二维码和副二维码,jsQR 一次只识别出一个,且不一定是用户想要的那个。
原因:jsQR 的设计就是「找到第一个可解码的二维码就返回」,它不像 ZXing 那样支持返回多个结果。这是 API 层面的限制,不是参数能解决的。
解决:如果可以接受,就调整拍摄角度让目标二维码占画面主体;不行的话只能换 ZXing 的 JS 版本,或者自己用图像分割做暴力尝试。实战里另一个常见的情况是二维码旁边有装饰性的小码或水印码,jsQR 识别到了错误的那个,需要在业务层判断result.data是否符合预期格式,不符合就继续扫而不是直接返回给用户。
5.5 画布被跨域图片污染:getImageData 直接报 SecurityError
现象:你用http://协议的图片地址直接画到 Canvas 上,然后调用getImageData,控制台报SecurityError: The operation is insecure。
原因:Canvas 的内容被跨域资源污染后,浏览器禁止读取像素数据,这是安全策略,不是 jsQR 的问题。常见触发场景是直接拿一个外链图片 URL 来识别。
解决:三种处理方式任选其一。图片源加crossorigin="anonymous"且服务端返回Access-Control-Allow-Origin;或者用后端代理把图片转成同源;或者直接把图片转成 base64 data URL 再画。第三种最省事,只要有File对象就能做,但 base64 会比二进制体积大 33%,大图片要小心内存。
6. 把简单例子做成能用的组件:封装、校验与摄像头扫码体验优化
6.1 封装一个一次性的扫码函数
平时做项目我不会每次写一遍完整的识别逻辑,而是封成一个简单函数,需要时直接调用。这个函数接收一个File或Blob,内部自动完成降采样、解码、返回结果或抛错误:
async function decodeQrFromFile(file) { const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); const maxSide = 1500; const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height)); const width = Math.round(bitmap.width * scale); const height = Math.round(bitmap.height * scale); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(bitmap, 0, 0, width, height); const imageData = ctx.getImageData(0, 0, width, height); const result = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' }); bitmap.close(); // 手动触发垃圾回收的替代手段:bitmap.close() 是关键,否则内存会堆积 if (!result) { throw new Error('未识别到二维码'); } return result.data; }这个函数的参数说明:maxSide是图像长边的最大像素值,控制在 1500 以内可以有效降低解码耗时。scale用Math.min(1, ...)保证小图不会被放大,避免放大后的插值像素干扰解码。bitmap.close()是把createImageBitmap创建的位图占用的内存立即释放,这一步经常被忽略,连续识别几十张图片后页面会变得非常卡。
6.2 校验二维码内容:识别出来不代表能用
识别成功不代表业务校验通过。很多码的内容是一个字符串,比如https://example.com/device/123,但你的业务只接受特定前缀的 URL。我在扫码逻辑里一定会在result.data返回后做一次内容格式校验,不符合就直接提示用户「扫码内容无效」并继续监听下一次扫码。常见的校验方式有URL 解析、正则匹配、JSON.parse三种,按业务需求选。
function validateQrContent(data) { if (data.startsWith('http://') || data.startsWith('https://')) { try { const url = new URL(data); if (url.hostname === 'example.com') return url.pathname; } catch (e) { return null; } } return null; }这个步骤能挡住一大半「扫到但没用」的码,也避免了误识别后给用户弹莫名其妙的界面。做设备绑定类业务的时候,二维码内容可能是一串设备序列号,还需要配合哈希校验或签名校验来防伪造,jsQR 只负责解码,不负责信任。
6.3 摄像头扫码的体验优化:扫码框、灯光反馈与防重复触发
摄像头实时扫码场景里,识别到二维码后如果不做处理,requestAnimationFrame循环会连续触发多次识别,弹出多个结果。我的做法是识别成功后立即设置一个locked标志位,停止识别的同时进行 UI 反馈,用户完成业务操作后再解锁:
let locked = false; function tick() { if (locked) { requestAnimationFrame(tick); return; } const result = scanFrame(); if (result) { locked = true; // 播放提示音、震动、高亮扫码框 navigator.vibrate && navigator.vibrate(100); handleResult(result); } requestAnimationFrame(tick); }navigator.vibrate在安卓 Chrome 和微信浏览器里有效,iOS 上无效但也不会报错,所以可以放心调用。扫码框的 UI 层我一般用 CSS 在 video 上叠一个绝对定位的半透明框,提示用户把码放在框内识别,但真做了会发现用户永远不太会把码对准框,所以不如把整个画面作为识别区域,扫码框只起到心理暗示作用。
6.4 验证你封装的组件:用什么图片测试最可靠
我自己验证decodeQrFromFile的时候,会准备一组固定测试图片:一张标准黑底二维码、一张反色二维码、一张旋转 45 度的二维码、一张带噪声的小尺寸二维码。标准码用来确认主流程通;反色码用来确认inversionAttempts生效;旋转码用来确认方向修正没问题;噪声码用来确认弱光场景不会崩。这四张图全部通过后,再上摄像头实测。
最后一个建议:jsQR 这个库已经稳定了很多年,API 变化非常小,1.4.0 左右的版本足够用。如果你的项目是纯前端且不需要多码识别,我认为 jsQR 就是最优解。如果你需要同时识别多个二维码或者对解码速度有极端要求,再去考虑 ZXing 的 wasm 版本。我在项目里用 jsQR 做过设备绑定扫码、仓库入库校验、日常打卡签到,踩坑基本都集中在图像输入侧而不是解码器本身,把图像预处理做好,这个方案能覆盖九成以上的扫码需求。希望这些经验和避坑清单对你有所帮助。
参考链接:
- jsQR 官方 GitHub 仓库:
cozmo/jsQR,其中包含完整的 API 文档与示例 - MDN Web Docs -
CanvasRenderingContext2D.getImageData():详细说明像素读取的安全限制 - MDN Web Docs -
MediaDevices.getUserMedia():摄像头调用的权限与错误处理
本文还有配套的精品资源,点击获取