☰
手机扫码页为何必须HTTPS?纯前端调用摄像头识别二维码实战
2026/10/14 3:43:25 网站建设 项目流程

简介:一份面向前端开发者和移动端开发者的可用示例工程,解决在手机浏览器中用JavaScript调用摄像头并实时识别二维码的需求。资源基于HTML5媒体接口与二维码解析库实现,兼容安卓常见浏览器,且需通过HTTPS访问以保证隐私与数据安全,适用于移动支付、扫码登录、信息分享等场景。压缩包共三个文件,包含可直接运行的页面、核心识别库与辅助脚本,整体仅134KB,结构精简,便于快速部署和二次开发。已有175人学习下载,示例代码经调试可用,涵盖权限申请、视频流获取、二维码帧解析及常见兼容性问题处理思路;同时强调HTTPS部署环境,帮助规避浏览器不开放摄像头的坑。整体轻量实用,适合初中级前端参考,也可作为扫码模块直接接入现有工程。

1. 手机扫码页为什么必须在 HTTPS 下访问:先说结论,再解释浏览器策略

把写好的手机扫码页面挂到本机服务器,用局域网 IP 访问,摄像头黑屏,控制台抛出一行英文:getUserMedia() is not allowed in insecure context。这不是代码逻辑写错了,而是浏览器对“手机调用摄像头识别二维码”这类硬件能力的统一限制:只有 HTTPS 或 localhost 这样被信任的上下文,navigator.mediaDevices 才会被放行。标题里“要用https访问”不是一句备注,而是整个扫码页能不能启动摄像头的前提。这篇文章要把这套纯前端扫码方案从原理到落地讲清楚:JS 如何调起摄像头、如何把视频帧转成图像数据、如何用解码库在帧里找二维码,以及部署到手机调试时的高频坑。适合给网页、H5 或混合应用 WebView 加扫码功能的人,不需要后端参与,也不需要额外装原生 App。

2. 调用摄像头的技术选型:视频流与二维码解码要分开想

手机扫码的前端实现,拆开看是两件事:先把摄像头的视频流拿到,再把某一帧画面里的二维码解出来。这两件事的技术路线完全不同,很多人把“调用摄像头”和“识别二维码”混成一步去搜,很容易搜到一堆不能用的 demo。这一章先把两条线拆开,给出选型依据。

2.1 用 getUserMedia 拿视频流:一个最小示例

浏览器暴露给页面的摄像头入口是 navigator.mediaDevices.getUserMedia。它返回一个 Promise,成功时给你一个 MediaStream 对象,这个对象里的视频轨会映射到 video 元素上。要注意的是,这组 API 只在安全上下文里存在,所以代码本身和 HTTPS 要求是同时生效的。

async function startCamera(videoEl) { if (!navigator.mediaDevices?.getUserMedia) { throw new Error('当前浏览器不支持摄像头 API'); } const constraints = { video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 15 } }, audio: false }; const stream = await navigator.mediaDevices.getUserMedia(constraints); videoEl.srcObject = stream; return stream; }

这段代码里最容易被忽略的是 constraints。facingMode 设为 environment 表示优先使用后置摄像头,这在扫码场景里是默认选择;width 和 height 用了 ideal 而不是 exact,意思是“我希望拿到 1280 宽”,但浏览器会根据实际硬件和功耗选择一个最接近的值,最终分辨率要通过轨道设置去查,不能假设它就是 1280。frameRate 设成 ideal: 15 是为了平衡功耗和流畅度,扫码不需要 60 帧,15 帧足够让画面看起来连续。

audio: false 很重要。如果不写这一项,部分浏览器会默认把麦克风权限也一起要了,弹窗多一次不说,还容易让用户产生警觉。拿到 stream 后,把 video 元素的 srcObject 指向它,画面就会开始播放。这里不要再用 video.src = stream 这种旧写法,在移动端兼容性不好,srcObject 才是可靠的方式。

扫码场景通常只需要视频轨。后面切换摄像头时,要停掉旧流里的所有 track,才能干净释放摄像头,这也说明拿到流之后不要只做表面赋值,要知道流里面有哪些轨,以及它们什么时候该被释放。

2.2 解码层:BarcodeDetector 与 jsQR 的本质区别

摄像头画面是视频流,二维码解码器不认识视频,它只认识图像数据。所以中间必须有一个 canvas 做转换:把 video 当前帧画到 canvas 上,再读取这一帧的像素。这也是整个方案最核心的桥接动作,所有纯前端扫码方案都绕不开这一步。

