简介:面向需要在移动端网页中调用摄像头并识别二维码的开发者,这份经调试可用的JS实现方案提供了完整可运行的示例代码。资源共3个文件,包含两个JavaScript库和一个HTML页面:html5-qrcode.min.js负责二维码解析,jquery-1.10.2.min.js用于简化页面操作,index.html则集成了摄像头调用与识别逻辑。压缩包整体仅134KB,结构精简,便于直接移植到支持HTTPS的站点使用。已有175人学习下载。针对不同浏览器兼容性、用户授权提示及HTTPS安全传输等关键问题,代码已做基础处理,适合有Web基础并希望快速集成手机扫码功能的开发者参考。通过阅读示例,可以理解getUserMedia获取摄像头视频流、在video元素中预览画面以及借助解析库识别二维码的整体流程,同时也能了解在移动端部署时需要注意的权限与安全要点。
1. HTTPS是前提:JS调手机摄像头识别二维码,为什么一上安卓就黑屏
某开发者拿到一个“经调试可用”的JS二维码识别Demo,在电脑上双击打开页面,摄像头正常,扫码也灵。可同一套代码传到手机,用局域网IP访问一个打头的地址,结果页面打开了,摄像头区域一片黑,控制台明确写着getUserMedia is not defined。这不是玄学,而是浏览器安全策略在起作用。JS只有在HTTPS或localhost这类安全上下文里,才能调起navigator.mediaDevices。这个资源解决的是“在安卓手机上用JS调用摄像头、识别二维码”的完整链路:页面、摄像头调用、二维码识别、HTTPS访问,缺一不可。它适合要给内部管理后台、活动页、巡检工具加扫码入口的开发者,尤其是机型多、没法强制装App的场景。
2. getUserMedia实战:三行代码拿到摄像头,但facingMode和分辨率决定成败
2.1 安全上下文与媒体流:为什么必须HTTPS
先把最烦人的概念讲清楚。navigator.mediaDevices是浏览器暴露给JS的媒体入口,它只有在“安全上下文”中才存在。安全上下文包括HTTPS页面、localhost、file协议在部分浏览器里的表现并不一致,所以最稳妥的判断方式是直接打印:
console.log("mediaDevices:", navigator.mediaDevices);如果你在手机浏览器里访问一个 http 开头的页面,这一行大概率输出undefined。后面的getUserMedia自然就调用不到,摄像头区域就会黑屏。理解了这一点,就知道为什么资源标题里特意强调“要用HTTPS访问”了。
摄像头调用本身不复杂,一个标准的启动函数是这样:
async function startCamera(videoElement) { // 没有 mediaDevices,说明页面不在安全上下文中 if (!navigator.mediaDevices?.getUserMedia) { alert("摄像头不可用:请确认页面是通过https访问的"); return; } const constraints = { video: { facingMode: { ideal: "environment" }, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }, audio: false }; try { const stream = await navigator.mediaDevices.getUserMedia(constraints); videoElement.srcObject = stream; await videoElement.play(); } catch (err) { console.error("摄像头打开失败:", err); } }这段代码有三个关键点。第一,facingMode: { ideal: "environment" },environment 表示后置摄像头,ideal 是“优先尝试”而不是“必须”。如果写成exact: "environment",在某些浏览器上会直接报错回退,反而切到了前置摄像头。第二,width和height写的是理想值,浏览器会根据硬件能力自行调整,不一定精确等于1280x720,所以之后读取video.videoWidth才是真实分辨率。第三,audio: false是不拿麦克风权限,扫码场景不需要音频输入,别多占权限。
打开摄像头之后,video标签上会显示画面,但这时JS还拿不到图像数据。二维码识别库需要的是像素矩阵,而不是视频流本身。所以下一步必须把视频帧画到canvas上。
2.2 帧循环与Canvas绘制:把视频画面变成像素
video标签本身不具备把画面交给JS分析的能力,video.toDataURL()也不是通用做法。正确路径是:用requestAnimationFrame循环,把 video 当前帧通过drawImage绘制到 canvas,再从 canvas 读取像素。
const canvas = document.getElementById("scanCanvas"); const ctx = canvas.getContext("2d", { willReadFrequently: true }); function drawFrame() { if (video.readyState >= 2) { // 每次重设宽高会清空画布,必须先设置再绘制 canvas.width = video.videoWidth; canvas.height = video.videoHeight; ctx.drawImage(video, 0, 0, canvas.width, canvas.height); } requestAnimationFrame(drawFrame); } video.addEventListener("loadedmetadata", () => { drawFrame(); });这里有个容易忽略的细节:canvas.getContext("2d", { willReadFrequently: true })。这个参数告诉浏览器你接下来会频繁调用getImageData,让canvas使用更适合读像素的底层实现。不加这个参数,在部分安卓机型上会出现掉帧、发热甚至白屏。这个参数不是标准里的家常便饭,但实战中确实能改善性能。
video.readyState >= 2表示已经有足够数据可以绘制。requestAnimationFrame的频率和屏幕刷新率一致,通常是60次每秒。如果你直接在这一层做二维码识别,CPU会受不了,所以之后我们会在识别层做节流。另外还要注意,canvas.width每次赋值都会触发画布重置,所以不要在drawFrame之外异步修改它,否则画面会闪烁。
到这里,我们已经拿到了摄像头画面,并且把它变成了 canvas 像素。接下来就是选择二维码识别方案了。
3. 二维码识别方案:jsQR、ZXing、BarcodeDetector怎么选,以及连续扫码代码
3.1 三个方案对比:别一上来就选最复杂的
手机端扫码库常见的是这三个,各有适用场景。
| 方案 | 识别类型 | 兼容性 | 体积 | 适用场景 |
|---|---|---|---|---|
| jsQR | 仅QR码 | 所有现代浏览器 | 约150KB(gzip后更小) | 纯前端、不需要后端,本项目首选 |
| zxing-js/library | QR、一维码、DataMatrix等 | 需要npm构建 | 较大,按需引入 | 多码制、复杂业务场景 |
| BarcodeDetector | QR、一维码等 | 仅部分Chromium内核浏览器 | 无体积提交 | 原生API调用最简单,但兼容性不稳 |
从这个资源的定位看,场景就是手机识别二维码,不包括一维码和复杂条码,所以用 jsQR 是性价比最高的选择。BarcodeDetector 听起来高级,但实测在安卓WebView、部分国产浏览器上不是不存在就是识别结果字段不稳定。ZXing 功能全,但要在工程里引入构建链,对一个单页Demo来说太重。
3.2 集成jsQR:连续扫码的完整代码与参数说明
jsQR 是一个纯JS库,不需要后台服务,把库文件引入页面之后,调用方式非常直接。下面这段就是核心扫码逻辑:
// 每150ms扫描一次,而不是每帧都跑 let scanTimer = null; function startScan(onResult) { scanTimer = setInterval(() => { if (canvas.width === 0) return; 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) { console.log("识别结果:", code.data); onResult(code.data); } }, 150); }代码里三个东西要解释清楚。第一,getImageData取到的imageData.data是Uint8ClampedArray,每个像素占用RGBA四个字节,jsQR 需要的就是这个原始数组。第二,coda.width和coda.height必须和 canvas 当前的宽高一致,否则识别会直接失败。第三,inversionAttempts控制是否去做“反色识别”。dontInvert表示只识别黑二维码、白底图案,这是最常见的场景。如果你要支持白二维码、黑底图案,改成attemptBoth,但识别速度会明显变慢,因为jsQR需要把图像反色后再算一遍。
这段逻辑跑起来之后,你可能会发现一个问题:setInterval里每150ms做一次完整图像分析,但 canvas 宽高可能是 1280x720,每次getImageData要处理近370万字节数据,手机端压力不小。常见做法是把 canvas 尺寸限制到一个更小的值,比如宽640、高480,或者从 canvas 拷贝一个缩小帧。我一般会直接修改绘制逻辑,把绘制的目标尺寸缩小一半:
const SCAN_WIDTH = 640; const SCAN_HEIGHT = 480; function drawFrame() { if (video.readyState >= 2) { canvas.width = SCAN_WIDTH; canvas.height = SCAN_HEIGHT; ctx.drawImage(video, 0, 0, SCAN_WIDTH, SCAN_HEIGHT); } requestAnimationFrame(drawFrame); }这样不会影响二维码识别率,因为一个二维码在640宽度下已经足够承载模块信息。更清晰的视频画面对人眼好,但对jsQR来说,像素大了只会增加计算量,并不会提升准确率。这是很多新手容易踩的误区。
4. 本地起HTTPS服务并过安卓兼容关:让手机真机跑通这套扫码Demo
4.1 用OpenSSL生成自签名证书,再用Node起HTTPS静态服务器
既然必须要HTTPS,本地调试就不能像以前那样直接python -m http.server了。你需要在本机起一个HTTPS服务,并且让手机通过HTTPS访问你电脑的IP。最常用的组合是 OpenSSL 生成自签名证书,然后用一个极简的 Node.js HTTPS服务器托管静态文件。证书生成命令:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=192.168.1.100"命令里的192.168.1.100要换成你自己电脑的局域网IP。-nodes表示生成的私钥不加密,这样服务器启动时不需要输入密码。自签名证书的CN字段和IP不一致时,手机访问会弹出安全警告,这是正常的,点击“高级-继续访问”即可。
Node服务器脚本可以写到server.js:
const https = require("https"); const fs = require("fs"); const path = require("path"); const options = { key: fs.readFileSync("key.pem"), cert: fs.readFileSync("cert.pem") }; https.createServer(options, (req, res) => { const urlPath = req.url === "/" ? "/index.html" : req.url; const fullPath = path.join(__dirname, urlPath); fs.readFile(fullPath, (err, data) => { if (err) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("文件不存在"); return; } res.writeHead(200, { "Content-Type": "text/html" }); res.end(data); }); }).listen(8443, "0.0.0.0", () => { console.log("HTTPS服务已启动: https://192.168.1.100:8443"); });启动后,手机和电脑连同一个WiFi,浏览器访问https://192.168.1.100:8443就能打开Demo页面。这个脚本只处理了HTML文件,如果页面里引用了JS、CSS文件,还需要扩展MIME类型,但对单文件Demo够用。注意listen(8443, "0.0.0.0")这行,必须监听0.0.0.0,否则手机访问不到电脑服务。
4.2 安卓浏览器与WebView的兼容性:哪里会翻车
真机测试时,你至少会遇到三类环境。第一,系统Chrome和多数安卓浏览器,只要HTTPS证书被确认,摄像头就能正常调起。第二,微信内置浏览器或某些App内WebView,它们对getUserMedia的支持依赖WebView内核版本和宿主App是否开启了摄像头权限。如果你发现是在WebView里不能扫码,多半不是页面代码的问题,而是宿主侧配置问题。常见的处理是在原生工程里申请摄像头权限并允许WebView使用。第三,部分国产浏览器的“权限管理”会默认拦截摄像头,需要在浏览器设置里把站点权限修改为允许。
一个看起来不起眼、但实际很坑的点是:手机浏览器访问自签名HTTPS站点时,首次遇到证书警告,如果你只点了“继续访问”,后续摄像头权限弹窗可能不出现。正确做法是先让该站点在地址栏里变成“不安全”但不拦截的状态,然后下拉刷新一次,再触发摄像头调用。我碰到过很多次,第一次能打开页面,但摄像头黑屏,刷新一次就正常了,原因就是权限请求和证书确认的时序问题。
5. 避坑与常见问题:五个真踩过的坑,每个都能省半天排查时间
5.1 摄像头黑屏但控制台没报错
现象:页面打开正常,video区域全黑,控制台干干净净。
原因:最常见的是页面不是HTTPS,navigator.mediaDevices直接是undefined,但你在代码里用了可选链或者try catch,把异常吞掉了。第二种可能是在某些安卓WebView里,宿主没有给WebView摄像头权限,getUserMedia直接静默失败。
解决:在启动摄像头前先打印navigator.mediaDevices,如果是undefined,就别继续执行了,弹出明确提示。WebView场景必须让原生代码先申请摄像头权限,网页端再调用。从那以后我拿到这类Demo的第一件事,就是打开控制台看这行输出。
5.2 一直启用的是前置摄像头
现象:明明想扫二维码,结果预览画面里是自己的脸。
原因:facingMode拼写错误,或者把ideal写成了exact。exact: "environment"在部分浏览器上无法满足时,会走失败回调,回退到默认前置摄像头,而且不报错。
解决:统一写成facingMode: { ideal: "environment" }。如果你确实要强制后置,才考虑exact,但必须在catch里做降级处理。我一般不用exact,因为扫码场景前置也能识别,只是体验差。
5.3 jsQR一直返回null,但对屏幕上的二维码就是扫不出来
现象:摄像头对焦清晰,二维码完整出现在画面里,可识别结果一直是null。
原因:最常见的是inversionAttempts设置成dontInvert,但二维码是反色的,比如深色页面上的白色二维码。另一种原因是canvas尺寸超过了二维码有效区域,二维码只占画面的一小块,像素不足。
解决:临时把inversionAttempts改成attemptBoth,确认能识别后再按性能需求调回去。像素问题就缩小画面,让二维码在画面中占比更大,或者让扫码者把手机凑近一点。别指望放大canvas能提升识别率,真正有用的是减少画面干扰。
5.4 摄像头权限被拒绝后,再次刷新不再弹权限
现象:第一次打开点了“不允许”,后面无论怎么刷新,浏览器都不再询问摄像头权限。
原因:浏览器对站点权限有记忆机制,一旦用户明确拒绝过,就会一直拒绝,除非用户手动修改站点权限。
解决:在Chrome里点击地址栏左侧的信号图标,进入站点设置,把摄像头权限从“拒绝”改为“允许”。如果是自签名HTTPS调试,也可以直接换一个端口,因为新端口会被视为新的站点权限记录。这个习惯帮我省过很多次重复安装测试步骤。
5.5 扫码页面跑久了手机发热、电量掉得明显
现象:页面连续开几分钟,手机背面烫手,电量百分比肉眼可见下降。
原因:requestAnimationFrame一直在全屏绘制,setInterval又保持高频getImageData,两者叠加让CPU持续高负载。特别是jsQR的计算量不低,同一画面反复识别相当于在跑持续压测。
解决:降低识别频率,从每150ms改成每300ms或500ms;把canvas绘制尺寸缩小到640x480;在识别成功后的几秒内暂停扫描,等反馈结束再恢复。这些措施加在一起,发热能明显缓解,识别率几乎不受影响。
6. 把扫码体验做成产品级:防重复触发、音效反馈与相册识别
6.1 防重复触发与音效反馈
识别到二维码后,如果页面没有跳转,jsQR会在接下来的每一帧里继续识别同一个码,导致回调函数被反复执行。常见的做法是加一个“锁存”机制:
let lastCode = null; let lockUntil = 0; function onScan(code) { if (code === lastCode && Date.now() < lockUntil) return; lastCode = code; lockUntil = Date.now() + 3000; beep(); // TODO: 跳转或提交业务逻辑 }同时可以加一个提示音,让用户确定“扫到了”。用Web Audio API生成一根短促的滴声,不用任何音频文件:
function beep() { const ctx = new AudioContext(); const osc = ctx.createOscillator(); osc.frequency.value = 880; osc.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.1); }注意部分浏览器要求AudioContext必须创建在用户手势之后才能播放。我一般是在页面加载时创建并resume一次,后面扫码时再触发,这样能避免第一次扫码没有声音。
6.2 从相册识别二维码:给扫码失败一个后悔药
有些灯光环境下摄像头就是扫不出,可以在页面里加一个“从相册选择图片”的入口。用<input type="file" accept="image/*">读取图片,再绘制到canvas走同一套jsQR识别:
input.addEventListener("change", (e) => { const file = e.target.files[0]; if (!file) return; const img = new Image(); img.onload = () => { canvas.width = Math.min(img.width, 800); canvas.height = img.height * (canvas.width / img.width); ctx.drawImage(img, 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: "attemptBoth" }); if (code) onScan(code.data); }; img.src = URL.createObjectURL(file); });图片尺寸做了限制,避免超大相片一次性读取几百MB像素导致浏览器崩溃。从那以后我每次写扫码页面,都会把相册识别作为兜底方案,摄像头不行还有后悔药,而且这个后悔药对模糊二维码的识别成功率往往比摄像头更高。希望帮到你。
本文还有配套的精品资源,点击获取