
简介一份面向初学者的jsQR二维码识别示例资源包适合需要在Web页面中快速接入二维码扫描与解析功能的前端开发者。包内演示了如何通过纯JavaScript读取本地图片中的二维码并输出识别结果配套的HTML文件、jQuery库与两张测试二维码图片可帮助直接运行和验证效果。资源共包含5个文件其中2个JavaScript脚本负责核心逻辑与依赖支撑1个HTML页面提供可视化示例另有2张JPG二维码测试图用于识别演练整体压缩包仅79KB轻巧易部署。目前已有1821人学习下载。通过对照示例代码读者能了解jsQR的调用方式、Canvas图像数据处理流程以及常见识别问题的处理思路可作为入门Web端二维码功能的参考模板。1. 简单jsQR识别二维码例子为什么说它是纯前端扫码最省事的一条路打开电脑摄像头扫二维码或者把一张带二维码的图片拖进网页就能识别出内容——这个需求听起来简单但如果你打算用原生 JavaScript 写会发现坑比想象的多。jsQR 是目前纯前端二维码识别方案里少有的「拿过来就能用」的库它不需要编译、不需要后端参与一个静态页面就能跑通全部逻辑。很多人第一反应是用微信内置的 JSSDK 或者接了第三方云识别服务但这些方案要么依赖特定环境要么会上传图片到服务器在隐私敏感和离线场景里根本走不通。这个例子真正解决的是「网页端本地识别二维码」这件事摄像头扫码可以直接在 H5 页面里做图片扫码只需要一个input typefile就能完成。适合的场景包括内部工具、移动端 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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titlejsQR 摄像头扫码/title /head body video idvideo stylewidth: 100%; max-width: 600px; playsinline/video div idresult等待识别.../div script srchttps://cdn.jsdelivr.net/npm/jsqr1.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 甚至 1920x1080getImageData要处理几百万个像素每一帧都要跑一遍普通手机很容易发热掉帧。我在实际项目里的方案是先用一个小 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 typefile acceptimage/*打开相册选择或拍摄一张照片再做静态图片识别。input typefile acceptimage/* captureenvironment idwxFileInputcaptureenvironment这个属性在大多数安卓微信里会直接调起后置相机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存在就用它不存在就回退到ImagedrawImage两套逻辑都不会让流程中断。4.3 微信里识别失败时的提示策略微信内选图识别还有一个用户体验上的隐性坑用户从相册里选的图可能像素极高现在的手机动不动就是 4000x6000。直接把这么大一张图喂给 jsQR 会卡死。我一般会在画到 Canvas 前做一个降采样如果长边超过 1500 像素就等比缩小到 1500这样既能保证识别速度也不会因为压缩过度丢失二维码细节。同时微信内置浏览器的canvas.toDataURL在部分版本里会有安全问题getImageData 被 isPointInPath 污染所以如果遇到「getImageData 报错」的情况检查一下是不是 Canvas 被跨域图片污染了解决办法是把图片先转成 data URL 再加载或者直接用crossOriginanonymous属性。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 来识别。解决三种处理方式任选其一。图片源加crossoriginanonymous且服务端返回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()摄像头调用的权限与错误处理本文还有配套的精品资源点击获取