解码层的选择,现在有两条路。第一条是浏览器原生的 BarcodeDetector API,它直接接收 canvas 或 ImageData,识别速度很快,而且内置了条码格式检测。但它的兼容性有明显缺口,iOS 自带浏览器长期不支持,Android 上也需要较新的 Chromium 内核才能稳。第二条是 jsQR 这个纯 JS 解码库,它接收的是 ImageData,自己完成灰度化、定位、解码整个过程,兼容性覆盖几乎所有能跑 canvas 的浏览器。

async function decodeFrame(canvas, ctx, video) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); if ('BarcodeDetector' in window) { try { const detector = new BarcodeDetector({ formats: ['qr_code'] }); const codes = await detector.detect(canvas); if (codes.length) return codes[0].rawValue; } catch (e) { // 原生 API 调用失败时继续走 jsQR } } const code = jsQR(imageData.data, imageData.width, imageData.height); return code ? code.data : null; }

这段代码先画帧,再取 ImageData,然后根据浏览器能力选择解码器。注意 jsQR 接收的是 imageData.data 这个 Uint8ClampedArray,不是 ImageData 对象本身,很多第一次用的人在这里翻车。BarcodeDetector 返回的是数组,所以要取 codes[0],没有识别到就是空数组。

选型时我一般把兼容性放在第一位。公开的 H5 页面用户手里是什么浏览器你控制不了,iOS 自带浏览器是必须覆盖的,所以默认用 jsQR。BarcodeDetector 只适合确认运行环境只有 Chromium 内核时使用,比如公司内部工具页面。

2.3 选型对比表:性能和兼容性我优先看哪一列

这里用一个表格把两个方案的关键差异列出来,方便你在写代码前就定好方向。

对比项BarcodeDetector 原生 APIjsQR 纯 JS 解码
兼容性桌面 Chrome、部分 Android Chromium 内核;iOS 自带浏览器不支持几乎所有支持 canvas 的浏览器,包括 iOS
依赖浏览器内置,零体积需要额外引入一个解码脚本,增加资源体积
解码性能原生实现,帧耗时低纯像素扫描,像素越多耗时越高
二维码格式URL 码、文本码都能识别主打 QR 码,其他格式支持有限
适用场景内部工具、可控内核的 Android 设备面向公众的手机 H5、混合应用 WebView

决定用 jsQR 之后,剩下的优化就是尽量减少每帧处理的像素数。这个点后面讲分辨率时会展开。选型这里还有一个容易犯的错:有人为了识别率高同时引入多个解码库,第一帧用这个,第二帧用那个。结果就是包体积翻倍,调试时也不知道问题在哪个库,不如锁定一个,把它的参数吃透。

如果你的业务场景对识别率要求非常高,比如苛刻光照下的批量扫码,纯前端方案仍有上限。但普通导航、签到、绑定这类辅助扫码,这套方案已经足够,这也是我用它落地的原因。

3. 在手机上跑通扫码页:最小可用的前端实现

选型定了,接下来就是落地。这一章给一个能直接跑的最小页面,分为页面骨架、解码主循环、以及手机访问所需的 HTTPS 环境三部分。这个页面不依赖框架,原生 HTML 加两个 JS 文件,方便你移植到任何项目里。

3.1 页面骨架:video 预览与 canvas 缓冲

页面里必须有一个 video 用来预览,一个不可见的 canvas 用来做解码缓冲,一个按钮用来触发启动摄像头。注意这里要把启动摄像头放到 click 事件里,而不是页面加载时,具体原因在避坑章节会讲。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>手机扫码</title> </head> <body> <button id="btnStart">开始扫码</button> <video id="video" playsinline autoplay muted></video> <canvas id="canvas" style="display:none"></canvas> <div id="result">等待扫码</div> <script src="jsQR.js"></script> <script src="main.js"></script> </body> </html>

video 上的 playsinline 是给 iOS 看的,没有它可能导致摄像头画面被全屏抢占;autoplay 和 muted 一起用,是为了让视频流不触发页面自动播放限制。canvas 不可见,只作为中间缓冲区,不参与用户交互。jsQR.js 要在 main.js 之前加载,因为 main.js 里直接用了全局变量 jsQR。

建议不要引入各种框架重写这套骨架,扫码页布局越轻越好。页面越简单,后续定位问题越容易:黑屏就是 video 没拿到流,不识别就是 canvas 绘制或解码的问题,边界非常清晰。

3.2 主流程:把视频帧画到 canvas 再做解码

main.js 的核心是一个循环:不断把 video 的当前帧画到 canvas,读取像素,交给 jsQR 解码。如果识别到内容,就记录结果并停止循环。

const video = document.getElementById('video'); const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const resultEl = document.getElementById('result'); let scanning = true; async function startCamera(videoEl) { const constraints = { video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 15 } }, audio: false }; const stream = await navigator.mediaDevices.getUserMedia(constraints); videoEl.srcObject = stream; return stream; } function scanOnce() { if (!scanning || video.readyState < 2) return; const maxW = 640; const scale = Math.min(1, maxW / 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 code = jsQR(imageData.data, imageData.width, imageData.height); if (code && code.data) { resultEl.textContent = '识别结果: ' + code.data; if (window.navigator.vibrate) { window.navigator.vibrate(100); } scanning = false; } } function loop() { scanOnce(); requestAnimationFrame(loop); } document.getElementById('btnStart').addEventListener('click', async function () { await startCamera(video); loop(); });

代码里的 maxW 是控制解码像素量的关键值。jsQR 的复杂度跟图像宽高直接相关,不是越大越容易识别。640 是大多数手机上比较稳的一个上限,画面清晰且帧耗时可控。如果你的页面在低端机上卡顿,优先调小这个值,而不是调短 requestAnimationFrame 间隔。

scanning 变量是扫码循环的开关,识别成功后置为 false,循环自然停止。需要重新扫码时,把它重置为 true,同时清空 resultEl 里的旧内容即可。loop 用 requestAnimationFrame,好处是跟随屏幕刷新率,不会空转。vibrate 只是静默震动提醒,有些浏览器不支持,所以先判断一下。

3.3 没有公网服务器:本地 HTTPS 怎么搭

手机要访问你电脑上的页面,直接用 http://IP 是不行的,因为 getUserMedia 要求安全上下文。本机 localhost 可以豁免,但手机不能访问你电脑的 localhost。所以要给手机的 HTTPS 访问搭一个最小的服务。

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes \ -subj "/CN=192.168.1.100" \ -addext "subjectAltName=IP:192.168.1.100"

把上面命令里的 IP 换成你自己的局域网 IP。生成 key.pem 和 cert.pem 后,用 Node 起一个静态 HTTPS 服务。

const https = require('https'); const fs = require('fs'); const path = require('path'); const options = { key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') }; const contentTypeMap = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; https.createServer(options, (req, res) => { const urlPath = req.url === '/' ? '/index.html' : req.url; const filePath = path.join(__dirname, urlPath); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404); res.end('Not Found'); return; } const ext = path.extname(filePath); res.writeHead(200, { 'Content-Type': contentTypeMap[ext] || 'application/octet-stream' }); res.end(data); }); }).listen(443, () => { console.log('HTTPS server on https://192.168.1.100'); });

把 index.html、main.js、jsQR.js 放在和 server.js 同一个目录,启动后手机访问 https://192.168.1.100 就能打开页面。第一次访问时浏览器会提示证书不受信任,需要把生成证书用的根 CA 安装到手机上并在系统设置里信任。这一步不做,摄像头 API 依然不会放行,浏览器虽然打开了页面,但安全上下文并没有被真正认可。

提示:openssl 的 -addext 参数需要 OpenSSL 1.1.1 以上版本。如果你的版本太老,生成的证书会被现代浏览器直接拒绝,导致后面所有调试都无法进行。建议先确认本机 openssl 版本,再继续。

4. 让二维码更快被扫到:分辨率、识别节流与摄像头切换

最小页面能跑通之后,你会遇到另一个问题:二维码明明对着摄像头,但半天才弹一次识别结果。原因通常是画布像素太大、每帧都在扫,或者没有利用好摄像头方向。这一章专门处理速度问题。

4.1 摄像头分辨率:别把 constraints 里的数字当绝对标准

前面提到 ideal 只是一个期望值,浏览器给到的分辨率经常和期望有出入。你真正拿到什么分辨率,要用轨道设置去查。

const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); const track = stream.getVideoTracks()[0]; const settings = track.getSettings(); console.log('实际分辨率', settings.width, settings.height);

这段代码能告诉你手机最终用的是 1280x720 还是 1920x1080。了解实际分辨率后,再决定解码画布用多大。我一般在扫码页里固定一个上限逻辑:如果源分辨率超过 960 宽,就按比例缩到 960 以内再交给 jsQR;如果源分辨率本来只有 640,就保持原尺寸,不要再缩放一遍。

原因是 jsQR 的耗时不只取决于画布尺寸,还取决于画面内容里的边缘复杂度。同一段视频流,从 1920 缩到 640,像素量只剩九分之一,识别速度可以快很多倍。但缩得太狠,二维码在画面里本来就小,解不出来。所以上限 960 和上限 640 之间,要按使用场景取舍:主要扫近距离的支付码,用 640 就够;需要扫远处货架上的码,建议放宽到 960。可以把这个值做成配置项,现场调试时随时改。

4.2 识别节流:每帧都扫反而更容易漏

看到这里你可能会以为识别速度越快越好。实际上,在低端 Android 机上每帧都跑 jsQR,会让主线程被像素计算占满,页面卡顿,二维码稍微晃动一下就错过了解码窗口。正确的做法是控制扫描频率。

let lastScanTime = 0; const SCAN_INTERVAL = 100; function loop(now) { if (now - lastScanTime >= SCAN_INTERVAL) { scanOnce(); lastScanTime = now; } requestAnimationFrame(loop); } requestAnimationFrame(loop);

这里的 SCAN_INTERVAL 单位是毫秒,100 表示每秒最多扫 10 次。不要小看这个数字,jsQR 在 640 宽画布上单帧耗时可能在 30-80ms 之间,每秒扫 10 次已经占掉了三分之一到一半的主线程时间。如果你发现页面扫码同时还有按钮点击延迟,把间隔调到 150ms 试试。

还有一个做法是检测到画面变化不大时跳过不扫,但这需要额外比较相邻帧的像素,成本比直接扫还高,不值得做。我通常只做时间节流和分辨率控制这两个手段,简单有效。如果节流之后单帧耗时仍然超过 200ms,就要回到分辨率上找答案,不要依赖节流硬扛。

4.3 切换前后摄像头:facingMode 与 enumerateDevices 的取舍

扫码功能一般只开后置摄像头,但也有人需要扫自己手机屏幕上的码,这时候就要能切换摄像头。最直接的做法是重新调用一次 getUserMedia,改变 facingMode。

async function startCamera(videoEl, facing = 'environment') { if (window.stream) { window.stream.getTracks().forEach(track => track.stop()); } const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 15 } }, audio: false }); videoEl.srcObject = stream; window.stream = stream; return stream; } async function switchCamera() { const nextFacing = window.facing === 'environment' ? 'user' : 'environment'; window.facing = nextFacing; await startCamera(video, nextFacing); }

