简介:面向H5前端开发者的摄像头调用与扫码功能测试示例,聚焦navigator.mediaDevices.getUserMedia拍照,以及zepto+qrcode、html5-qrcode两种扫一扫实现,适用于需要兼容PC端与手机端、且部署于HTTPS协议下的移动端页面调试场景。压缩包共10个文件,包含4个JavaScript逻辑脚本、3个HTML页面、1个CSS样式及2张PNG参考图,整体仅73KB,结构轻量,便于直接查阅。已有1023人学习,适合初涉H5设备API或需要快速验证扫码方案的前端开发者参考。资料既给出了getUserMedia拍照调用核心代码,也展示了从相册解析、实时摄像头扫码及相册选图解析的两种实现思路;通过示例可快速跑通流程,并对比不同方案在PC端与手机端HTTPS环境下的实际表现,节省从零搭建测试环境的时间。
1. 为什么 H5 里调摄像头和扫一扫不是“打开摄像头”这么简单
拿到“H5 调用摄像头和扫一扫.zip”这套资源时,很多人第一反应是<input type="file" accept="image/*">加上 capture 属性就够了。实际落地才发现,摄像头流必须依赖navigator.mediaDevices.getUserMedia,二维码识别又分成“相册图片解析”和“摄像头实时解析”两套方案,二者在 PC 和手机上的授权行为、失败表现完全不同。这篇博文把拍照链路、两种扫一扫实现、HTTPS 与双端联调、以及可复用扫描组件一次性拆开,当作可以直接抄作业的素材来用。适合正在做 H5 页面、App 内嵌 H5 或微信公众号 H5 的前端开发,也适合要写摄像头兼容性测试用例的 QA。
2. 从 getUserMedia 说起的 H5 拍照链路
2.1 摄像头权限的前提:secure context 与用户手势
H5 里的“打开摄像头”并不是释放一个 native API,而是浏览器按安全策略交出设备句柄。浏览器要求页面必须运行在 secure context 上:https://或http://localhost被视为安全,普通局域网 IP 加 HTTP 基本不行。对应到网络协议层面,就是页面请求必须走 TLS;没有 HTTPS 时,navigator.mediaDevices直接是 undefined,调用getUserMedia会得到TypeError,这跟用户有没有点“允许”没有关系。另一个隐蔽条件是“用户手势”:Chrome 会在用户点击事件触发时放行弹窗,页面后台自动打开摄像头则容易被拦截。所以实测时要记住,不能在DOMContentLoaded里直接调用openCamera(),必须由按钮点击或路由返回后触发。
2.2 一个兼容 PC 与 Android/iOS 的拍照函数
async function openCamera(videoElement, facingMode = 'environment') { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error('当前环境不是 secure context,或浏览器不支持 getUserMedia'); } const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); videoElement.srcObject = stream; await videoElement.play(); return stream; }facingMode在 PC 上通常没有前后摄之分,浏览器会忽略;手机上environment指定后置摄像头,适合扫二维码和拍实物。width和height用ideal而不是exact,因为很多 Android 机型并没有 1280x720 的精确采集档位,exact会直接抛OverconstrainedError。测试时应把分辨率切到 1920x1080 与 640x480 各跑一次,确认画质和性能的平衡点。audio: false是必须的,扫码和拍照场景不需要麦克风,也能少弹一次权限窗。
function captureToCanvas(videoElement, canvasElement, shouldFlip = false) { const w = videoElement.videoWidth; const h = videoElement.videoHeight; if (!w || !h) { throw new Error('video 还没有可绘制的画面'); } canvasElement.width = w; canvasElement.height = h; const ctx = canvasElement.getContext('2d'); if (shouldFlip) { // 前置摄像头画面是镜像的,水平翻转一下更符合自拍习惯 ctx.translate(w, 0); ctx.scale(-1, 1); } ctx.drawImage(videoElement, 0, 0, w, h); return canvasElement.toDataURL('image/jpeg', 0.92); }videoWidth必须在播放后才有值,不能在loadedmetadata触发前直接读。drawImage的四个参数是目标坐标和宽高,如果把 canvas 强制固定为 750 宽,再直接drawImage(video, 0, 0, 750, 500)会导致画面裁切,正确做法是先匹配videoWidth/videoHeight。返回的 dataURL 可以直接给<img>,也可以经过fetch转 Blob 再走 multipart 上传。0.92是 jpeg 压缩质量,对白底二维码图片来说,降到 0.7 也不会影响解析率,体积能小很多。
2.3 分辨率与画面方向:PC 与移动端差异表
| 运行环境 | 常见输出分辨率 | 是否区分前后摄 | 需要特别注意 |
|---|---|---|---|
| PC Chrome | 1280x720 / 1920x1080 | 不区分 | 用 enumerateDevices 取 deviceId |
| iOS Safari | 1920x1080 / 1280x720 | 区分 | 首次授权后需刷新 |
| Android WebView | 640x480 / 1280x720 | 区分 | 宿主 App 要处理 onPermissionRequest |
| 微信内置 H5 | 不一定支持摄像头 API | 不一定 | 需降级到 wx.scanQRCode |
这张表是做双端测试时最容易踩出问题的部分。PC 端如果有外接摄像头,facingMode会被忽略,但deviceId变化会导致上层维护的“默认摄像头”失效。手机端 iOS Safari 在用户首次拒绝后,域名会进入 Safari 的摄像头权限黑名单,网页端无法再次弹窗;Android WebView 则是宿主 App 在原生层拦截请求,与 H5 代码无关。测试用例里至少要有“页面级拒绝”“系统级拒绝”“摄像头被其他 App 占用”三个分支。
3. 扫一扫的两种实现:zepto + qrcode 与 html5-qrcode 的选型边界
3.1 为什么不能直接用摄像头原始帧识别二维码
浏览器里的实时画面是一帧帧视频流,页面脚本无法直接拿到摄像头输出的原始帧,只能借助 canvas 的drawImage先把当前帧抽出来,再交给二维码解码库做灰度化和定位。这就是为什么“调用摄像头拍照”和“扫一扫”看起来只差一步,代码复杂度却差一截。扫一扫本质是“连续拍照 + 图像识别”,浏览器没有现成的scan()API。项目里给出两条路:轻量的zepto + qrcode适合解析相册图片,html5-qrcode适合从摄像头实时取流。选型不能只比解析速度,要比取流方式和失败回调频率。
3.2 zepto + qrcode:相册图片解析的实现与局限
$('#qr-file').on('change', function () { const file = this.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { try { const result = qrcode.decode(img); alert('扫描结果: ' + result); } catch (err) { console.error('解析失败', err); } }; img.src = e.target.result; }; reader.readAsDataURL(file); });qrcode.decode内部会把img绘制到隐藏 canvas,再逐像素扫描定位角点,所以图片是否模糊、是否带白边、是否倾斜,都会直接影响结果。zepto在这里只负责事件绑定,真正干活的是 qrcode 库对 canvas 像素的同步遍历。局限很明显:它没有逐帧识别能力,用户必须先从相册选图,适合“上传二维码凭证”的后台页面;手机相册大图直接导入会卡,上传前最好用 canvas 压到 800 像素宽。
注意:
qrcode.decode是同步操作,图片越大主线程阻塞越久,不要让用户点完图片后没有 loading 反馈。
3.3 html5-qrcode:三种解析模式的接入
import { Html5Qrcode } from 'html5-qrcode'; const scanner = new Html5Qrcode('qr-reader'); // 模式一:摄像头实时解析 async function startScan() { await scanner.start( { facingMode: { ideal: 'environment' } }, { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0 }, (text) => { console.log('识别成功:', text); stopScan(); }, () => { // 该回调在未识别时会高频触发,刻意留空 } ); } async function stopScan() { if (scanner.isScanning) { await scanner.stop(); } } // 模式二:从相册选择图片解析 fileInput.addEventListener('change', async () => { const file = fileInput.files[0]; if (!file) return; const text = await Html5Qrcode.scanFile(file, false); console.log('相册解析结果:', text); });Html5Qrcode的start()第三个参数是成功回调,第四个是失败回调,失败回调几乎每帧都会触发,在里面打日志会把 console 刷爆。fps: 10表示每秒尝试 10 次,低端机应降到 5;qrbox是取景框大小,二维码不一定要占满全屏,框太大反而容易扫到背景里的脏图案。scanFile的第二个参数传false,表示不把源图显示在页面里,直接走内存解析。这里已经是三合一的写法:拍照解析、摄像头解析、相册图片解析都在同一套库内部实现。
3.4 两种方案的性能与兼容性对比表
| 维度 | zepto + qrcode | html5-qrcode |
|---|---|---|
| 摄像头实时解析 | 不支持 | 支持 |
| 相册图片解析 | 支持 | 支持 |
| 依赖复杂度 | zepto + qrcode.js | html5-qrcode,内部集成 ZXing 思路 |
| 失败表现 | 抛异常 | 高频回调 |
| 适合场景 | 已做图片上传的后台管理 | 扫码枪替代、扫码登录 |
这里有个容易被忽略的点:html5-qrcode体积更大,但内部把 ZXing 的解析逻辑搬到了浏览器端,所以相册解析的成功率通常比纯qrcode更稳定。如果 H5 页面既要“拍照”又要“扫一扫”,推荐直接上html5-qrcode,少维护一套 canvas 处理逻辑;如果只是做后台图片解析,就用zepto + qrcode,资源占用更小,改造成本也低。
4. HTTPS 与双端测试:一套可落地的排错流程
4.1 为什么必须 HTTPS?非 HTTPS 下发生了什么
从网络协议视角看,浏览器把摄像头视为敏感设备,只有 TLS 加密的页面才允许navigator.mediaDevices.getUserMedia。如果访问地址是http://192.168.1.10:8080,控制台多半会出现getUserMedia() no longer works on insecure origins这类提示,navigator.mediaDevices也直接不存在。这不是页面 bug,而是浏览器策略。验证方法是在 DevTools Console 执行:
console.log(window.isSecureContext); console.log(navigator.mediaDevices !== undefined);如果第一行是 false,先把页面部署到 HTTPS,或者用localhost做本地联调。Windows 上localhost加任意 HTTP 端口,浏览器也认为安全;但 Android 手机调试时不能用localhost指向电脑,需要临时签名 HTTPS 证书,否则扫码枪类功能测不了。
4.2 PC 端测试:权限开关、设备枚举、报错定位
PC 端的测试路径比手机简单,但它能快速暴露代码层面的问题。先打开 Chrome 的chrome://settings/content/camera,确认站点是否被允许;再在页面里枚举设备:
const devices = await navigator.mediaDevices.enumerateDevices(); const cameras = devices.filter(device => device.kind === 'videoinput'); console.table(cameras.map(({ deviceId, label }) => ({ deviceId, label })));这个调用不需要用户授权,也能拿到 label;如果 cameras 为空,说明权限或驱动有问题。拿到deviceId后,可以用它精确指定摄像头:
const stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: cameras[0].deviceId } } });exact一旦指定,设备被拔掉就会抛OverconstrainedError,所以生产代码通常用ideal,或者做 try/catch 降级。PC 端最常见的三个报错分别是NotAllowedError(用户点了拒绝)、NotFoundError(没有可用摄像头)、NotReadableError(摄像头被其他软件占用)。测试时把这三个分支都触发一遍,再在页面上放对应的引导文案。
4.3 手机端测试:iOS Safari 与 Android WebView 的授权差异
手机端不能只考虑网页逻辑,还要兼顾系统弹窗和 WebView 的授权机制:
| 环境 | 首次授权表现 | 拒绝后恢复路径 |
|---|---|---|
| iOS Safari | 自动弹出系统相机权限 | 设置 > Safari > 摄像头,手动打开 |
| Android Chrome | 自动弹出系统权限 | 地址栏左侧图标进入站点设置 |
| Android WebView 内嵌 H5 | 由宿主 App 的 onPermissionRequest 决定 | 必须在原生设置里授权 |
| 微信内置 H5 | 不一定暴露 getUserMedia | 常见做法是降级到微信 JS-SDK |
Android 上做 App 内嵌 H5 时,如果 H5 页面清理过缓存,摄像头权限状态不会自动重置,属于“权限已授权但重新加载后还报错”的典型场景。先清掉 WebView 缓存,再关闭页面重新进入,能解决大部分残留状态。iOS WKWebView 则需要在原生 Info.plist 声明NSCameraUsageDescription,很多内嵌 H5 摄像头打不开,原生工程少了这一条描述是常见原因。
4.4 扫一扫识别率低的调参步骤
如果摄像头画面正常但一直扫不出来,先按顺序调:
- 把
fps从 10 降到 5,低端机实时解析来不及完成时,降帧比降分辨率更稳。 - 缩小
qrbox,让二维码在取景框里占比更大,减少背景干扰。 - 强制使用后置摄像头,前置摄像头解析距离太近容易糊。
- 避免在扫码页同时做动画,解析回调会阻塞主线程。
调整后的典型配置:
scanner.start( { facingMode: { ideal: 'environment' } }, { fps: 5, qrbox: { width: 240, height: 240 } }, onSuccess, () => {} );facingMode: { ideal: 'environment' }表示优先后摄,手机没有后摄也不会直接报错;如果写成{ exact: 'environment' },在只有前摄的平板上会抛错,需要额外 catch。qrbox调小后,识别距离会变近,所以还要引导用户把手机靠近二维码,而不是站在原地等。
5. 进阶:封装一个可复用的扫描组件并处理弱光场景
5.1 用类封装摄像头扫码的生命周期
直接裸写scanner.start()的页面,很容易在路由切换时忘记stop(),导致摄像头灯一直亮。常见做法是封装成类,把启动、停止、结果回调收敛到同一处:
class QRScanner { constructor(rootId) { this.scanner = new Html5Qrcode(rootId); this.running = false; this.lastResult = ''; this.lastTime = 0; } async start({ fps = 8, qrboxSize = 220 } = {}) { if (this.running) return; await this.scanner.start( { facingMode: { ideal: 'environment' } }, { fps, qrbox: { width: qrboxSize, height: qrboxSize } }, (text) => this.handleResult(text), () => {} ); this.running = true; } async stop() { if (this.running) { await this.scanner.stop(); this.scanner.clear(); this.running = false; } } handleResult(text) { const now = Date.now(); if (text === this.lastResult && now - this.lastTime < 3000) return; this.lastResult = text; this.lastTime = now; this.onResult?.(text); } }running标志避免重复调用 start 导致多个取流循环;lastResult与lastTime组成 3 秒去重窗口,防止同一个二维码被连续识别后触发多次业务提交。真正的业务逻辑写在onResult回调里,组件本身不关心是跳转页面还是发请求。
5.2 连续扫码与防抖规则
如果业务是连续扫多件商品,就不能扫一次就永久停止。常见做法是每次成功后将取景区域遮罩,等上层处理完成后再调start()重新扫描。去重窗口的时长要根据业务间隔调整:扫码入库场景 3 秒足够,领取优惠券场景需要 10 秒以上,避免走开时被同一个码反复扫到。
5.3 弱光环境:手动控制闪光灯
识别率低不全是算法问题,光线不足也会导致帧内无法定位。部分 Android 摄像头可以通过torch打开补光灯:
const track = stream.getVideoTracks()[0]; const capabilities = track.getCapabilities?.() ?? {}; if (capabilities.torch) { await track.applyConstraints({ advanced: [{ torch: true }] }); }getCapabilities()不是所有浏览器都实现,所以先做?.()防御。torch不是标准约束,iOS Safari 目前基本不支持;UI 层应该在打开摄像头后动态判断,如果能力缺失,就把“打开补光”按钮隐藏,而不是让用户点击后无效。弱光场景记得保留一个手动开关,不要只依赖浏览器自动曝光。
本文还有配套的精品资源,点击获取