ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JS调用摄像头与二维码识别:H5扫码功能开发全记录

JS调用摄像头与二维码识别:H5扫码功能开发全记录 简介在移动Web开发中调用手机摄像头识别二维码是支付验证、扫码登录、信息分享等场景的常见需求。对于具备一定JS基础、希望快速实现跨平台扫码功能的前端开发者这份资源提供了经调试可用的完整示例压缩包内共3个文件包含一个HTML入口页面、一个jQuery依赖库和一个二维码识别核心库整体仅134KB轻量易部署适合嵌入现有业务。HTML页面完整演示了摄像头预览、权限申请和实时识别流程识别逻辑基于成熟的JS扫码插件可直接套用到现有项目中目录结构清晰便于按需替换或升级识别算法对二次开发比较友好。需要特别留意的是受浏览器安全策略限制摄像头功能必须在HTTPS环境下才能正常调起资源标题已明确提醒该要点示例代码也按此规范编写能帮读者避开常见的权限失效问题。目前已有175人学习下载适合移动端Web开发、扫码功能调试与学习参考。1. 手机扫码绕不开的“摄像头调用 二维码识别”组合把手机摄像头调起来、再让 JS 识别出二维码听起来是个标准的 H5 功能但真做起来坑比想象的多。开发过的人都知道这个场景有两条硬约束用户必须用 HTTPS 访问否则浏览器直接拒绝摄像头权限Android 上不同厂商的 WebView 对getUserMedia的支持又五花八门同一个页面在 Chrome 里好好的换到微信内置浏览器就黑屏。这篇笔记把「JS 调用摄像头 二维码识别」的完整链路拆开从权限获取、视频流绑定到识别库选型一步步落代码最后把调试时踩过的坑全部列出来——适合正在做 H5 扫码页、或者想把扫码功能塞进现有 Web 项目的人参考。2. 摄像头调用与二维码识别先搞懂底层再动手写代码2.1getUserMedia的权限模型不是你想调就能调浏览器要把摄像头画面给你核心 API 是navigator.mediaDevices.getUserMedia()。但很多人在第一步就翻车用file://协议打开页面直接报NotAllowedError原因很简单——这个 API 只在安全上下文里可用也就是 HTTPS 或localhost。权限流大致是这样页面请求video权限浏览器弹窗让用户选择“允许”如果用户拒绝过一次浏览器会记住状态下一次getUserMedia不再弹窗直接抛NotAllowedError。安卓 Chrome 上用户还能在地址栏右侧重新开启权限但微信内置 WebView 里这个入口不一定存在用户只能清缓存或者换浏览器。代码层面获取视频流的标准写法是async function getCameraStream() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error(当前浏览器不支持 getUserMedia请使用 HTTPS 访问); } const constraints { video: { facingMode: environment, // 后置摄像头自拍用 user width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }; try { const stream await navigator.mediaDevices.getUserMedia(constraints); return stream; } catch (err) { // err.name 可能是 NotAllowedError / NotFoundError / NotReadableError throw new Error(摄像头调用失败: ${err.name} - ${err.message}); } }facingMode用environment表示优先调后置摄像头这样扫码时对焦距离更合理。width和height传ideal值表示“最理想是这个分辨率但允许浏览器降级”这样在低端安卓机上不会因为分辨率过高导致卡顿。audio: false是因为扫码不需要收音否则会额外触发麦克风权限用户一看要授权两个东西容易产生顾虑。拿到stream之后把它绑定到video标签上const video document.getElementById(scanner-video); video.srcObject stream; video.setAttribute(playsinline, true); // iOS Safari 必须加 video.play();playsinline这行很重要iOS Safari 上不加它视频会进入全屏播放模式扫码画面的布局就乱了。Android 的 Chrome 和 WebView 一般不用加但加了也无害。2.2 识别库选型jsQR 与 ZXing 的取舍摄像头画面拿到以后二维码识别可以交给专门的库。主流的方案有两个jsQR和ZXing-js各有侧重。jsQR是一个纯 JS 实现的二维码解码器体积在 40KB 左右gzip 后更小输出的是一个简单的{ data, location }结构。它的解码逻辑依赖 Canvas 的像素数据也就是把视频帧画到 canvas 上再取getImageData()喂给jsQR()函数。缺点是每个像素都参与计算视频分辨率太高时解码延迟会很可观。ZXing-js是 Java ZXing 库的 TypeScript 移植版底层用 WebAssembly 做部分计算性能上限更高而且能识别更多格式比如 Data Matrix、PDF417。但它体积大不少而且 API 设计更复杂初始化时得管理 worker 线程。我自己的习惯是项目只识别普通二维码、且不想增加构建复杂度时直接用jsQR如果业务里二维码密度高、或者像素坏点多再换成ZXing-js。下面以jsQR为例因为它对新手最友好出错排查也容易。2.3 把视频帧转成图像数据Canvas 是必经之路jsQR只接受ImageData或者 Uint8ClampedArray所以视频画面必须先从video元素画到canvas再读取像素。这里有一个关键参数采样分辨率。视频流是 1280×720但从drawImage到 canvas 时完全可以画小一点比如 640×480识别速度能提升不少代价是二维码太小时可能扫不出来。每次请求动画帧循环中采集一帧代码结构是import jsQR from jsqr; const canvas document.getElementById(scanner-canvas); const ctx canvas.getContext(2d, { willReadFrequently: true }); function scanFrame() { // 视频画面按比例缩小到 canvas 上 const targetWidth 640; const scale targetWidth / video.videoWidth; canvas.width targetWidth; canvas.height Math.floor(video.videoHeight * scale); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); const result jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: dontInvert }); if (result result.data) { handleSuccess(result.data); } else { requestAnimationFrame(scanFrame); } }willReadFrequently: true这个参数容易被忽略它告诉浏览器“这个 canvas 会频繁调用 getImageData”让内部存储直接走 CPU 可读的路径性能有可见提升。drawImage时把 canvas 宽高设成视频尺寸的比例缩放值别把整个 1280 宽的帧丢给jsQR不然手机发烫不说扫描帧率也会掉到让你怀疑人生。2.4 HTTPS 与 WebView 环境为什么本地调试和真机行为不一致前面反复提 HTTPS这里展开说清楚。getUserMedia的可用条件不只是“页面在 HTTPS 下”而是整个权限链路的 origin 都必须是安全上下文。这意味着用 IP 地址访问比如http://192.168.1.10:8080大概率被拒因为 IP 不算是 secure context除非显式配置了证书用https://localhost或http://localhost在桌面端没问题但真机访问时 localhost 指手机自己所以调试得把手机和电脑放到同一局域网然后用电脑的 HTTPS 地址访问。还有一个容易踩的坑微信内置 WebView 在某些版本里对getUserMedia的支持有缺陷明明 HTTPS 也加了但摄像头就是黑屏。常见做法是识别到微信 UA 且黑屏时提示用户用系统浏览器打开或者直接走微信的wx.scanQRCode原生接口。这是平台限制不是代码能完全绕开的。3. 完整实现一个手机扫码页面从 HTML 到识别回调3.1 页面骨架视频容器、Canvas 和提示区搭建一个最小可用的扫码页不需要任何前端框架原生 HTML JavaScript 就够。页面布局上视频铺满容器canvas 隐藏识别结果输出到提示区!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno title手机扫码识别/title style #scan-container { position: relative; width: 100%; max-width: 400px; margin: 0 auto; } #scanner-video { width: 100%; border-radius: 12px; object-fit: cover; } #scan-tip { position: absolute; bottom: 12px; left: 0; right: 0; text-align: center; color: #fff; background: rgba(0, 0, 0, 0.5); padding: 6px 0; font-size: 14px; } #scan-result { margin-top: 12px; text-align: center; word-break: break-all; } /style /head body div idscan-container video idscanner-video autoplay muted playsinline/video div idscan-tip将二维码放入取景框内/div /div canvas idscanner-canvas styledisplay:none;/canvas div idscan-result/div script typemodule src./scan.js/script /body /htmlmuted属性加上是为了满足 iOS Safari 的视频播放策略——不带声音的自动播放才会被允许。autoplay让视频流获取后立即渲染不用再手动点一次播放。object-fit: cover保证视频在容器里等比缩放且填满否则有些安卓机上画面会被拉伸变形。3.2 识别循环与结果回调连续扫描模式在scan.js里把上一章的getCameraStream和scanFrame整合成完整的控制流import jsQR from jsqr; const video document.getElementById(scanner-video); const canvas document.getElementById(scanner-canvas); const resultEl document.getElementById(scan-result); const ctx canvas.getContext(2d, { willReadFrequently: true }); let scanning false; let scanLock false; async function initScanner() { try { const stream await getCameraStream(); video.srcObject stream; await video.play(); scanning true; requestAnimationFrame(scanFrame); } catch (err) { resultEl.textContent err.message; showPermissionGuide(err); } } function scanFrame() { if (!scanning) return; if (scanLock) { // 上一次识别回调还没处理完跳过这一帧 requestAnimationFrame(scanFrame); return; } const scale Math.min(1, 640 / video.videoWidth); canvas.width Math.floor(video.videoWidth * scale); canvas.height Math.floor(video.videoHeight * scale); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); const result jsQR(imageData.data, imageData.width, imageData.height); if (result result.data) { scanLock true; // 防止重复触发回调 handleResult(result.data); } else { requestAnimationFrame(scanFrame); } } function handleResult(data) { resultEl.textContent 识别结果: ${data}; audioBeep(); // 可选的震动/声音反馈 // 识别成功后延迟再继续扫避免同一二维码连续触发 setTimeout(() { scanLock false; }, 800); } function showPermissionGuide(err) { if (err.name NotAllowedError) { resultEl.textContent 请在浏览器设置中允许摄像头权限并刷新页面; } else if (err.name NotFoundError) { resultEl.textContent 未检测到摄像头设备; } else if (err.name NotReadableError) { resultEl.textContent 摄像头被其他应用占用请关闭后再试; } } initScanner();scanLock变量是这段代码里最容易忽略的细节。jsQR解码本身是同步的性能好时每一帧都能识别出结果如果handleResult里有网络请求或弹窗同样的码会被连续触发好多次。加一个锁之后只有等上一次回调结束scanLock false才继续处理识别同时起到节流的作用。3.3 相机参数调优对焦、分辨率和扫码距离真机调试时常遇到“二维码对得准但识别不出来”的情况。这个问题的七个源头里最常见的是对焦模式。getUserMedia允许传advanced数组来请求特定相机能力但不是所有浏览器都支持const constraints { video: { facingMode: environment, width: { ideal: 1280 }, height: { ideal: 720 }, advanced: [ { zoom: 1 }, // 部分安卓机型支持数字变焦 // { focusMode: continuous } // Chrome 安卓 83 支持 ] }, audio: false };advanced数组里每一项都是一个可选的约束浏览器会尽量满足不支持的项会被静默忽略所以不用害怕兼容性。focusMode: continuous在 Chrome 安卓比较新的版本里有效但 Safari 和微信 WebView 通常忽略它。识别距离也有讲究。我用 jsQR 实测过的经验是一张 200×200 像素的二维码在 640×480 的 canvas 里二维码占画面宽度四分之一以上时识别成功率最高。换成分辨率语言就是二维码距离手机 1020 厘米最合适。如果用户拿得太远画面里二维码只有指甲盖大小降采样后细节丢失识别算法很容易放弃。3.4 权限被拒后的恢复流程不能一句“请开启权限”就完事用户拒绝权限之后单纯提示“请开启权限”等于把问题推回去很多用户根本找不到入口在哪里。我一般分三种情况处理。第一种浏览器是 Chrome 系直接调navigator.permissions.query({ name: camera })检测当前权限状态。如果检测结果是denied提示用户去地址栏左侧图标里重新开启权限。第二种微信 WebView 等没有可编程权限入口的环境只能提示“请点击右上角菜单切换到系统浏览器打开”。第三种用户之前选的是“仅一次”模式刷新页面后权限会重新询问——这种情况下直接提示“请刷新页面重新授权”就够了。async function checkPermission() { if (navigator.permissions navigator.permissions.query) { const status await navigator.permissions.query({ name: camera }); // status.state 可能是 granted / denied / prompt return status.state; } return unknown; }这段代码里permissions.query只对 HTTPS 生效HTTP 下拿到的state永远是prompt而且query({ name: camera })在 Safari 上可能直接抛TypeError所以外层得包 try/catch。理论上说denied状态下任何getUserMedia调用都会立刻失败检查这一步的意义在于省去一次必然失败的请求让页面能跳转到更明确的引导。4. 避坑与常见问题调试了几十台手机的踩坑记录4.1 黑屏但有视频流play()返回值被忽略现象调用video.play()后控制台没报错画面却是黑的但检查video.srcObject不为空。原因video.play()返回的是一个 Promise。在部分安卓机的 WebView 里这个 Promise 会进入pending状态直到页面有用户交互才真正开始渲染。如果调用后不等待、直接进入requestAnimationFrame循环就会在视频还没开始播时读到空的 canvas于是整页黑屏。解决写成await video.play()并在知道它是 pending 时提示用户“轻触屏幕开始扫码”用一次点击事件去 resolve 播放try { await video.play(); } catch (e) { // 自动播放被阻止等用户点击后再播 document.body.addEventListener(click, function handleClick() { video.play(); document.body.removeEventListener(click, handleClick); }, { once: true }); }4.2 识别总是差一点inversionAttempts参数导致漏扫现象同一个二维码在微信里能扫出来自己页面死活识别不了而且界面上看画面是清楚的。原因二维码的颜色组合不是标准的“黑底白码”。比如有些商品码是白色模块印在深色包装上或者印刷时反色了。jsQR默认只尝试原始图像遇到反色二维码就会失败。解决把inversionAttempts改成attemptBothconst result jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: attemptBoth });代价是每帧解码耗时大约增加 30% 左右如果你确定场景里都是标准黑白二维码用dontInvert性能更好。我一般在真机测试阶段用attemptBoth稳定后再按业务需求收紧。4.3 微信内置 WebView 永远黑屏playsinline和webkit-playsinline缺一不可现象Chrome 正常微信小游戏或官方账号 H5 里视频是黑屏的但权限弹窗出现过、控制台无报错。原因微信 WebView 对 video 标签的处理策略跟系统浏览器不同特别是 iOS 上如果不加webkit-playsinline视频会被系统强制推到全屏播放页面里的 video 元素拿不到画面。解决HTML 里同时写上两个属性video idscanner-video autoplay muted playsinline webkit-playsinline x5-playsinline/videox5-playsinline是腾讯 X5 内核的私有属性给安卓微信 WebView 用的。三个一起加不会报错不认识的属性会被忽略。4.4 局域网 IP 访问导致权限被拒必须走 HTTPS 自签名证书现象用http://192.168.1.5:8080在手机上访问点击“允许”后摄像头直接报NotAllowedError。原因IP 地址不是 secure context浏览器直接拒绝调用摄像头不管用户怎么点。这跟localhost不同——localhost是特例被放行的。解决本地开发时用http://localhost调试真机测试必须起一个 HTTPS 服务。临时方案是用mkcert生成自签名证书然后手机安装信任证书mkcert -key-file key.pem -cert-file cert.pem 192.168.1.5 localhost生成后在项目目录里起 HTTPS 静态服务npx serve --ssl-cert cert.pem --ssl-key key.pem dist真机访问https://192.168.1.5:3000首次进入时浏览器会提示证书不受信任这时候需要在手机上手动安装并信任mkcert的 CA 证书。麻烦是麻烦但这是getUserMedia在真机上唯一稳妥的调试路径。4.5 旧手机性能不够识别帧率低到 2 帧现象二维码放在镜头前画面明显卡顿requestAnimationFrame每一帧都要等很久。原因getImageData的操作是同步读像素jsQR又要遍历像素矩阵做模式匹配。旧手机 CPU 慢1280×720 的帧直接解码单帧耗时可能超过 500ms。解决把 canvas 宽度降到 320高度按比例缩放识别耗时能降到原来的四分之一。同时降低video流的分辨率请求const constraints { video: { facingMode: environment, width: { ideal: 640 }, // 不用 1280够扫二维码就行 height: { ideal: 480 } } };画面清晰度会打折扣但扫码场景里二维码的模块数量有限640 宽足以识别。5. 进阶把扫码做成可复制的基础模块并验证真实识别率与其每次新项目从零写一遍不如把上面这套逻辑封装成一个可复用模块暴露最小的 API 给页面调用。我通常做成一个createScanner函数内部处理权限、视频绑定、识别循环、结果回调业务方只需要关注onResult和onError。export function createScanner({ videoEl, canvasEl, onResult, onError }) { const canvas canvasEl; const ctx canvas.getContext(2d, { willReadFrequently: true }); const video videoEl; let rafId null; let active false; async function start() { const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: environment, width: { ideal: 640 } }, audio: false }); video.srcObject stream; await video.play(); active true; tick(); } function tick() { if (!active) return; const scale Math.min(1, 480 / video.videoWidth); canvas.width Math.floor(video.videoWidth * scale); canvas.height Math.floor(video.videoHeight * scale); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); const result jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: attemptBoth }); if (result result.data) { onResult(result.data); } else { rafId requestAnimationFrame(tick); } } function stop() { active false; if (rafId) cancelAnimationFrame(rafId); if (video.srcObject) { video.srcObject.getTracks().forEach(track track.stop()); video.srcObject null; } } return { start, stop }; }stop()里getTracks().forEach(track track.stop())这一步太重要了。很多页面退出扫码后摄像头的小红点还在亮着就是没显式停流。不主动关闭轨道摄像头一直被占用浏览器右上角会显示录制中的图标用户会质疑页面在窃听。写完模块后验证识别率有个笨但有效的办法找 10 张不同对比度、不同尺寸、不同损坏程度的二维码打印出来贴在墙上用手持手机在不同距离、不同角度各扫 10 次记录成功率。我实测过的一组数据是标准 A4 打印码20 厘米距离成功率 100%同码在手机屏幕上显示12 厘米距离成功率 90% 以上如果二维码上有香水渍或者折痕识别率下降到 60% 左右这时候把inversionAttempts调成attemptBoth能挽回 10 个百分点但帧率会掉一截。真机调试时还有一个习惯很重要每种机型至少测一次权限拒绝后的恢复路径。iPhone 上拒绝后进设置非常顺滑安卓不同品牌的入口完全不同华为在“应用权限管理”小米在“更多应用-设置-权限”代码里引导文案不能只写“请去设置”最好把品牌判断也写进去。从那以后我每次交付扫码页面都会强制走一遍“拒绝权限 → 引导开启 → 重新扫码”全流程确保每一步的用户提示都具体到路径不把用户丢给一个笼统的报错。希望这段拆解能帮你少走几步弯路。本文还有配套的精品资源点击获取
返回列表