切换前必须先 stop 掉所有旧 track,这一个步骤漏掉的后果是:第二次调用 getUserMedia 时系统提示摄像头被占用,请求直接失败,页面黑屏。iOS 上这个问题尤其明显,旧流的摄像头灯可能还亮着,但新流已经拿不到权限了。

至于 enumerateDevices,它能列出更精确的设备列表和设备 ID,但在移动端,用户没授权前设备标签往往是空的,而且 iOS 很多时候只有一个设备条目。我建议移动端默认就用 facingMode 切换,简洁也够用。如果以后需要更细的机型适配,把 facingMode 和 deviceId 做成二选一的配置,通过 URL 参数控制。

5. 扫码页避坑记录:移动端 5 个高频问题与排查路径

一套流程跑过几轮之后,我把移动端 H5 扫码最常遇到的五个坑整理在这里。每条都按现象、原因、解决三个步骤写,方便你排查时对照。

5.1 一打开摄像头就黑屏,控制台报 insecure context

现象:手机通过 http 访问页面,video 区域黑屏,控制台报这个错。 原因:页面不在安全上下文,getUserMedia 接口虽然存在,但被浏览器策略禁止调用。 解决:把页面改成 HTTPS 访问。不要试图用 http 加参数绕过,没有这种窗口。本机 localhost 例外,但这只对电脑本身有效,手机访问不了。所以回到第 3.3 节,老老实实搭 HTTPS 并信任证书。这条坑也是标题里“要用https访问”的直接来源。

