简介这是一份面向前端与跨端开发者的H5扫码功能实现资源围绕jsQR与html5-qrcode两个JavaScript库解决浏览器端无需安装插件即可读取二维码的问题并兼顾uni-app在H5环境下的集成需求。资源包共43个文件约488KB以13个ts与9个js源码为主体配合6个json配置、4个vue组件、2个html入口及png、scss、css等静态资源覆盖从库文件引入到页面调用的完整结构。内容涉及视频流获取、逐帧图像解析、扫码回调处理、错误捕获与HTTPS环境要求等关键环节并包含扫码框与提示交互的实现思路。目前已有3595人学习下载适合希望快速搭建H5扫码能力、理解二维码解码流程或排查uni-app兼容问题的开发者参考可据此对照目录结构梳理工程组织方式减少从零调试的成本。1. 从一次线上事故说起h5 扫码到底能不能扛住真实业务去年双十一前夜某电商团队的一个 H5 活动页崩了。用户点「扫码领券」按钮安卓机正常iOS 上要么黑屏、要么摄像头起不来、要么扫半天识别不出二维码。值班的 A 同学连夜排查最后发现是调用了系统getUserMedia却没处理 iOS Safari 的权限时序问题。这类翻车在 h5 扫码场景里太常见了——它不像原生 App 有成熟的扫码 SDK浏览器环境里摄像头权限、视频流渲染、解码性能全是玄学。h5 扫码的本质是在浏览器里通过navigator.mediaDevices.getUserMedia拿到摄像头视频流把video的每一帧画到canvas上再用 JS 解码库从像素数据里还原二维码内容。目前主流方案就两个jsQR和html5-qrcode。前者是一个纯解码库只负责「给我一帧图像数据我还你二维码字符串」后者是封装好的完整组件自带摄像头调用、UI 渲染、扫描循环。在 uni-app 的 H5 端做扫码绕不开这两个东西。这篇笔记就把它们拆开讲清楚各自适合什么场景、参数怎么调、坑在哪、怎么组合用最稳。2. jsQR 与 html5-qrcode 的选型逻辑什么时候用哪个2.1 jsQR 的定位只做解码其余你自己控jsQR 是一个纯 JavaScript 的二维码解码库核心 API 就一个函数import jsQR from jsqr; // imageData 必须是 Uint8ClampedArray来自 canvas.getImageData() const code jsQR(imageData, width, height, { inversionAttempts: dontInvert, // 或 attemptBoth / invertFirst }); if (code) { console.log(code.data); // 二维码内容字符串 console.log(code.location); // 四个角点坐标 }它不碰摄像头、不碰 DOM、不管渲染。你给它一帧 RGBA 像素数组它返回解码结果或null。这种「无侵入」设计的好处是你可以完全控制取帧频率、裁剪区域、图像预处理灰度化、二值化、锐化适合对性能或识别率有极致要求的场景。代价是摄像头调用、视频渲染、扫描循环、错误处理全得自己写。inversionAttempts这个参数值得单独说。二维码有「黑底白块」和「白底黑块」两种默认attemptBoth会尝试两种模式识别率最高但耗时翻倍。如果你的场景二维码都是标准白底黑块设成dontInvert能省一半 CPU。我一般会在移动端设dontInvert桌面端设attemptBoth。2.2 html5-qrcode 的定位开箱即用但定制空间有限html5-qrcode 把上面那套流程全封装了。你只需要给一个容器 ID调start()就完事import { Html5Qrcode } from html5-qrcode; const scanner new Html5Qrcode(reader); scanner.start( { facingMode: environment }, // 优先后置摄像头 { fps: 10, // 每秒取帧次数 qrbox: { width: 250, height: 250 }, // 扫描框区域 aspectRatio: 1.0, }, (decodedText, decodedResult) { console.log(扫码结果:, decodedText); scanner.stop(); // 扫到就停避免重复回调 }, (errorMessage) { // 每帧没识别到都会进这里别在这里弹 toast } );它内部默认用的解码器就是 jsQR也支持 BarcodeDetector但兼容性差。fps控制取帧频率设太高费电、设太低识别慢移动端 10 帧是甜点值。qrbox限定扫描区域缩小它能显著提升识别速度因为参与解码的像素少了。2.3 选型对照表维度jsQRhtml5-qrcode职责纯解码摄像头渲染解码全包包体积约 40KB约 300KB定制能力完全可控受限于配置项上手成本高需自己搭管线低几行代码跑起来适合场景需要图像预处理、多码识别、性能敏感快速上线、标准扫码需求uni-app H5 兼容需自行处理 video/canvas直接可用但要注意容器渲染时机我的经验是如果只是「点按钮→扫码→拿结果」的标准流程直接用 html5-qrcode省下的时间够你调别的 bug。如果你要做「相册选图识别」「多二维码同时识别」「低光照增强」这类进阶需求用 jsQR 自己搭管线更灵活。下面两章分别把两条路走通。3. 用 html5-qrcode 在 uni-app H5 端跑通扫码从零到可用3.1 环境准备与依赖安装uni-app 项目里用 npm 装依赖注意 H5 端和 App 端、小程序端的差异。html5-qrcode 只能在 H5 端用App 端要用uni.scanCode小程序端要用wx.scanCode。所以代码里必须做条件编译。# 在 uni-app 项目根目录执行 npm install html5-qrcode --save装完后检查package.json确认版本号。html5-qrcode 的 2.x 版本 API 和 1.x 有差异网上很多老教程用的是 1.x 的Html5QrcodeScanner类2.x 推荐用Html5Qrcode类手动控制生命周期。我一般锁 2.3.x稳定。3.2 页面结构与条件编译在 uni-app 的.vue文件里H5 端需要一个真实的 DOM 容器给 html5-qrcode 挂载。注意 uni-app 的view编译到 H5 会变成uni-viewhtml5-qrcode 内部用document.getElementById找容器所以容器必须能被原生 DOM API 拿到。template view classscan-page !-- #ifdef H5 -- view idqr-reader classqr-reader/view button clickstartScan v-if!scanning开始扫码/button button clickstopScan v-else停止扫码/button !-- #endif -- !-- #ifndef H5 -- button clicknativeScan调用原生扫码/button !-- #endif -- /view /template#ifdef H5是 uni-app 的条件编译语法保证这段代码只在 H5 端编译。idqr-reader就是 html5-qrcode 要挂载的容器。容器必须有明确的宽高否则视频流渲染出来是 0 尺寸表现为「黑屏」。3.3 启动扫码的完整逻辑// #ifdef H5 import { Html5Qrcode } from html5-qrcode; // #endif export default { data() { return { scanner: null, scanning: false, }; }, methods: { async startScan() { // #ifdef H5 // 1. 先检查浏览器是否支持 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { uni.showToast({ title: 当前浏览器不支持摄像头, icon: none }); return; } // 2. 实例化传入容器 id this.scanner new Html5Qrcode(qr-reader); // 3. 配置参数 const config { fps: 10, qrbox: (viewfinderWidth, viewfinderHeight) { // 动态计算扫描框取短边的 70% const minEdge Math.min(viewfinderWidth, viewfinderHeight); const size Math.floor(minEdge * 0.7); return { width: size, height: size }; }, aspectRatio: 1.0, disableFlip: false, // 允许镜像翻转识别 }; try { await this.scanner.start( { facingMode: environment }, config, (decodedText) { // 成功回调 this.scanning false; this.scanner.stop().then(() { this.scanner.clear(); }); uni.showModal({ title: 扫码结果, content: decodedText, showCancel: false, }); }, (errorMsg) { // 失败回调每帧未识别都会触发不要在这里做 UI 反馈 } ); this.scanning true; } catch (err) { // 摄像头权限被拒、设备无摄像头等 console.error(启动扫码失败:, err); uni.showToast({ title: 无法启动摄像头, icon: none }); } // #endif }, async stopScan() { // #ifdef H5 if (this.scanner) { await this.scanner.stop(); this.scanner.clear(); this.scanning false; } // #endif }, }, beforeDestroy() { // 页面销毁时必须停掉摄像头否则摄像头指示灯一直亮 this.stopScan(); }, };这段代码有几个关键点。第一start()是异步的必须await否则拿不到权限结果。第二成功回调里先stop()再clear()stop()释放摄像头clear()清空容器 DOM两个都做才干净。第三失败回调每帧都会触发千万别在里面弹 toast否则一秒弹十次。第四beforeDestroy里必须停摄像头这是血泪经验——很多开发者忘了这步用户退出页面后摄像头指示灯还亮着被投诉「偷拍」。3.4 参数调优与场景适配fps设 10 是通用值。如果二维码很小或距离远可以降到 5让每帧有更多时间解码如果追求快速识别可以升到 15但中低端安卓机会发热。qrbox用函数动态计算比写死数值好。写死{ width: 250, height: 250 }在不同屏幕尺寸上表现不一致小屏上框太大、大屏上框太小。用短边 70% 是个稳妥比例。aspectRatio设 1.0 让视频区域呈正方形视觉上最像原生扫码。设成1.777会变宽屏但二维码在画面里会显得小。disableFlip默认false允许识别镜像二维码。前置摄像头扫码时画面是镜像的这个参数必须为false才能识别。如果你只用后置摄像头设true能省一点性能。4. 用 jsQR 自建解码管线相册识别与多码场景4.1 从视频流到 ImageData 的完整链路jsQR 需要的是Uint8ClampedArray格式的 RGBA 像素数据。从摄像头到这一步中间要经过 video → canvas → getImageData 三步。import jsQR from jsqr; class QrDecoder { constructor(videoElement) { this.video videoElement; this.canvas document.createElement(canvas); this.ctx this.canvas.getContext(2d, { willReadFrequently: true }); this.running false; } async initCamera() { const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: environment, width: { ideal: 1280 }, height: { ideal: 720 }, }, audio: false, }); this.video.srcObject stream; this.video.setAttribute(playsinline, true); // iOS 必须 await this.video.play(); } startDecodeLoop(onResult) { this.running true; const tick () { if (!this.running) return; if (this.video.readyState this.video.HAVE_ENOUGH_DATA) { // 按视频实际尺寸设置 canvas this.canvas.width this.video.videoWidth; this.canvas.height this.video.videoHeight; this.ctx.drawImage(this.video, 0, 0); const imageData this.ctx.getImageData( 0, 0, this.canvas.width, this.canvas.height ); const code jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: dontInvert, }); if (code) { onResult(code.data, code.location); } } requestAnimationFrame(tick); }; tick(); } stop() { this.running false; const stream this.video.srcObject; if (stream) { stream.getTracks().forEach((track) track.stop()); this.video.srcObject null; } } }willReadFrequently: true这个 canvas 上下文参数很关键。它告诉浏览器这个 canvas 会被频繁读取像素浏览器会优化成 CPU 内存而非 GPU 纹理getImageData的耗时会降一个数量级。不加这个参数中低端机上每帧getImageData可能耗时 30ms 以上直接拖垮帧率。playsinline属性是 iOS Safari 的硬性要求。不加的话iOS 上视频会强制全屏播放你的扫码 UI 全乱套。4.2 相册选图识别绕过摄像头jsQR 的另一个优势是能处理静态图片。用户从相册选一张二维码截图你把它画到 canvas 上再解码完全不需要摄像头权限。async function decodeFromFile(file) { return new Promise((resolve, reject) { 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 code jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: attemptBoth, // 相册图不确定底色两种都试 }); if (code) { resolve(code.data); } else { reject(new Error(未识别到二维码)); } }; img.onerror () reject(new Error(图片加载失败)); img.src URL.createObjectURL(file); }); }相册图用attemptBoth更稳因为用户截图可能来自深色模式二维码是白块黑底。代价是解码耗时翻倍但静态图只解一次无所谓。4.3 多码识别与区域裁剪jsQR 一次只返回一个码。要识别画面里多个二维码得自己切分区域对每个子区域分别调用 jsQR。function decodeMultiple(imageData, width, height, gridSize 2) { const results []; const cellW Math.floor(width / gridSize); const cellH Math.floor(height / gridSize); for (let row 0; row gridSize; row) { for (let col 0; col gridSize; col) { const x col * cellW; const y row * cellH; // 从完整 imageData 里裁剪子区域 const subData new Uint8ClampedArray(cellW * cellH * 4); for (let i 0; i cellH; i) { const srcStart ((y i) * width x) * 4; const srcEnd srcStart cellW * 4; subData.set(imageData.slice(srcStart, srcEnd), i * cellW * 4); } const code jsQR(subData, cellW, cellH, { inversionAttempts: dontInvert, }); if (code) { results.push({ data: code.data, location: code.location }); } } } return results; }gridSize设 2 就是把画面切成 2×2 四个区域。切得越细小二维码越容易被识别但总解码次数按平方增长性能下降明显。实际用 2 或 3 就够了。裁剪时注意slice的起止索引是按整行宽度算的别算错否则图像会错位表现为「明明有码却识别不出」。5. 避坑与排查h5 扫码最常见的五个翻车现场5.1 iOS 上摄像头黑屏安卓正常现象安卓机扫码正常iOS Safari 或微信内置浏览器里视频区域全黑控制台无报错。原因iOS 要求video元素必须有playsinline和muted属性且play()必须在用户手势如 click的同步调用栈里触发。如果你在await getUserMedia之后再调play()手势上下文已经丢失iOS 会拒绝播放。解决把video.setAttribute(playsinline, true)和video.muted true写死并且把play()放在用户点击事件的直接回调里不要包在多层await之后。如果必须等权限可以先播放一个空流占位。5.2 扫码回调触发多次业务逻辑重复执行现象扫到一个码成功回调被调了三四次订单创建了多笔。原因html5-qrcode 的fps是 10意味着每秒解码 10 次。二维码在画面里停留 0.5 秒成功回调就会触发 5 次。jsQR 自建管线同理requestAnimationFrame每帧都在解。解决在成功回调里立刻加一个「已处理」标志位或者直接stop()扫码。我一般用let handled false在回调开头判断处理完设true。更稳妥的做法是扫到后先停摄像头业务处理完再决定是否重启。5.3 微信内置浏览器权限被拒后无法再次唤起现象用户第一次点了「拒绝」摄像头权限之后再点扫码按钮直接进失败回调连权限弹窗都不出了。原因浏览器会记住用户的拒绝决定同一域名下不会再次弹窗。这是浏览器行为JS 无法强制重新弹窗。解决检测到NotAllowedError时引导用户去浏览器设置里手动开启权限。微信里可以提示「点击右上角→设置→允许摄像头」。同时提供一个「从相册选择」的降级方案用 jsQR 解静态图绕开摄像头权限。5.4 低端安卓机扫码卡顿、发热严重现象千元机上扫码页面卡成幻灯片手机背面烫手。原因getImageData每帧读取全分辨率像素加上 jsQR 解码CPU 满载。如果fps还设了 15 或更高雪上加霜。解决三招组合。第一fps降到 58。第二getUserMedia的width/height设ideal: 640别用 1280解码 640×480 比 1280×720 快四倍。第三canvas 上下文加willReadFrequently: true。这三招下去千元机也能跑顺。5.5 uni-app H5 端容器渲染时机不对报「元素未找到」现象new Html5Qrcode(qr-reader)报错说找不到 id 为qr-reader的元素。原因uni-app 的view编译到 H5 有渲染延迟在onLoad或created里直接初始化DOM 还没挂载完。解决把初始化逻辑放到mounted或$nextTick里。如果是弹窗里的扫码要等弹窗动画结束、DOM 真正插入后再初始化。我一般用setTimeout(..., 300)兜底虽然不优雅但管用。6. 进阶技巧把识别率从 70% 拉到 95% 的图像预处理标准 jsQR 在光线充足、二维码清晰时识别率不错但真实场景里经常遇到反光、模糊、倾斜、低对比度。直接丢原始帧给 jsQR识别率可能只有七成。加一层轻量图像预处理能拉到九成五以上。核心思路是「灰度化 对比度拉伸 自适应二值化」。jsQR 内部其实会做二值化但它用的是全局阈值遇到光照不均就歇菜。我们手动做局部自适应二值化把图像变成纯黑白再喂给它。function preprocessImageData(imageData, width, height) { const data imageData.data; const gray new Uint8ClampedArray(width * height); // 第一步灰度化用人眼感知加权 for (let i 0; i data.length; i 4) { gray[i / 4] 0.299 * data[i] 0.587 * data[i 1] 0.114 * data[i 2]; } // 第二步局部自适应二值化窗口 15x15 const windowSize 15; const half Math.floor(windowSize / 2); const output new Uint8ClampedArray(width * height * 4); for (let y 0; y height; y) { for (let x 0; x width; x) { // 计算窗口内均值 let sum 0; let count 0; for (let wy -half; wy half; wy) { for (let wx -half; wx half; wx) { const ny y wy; const nx x wx; if (ny 0 ny height nx 0 nx width) { sum gray[ny * width nx]; count; } } } const mean sum / count; const idx (y * width x) * 4; // 低于局部均值 10 的算黑否则算白 const val gray[y * width x] mean - 10 ? 0 : 255; output[idx] output[idx 1] output[idx 2] val; output[idx 3] 255; } } return new ImageData(output, width, height); }这段代码的windowSize和阈值偏移10是两个可调参数。窗口越大对光照渐变的容忍度越高但计算越慢窗口 15 是性能和效果的平衡点。阈值偏移10控制黑白分界反光严重的场景可以调到15让更多像素被判为黑。预处理完再调 jsQRconst processed preprocessImageData(imageData, width, height); const code jsQR(processed.data, width, height, { inversionAttempts: dontInvert, });注意预处理本身有开销15×15 窗口的均值计算是 O(width × height × 225)在 640×480 上大约 5ms可以接受。如果嫌慢可以先把图像缩到 320×240 再预处理识别率损失很小但速度快四倍。还有一个技巧是「多帧投票」。同一二维码连续三帧都解出相同结果才认为有效能过滤掉偶发的误识别。实现很简单维护一个最近结果的数组出现次数超过阈值才触发业务回调。从那以后我每次接 h5 扫码需求都强制走一遍「权限检测 → 降级方案 → 预处理开关 → 多帧投票」这套流程再也没在活动上线前夜被叫起来过。希望帮到你。本文还有配套的精品资源点击获取