5.2 iOS 上第一次点按钮没反应,权限弹窗不出来

现象:点击“开始扫码”后没有报错,也没有弹窗,摄像头不开。 原因:iOS 对 getUserMedia 的调用时机有严格要求,必须在一个用户手势事件的同步调用链里。如果在页面 DOMContentLoaded 后异步调用,浏览器会直接静默拒绝。 解决:把 startCamera 放到按钮 click 事件的回调里,然后在这个回调内部 await。注意不要在 click 回调里先做个 setTimeout 再调用,那会掉出手势链,一样失败。这个坑我在真机上遇到过,花了一整个下午才定位。

5.3 安卓上面面正常,但二维码一直识别不出

现象:视频清晰,二维码完整显示在画面里,jsQR 一直返回 null。 原因:解码画布尺寸太小,或者二维码在画面中的占比太低。jsQR 对二维码的最小像素宽度有隐性要求,如果码区域不足画布尺寸的 20%,定位基本靠运气。 解决:把二维码尽量靠近摄像头,让它占画面三分之一以上;同时把解码画布的上限从 640 提到 960,换更多像素给解码器。如果这两步都做了还不行,检查摄像头是否反光,适当倾斜手机避开顶光。这个和算法没关系,属于拍摄环境问题。

5.4 前置摄像头画面是镜像,识别坐标对不准

现象:用前置摄像头扫码时,画面里自己的脸是正常的(镜像),但识别区域和用户看到的位置完全对不上。 原因:前置摄像头默认输出原始画面,而 video 预览时浏览器会自动做镜像,canvas drawImage 拿到的却是没有镜像的原始帧,两边坐标不匹配。 解决:在 drawImage 前先做一次水平翻转,再取图像数据:

ctx.save(); ctx.translate(canvas.width, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.restore(); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

注意翻转之后再用 getImageData,因为 jsQR 需要拿到和用户视觉一致的图像。如果你扫码时不需要绘制坐标框,可以不做翻转,识别结果不受影响,只有依赖坐标的 UI 才需要这个操作。

5.5 自签证书在手机上被识别为不安全,摄像头还是不开

现象:已经用 https 打开页面,但摄像头依旧黑屏,控制台也没有明显报错。 原因:自签证书没有被手机系统信任,浏览器认为这个连接仍然可能被中间人攻击,摄像头权限策略没有放行。 解决:不仅要把证书安装到手机,还要到系统设置里找到“已安装的证书”并手动开启完全信任。这一步在 iOS 和 Android 上的路径不同,iOS 的证书信任开关藏在“设置-通用-关于本机-证书信任设置”里,装上证书不去开开关等于白装。这算是本地调试里血泪经验最多的地方。

6. 进阶验证:FPS 监控、虚拟流调试与扫码提示音

页面跑通后,下一步是验证它“经调试可用”不是靠运气。我会在页面上加一个简单的 FPS 面板,用它判断真实识别频率。

let scanFrameCount = 0; let lastTime = 0; function fpsLoop(now) { scanFrameCount++; if (now - lastTime >= 1000) { console.log('每秒识别次数:', scanFrameCount); scanFrameCount = 0; lastTime = now; } requestAnimationFrame(fpsLoop); }

这个计时器挂在真实的扫循环里,看到每秒识别次数在 8-12 之间,说明节流参数合理;如果掉到 5 以下,优先降低画布分辨率,而不是降 SCAN_INTERVAL,因为时间节流不能补性能。

没有真机时,我常用支持虚拟摄像头的调试环境,把一张二维码图片作为视频源来验证解码流程。这样方便反复测试不同码内容,不必每轮都拿起手机。实际真机调试时,建议准备三张不同大小和颜色的二维码,分别测近距、远距和暗光,避免只盯着一个样本调参。

提示音不需要下载音频文件,用 Web Audio API 几行代码就能在识别成功时播一声:

function beep() { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); osc.frequency.value = 880; gain.gain.setValueAtTime(0.01, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.3, audioCtx.currentTime + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.3); osc.start(); osc.stop(audioCtx.currentTime + 0.3); }

移动端 AudioContext 也要等到用户点击之后才能出声,所以不要在扫码成功后临时创建再播放,建议在按钮点击时创建并复用同一个实例。把这些都调完再交给测试,扫码页才算真的稳定。

我现在接到扫码需求,第一件事永远是把 HTTPS 壳子搭好,让手机先能打开摄像头,再写解码逻辑。跳过这一步直接写识别,大概率会翻车在一轮黑屏调试上。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询