最近在给一个管理类桌面工具加“扫码录入”功能时我把 Electron、Vue、摄像头、二维码这几个词拼到了一起。需求听起来很常规用户打开桌面端用摄像头扫一下二维码把结果自动填进表单。但真正动手之后才发现Electron 调用摄像头扫描二维码这条路比想象中多不少暗坑。从扫码库选型、页面加载地址、权限申请到打包后的系统授权每一层都可能让你在“开发环境好好的一打包就黑屏”的怪圈里折腾半天。这篇文章把我这次完整选型和实现过程记录下来包括为什么没用某些现成库、摄像头权限到底怎么放行、Vue 组件里的扫码循环怎么写以及打包分发阶段最容易被忽略的几个坑。适合正好在 Electron Vue 里做摄像头扫码、或者想在桌面端打开本机相机做条码识别的同学参考。1. 扫码库的底层差异决定了我放弃“开箱即用”方案1.1 三种路线我都跑了一遍最后选了纯 JS 解码做摄像头扫码第一步就是选解析库。市面上公开的解法大致有三条路用封装好的扫码插件、用纯 JS 解码库读帧、用浏览器内置的条码检测接口。我分别搭了一个最小 Demo 试差别非常明显。第一种是html5-qrcode这类封装库。它对移动端浏览器很友好能在页面上直接渲染出摄像头画面、自动切镜头、自带扫码框和结果回调。在 Electron 里它也能跑但问题在于 UI 样式不好统一封装组件为了兼容各种环境内部逻辑较重而且一旦项目对扫码框、提示文案、结果去重有定制要求改起来反而比从头做还费劲。第二种是用jsQR这类纯 JS 解码库。它本身不做任何 UI只接收一帧图像的像素数据返回二维码的内容。我要做的就是在 Vue 组件里拿到摄像头视频流定时截帧把图像数据丢给 jsQR 解析。优点是可控性强UI 完全是自己写的扫到什么、什么时候触发、同一结果怎么去重逻辑都在自己手里。缺点是基础工作要自己做比如视频尺寸裁剪、解码频率控制、取景框逻辑。第三种是BarcodeDetector。Chromium 内核自带这种条码检测能力理论上性能最好用起来也简单const detector new BarcodeDetector({ formats: [qr_code] }) const codes await detector.detect(video)顺着这条路走下去会有两个现实问题一是 Electron 的内核版本不同打包后的电脑系统千差万别你不能保证所有目标机器上的 BarcodeDetector 都可用二是它直接对video元素检测在部分硬件加速环境下会把视频帧挂在 GPU 上读帧时机不可控。作为一个需要长期维护、可能跑在不同机器上的桌面工具我不太想把核心能力押在这样一个不稳定边界上。三种方案放在一起对比各自的适用场景会更清楚方案底层机制上手成本定制空间我的取舍html5-qrcode封装组件内部自己调摄像头并解码最低低样式和流程受控件约束适合快速演示不适合深度定制jsQR手动截帧把 ImageData 交给解码器中高所有流程自己掌控最终采用BarcodeDetector浏览器原生条码检测低受 Chromium 版本影响可以备用不作为主方案1.2 二维码不是“拍下来”就行解码要看图像质量用 jsQR 这类库之前得先理解它到底在做什么。二维码里除了黑白块还包含三个角落的“回”字形定位图案、若干校正图形和数据区域。解码器拿到一张图片后会先寻找定位图案再根据透视关系对图像做采样重建最后按容错等级纠错并读出内容。这个机制决定了它非常依赖图像清晰度。二维码边缘模糊、光照不均、反光、角度太偏、拍摄距离过近导致取景框截断二维码都可能让解码器找不到定位图案或者采样时错位。jsQR 内部分了好几层容错但它不是魔法输入的是糟糕图像输出自然是空。所以扫码界面上一定要有引导提示而不是甩一个黑乎乎的摄像头画面给用户。我后来在取景框下方加了一行说明请保持二维码平整、光线充足、不要贴得太近。这几个字看起来简单实际效果比我在代码里调任何参数都明显。2. 摄像头权限和加载地址的关系两个容易卡住的点2.1 主进程不放行 media 权限摄像头画面永远是黑的Electron 里的摄像头权限和普通浏览器不是一个逻辑。浏览器访问 HTTPS 页面时会自动弹出摄像头授权框但 Electron 默认会走自己的一套权限模型。如果你直接把页面丢进 BrowserWindow大概率会看到摄像头黑屏控制台也没有明确的报错信息。我第一次遇到时查了很久最后发现是主进程根本没有给渲染进程放行 media 权限。Electron 提供了session.setPermissionRequestHandler你需要在主进程里手动决定哪些权限可以给、哪些不能给const { app, BrowserWindow, session } require(electron) app.whenReady().then(() { session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) { const url webContents.getURL() // 只放行当前应用自己页面发起的摄像头权限请求 if (permission media url.startsWith(app://)) { callback(true) } else { callback(false) } }) createWindow() })这里有一个安全细节值得强调权限校验一定要带上url.startsWith(app://)而不是看到media就直接放行。Electron 窗口如果被导航到任意外部页面外部页面也能发起摄像头请求。桌面端权限模型比浏览器更“默认信任”所以在主进程层面把住关口很重要。我最初只写了callback(true)开发环境一切正常。后来把窗口里的外部链接打开了一下发现第三方页面也能唤起摄像头才意识到这个校验不能省。2.2 getUserMedia 必须在安全上下文里跑生产环境不能直接用 file://摄像头媒体流走的是navigator.mediaDevices.getUserMedia而浏览器规范要求这个 API 只能在“安全上下文”里调用。在 Electron 里“安全上下文”不是一个默认成立的概念。开发时如果用loadFile加载本地 HTML页面地址是file://...在部分 Electron 版本和系统组合下getUserMedia可能直接不工作或者能获取到流但画面无法显示。我后来统一改成加载自定义协议生产环境和开发环境都通过一个app://开头的内容地址来打开页面。这样既避免file://的兼容性问题又能在权限校验时通过 URL 前缀区分自己的页面。实现方法是在主进程启动前注册协议特权const { protocol, net } require(electron) const path require(path) const { pathToFileURL } require(url) protocol.registerSchemesAsPrivileged([ { scheme: app, privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true, }, }, ])然后在app.whenReady()里用protocol.handle接管这个协议把请求映射到本地打包目录的文件app.whenReady().then(() { protocol.handle(app, (request) { const { pathname } new URL(request.url) const cleanPath decodeURIComponent(pathname).replace(/^\//, ) const filePath path.join(__dirname, dist, cleanPath) return net.fetch(pathToFileURL(filePath).toString()) }) // 切换摄像头前需要先确认权限 session.defaultSession.setPermissionRequestHandler(handler) createWindow() })这里有一层容易被忽略的逻辑即使你在主进程放行了media权限如果页面地址本身不是安全上下文getUserMedia一样起不来。所以权限放行和页面协议注册要一起改只处理其中一个问题还是会反复出现。另一个相关的坑是不要因为摄像头不工作就随手关掉webSecurity。把webSecurity: false打开确实能解决一部分跨域资源问题但同时也会让页面所有来源都变得不可信摄像头权限校验等于形同虚设。正规做法始终是注册app://协议、标记secure、在主进程按 URL 前缀放行权限。3. Vue 组件里把视频流变成可用的扫码循环3.1 从摄像头画面到二维码结果的核心代码选定了 jsQR主进程也处理了权限和协议剩下的问题就聚焦在 Vue 渲染层的扫码循环上。核心思路是通过getUserMedia拿到视频流丢给video元素播放每一帧从视频里截取画面绘制到隐藏的 canvas 上再把 canvas 的像素数据交给 jsQR 解析。Vue 3 的组件可以这样写template div classscanner video refvideoRef muted playsinline/video canvas refcanvasRef classscan-canvas/canvas div v-iftip classscanner-tip{{ tip }}/div /div /template script setup import { onMounted, onBeforeUnmount, ref } from vue import jsQR from jsqr const videoRef ref() const canvasRef ref() const tip ref(正在打开摄像头...) const emit defineEmits([scan]) let stream null let rafId 0 let stopFlag false let lastResult let lastResultTime 0 const CROP_SIZE 400 async function startScan() { stream await navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: environment, width: { ideal: 1280 }, height: { ideal: 720 }, }, }) const video videoRef.value video.srcObject stream await video.play() tip.value 请对准二维码保持画面稳定 rafId requestAnimationFrame(tick) } function tick() { if (stopFlag) { return } const video videoRef.value const canvas canvasRef.value if (video.readyState video.HAVE_CURRENT_DATA) { const size Math.min(CROP_SIZE, video.videoWidth, video.videoHeight) const sx (video.videoWidth - size) / 2 const sy (video.videoHeight - size) / 2 canvas.width size canvas.height size const ctx canvas.getContext(2d, { willReadFrequently: true }) ctx.drawImage(video, sx, sy, size, size, 0, 0, size, size) const imageData ctx.getImageData(0, 0, size, size) const code jsQR(imageData.data, size, size, { inversionAttempts: attemptBoth, }) if (code typeof code.data string code.data.length 0) { const now Date.now() if (code.data ! lastResult || now - lastResultTime 1500) { lastResult code.data lastResultTime now emit(scan, code.data) } } } rafId requestAnimationFrame(tick) } function stopScan() { stopFlag true cancelAnimationFrame(rafId) if (stream) { stream.getTracks().forEach((track) track.stop()) stream null } } onMounted(startScan) onBeforeUnmount(() { stopScan() }) /script这段代码有几个细节值得说明。canvas.getContext(2d, { willReadFrequently: true })是给浏览器一个提示这个 canvas 会被频繁读取像素数据让它不要做太多 GPU 相关优化否则后续getImageData的耗时可能不固定。muted playsinline是让视频元素不需要音频权限也避免在桌面端出现自动播放限制。requestAnimationFrame驱动解码循环视觉上比较流畅。3.2 截取中间区域而不是解码整幅画面很多人一开始会把整个摄像头画面都画进 canvas再整张丢给 jsQR。这样不是不行但桌面端摄像头画面往往是 1280x720 或更高分辨率每一帧都要读几十万个像素数据CPU 开销很大识别速度反而会受影响。二维码解码并不需要整幅画面它只需要完整包含二维码的最小区域。用户把二维码对准取景框中间时我就只截取视频中间的一块正方形区域比如 400x400把这部分缩放到 canvas 里解码。这样像素数量大幅减少同时还能把解码范围限制在取景框内避免画面边缘出现无关图案干扰结果。截取逻辑在tick里看得出来const size Math.min(CROP_SIZE, video.videoWidth, video.videoHeight) const sx (video.videoWidth - size) / 2 const sy (video.videoHeight - size) / 2 ctx.drawImage(video, sx, sy, size, size, 0, 0, size, size)这个方案唯一的限制是二维码不能超出中间取景区域但这对扫码体验是合理约束。UI 上画一个取景框用户自然会把二维码放进去。3.3 切换摄像头时先停掉正在跑的帧循环桌面端经常会插一个外接 USB 摄像头或者笔记本自带前置摄像头。切换摄像头时最容易犯的错误是直接换srcObject旧的视频流没有停掉后端的多个摄像头设备会一直占用资源甚至出现画面卡死。正确的做法是先完整停掉当前扫码循环再重新创建新的视频流function changeCamera(deviceId) { stopScan() navigator.mediaDevices .getUserMedia({ audio: false, video: { deviceId: { exact: deviceId }, }, }) .then((newStream) { stream newStream videoRef.value.srcObject newStream return videoRef.value.play() }) .then(() { stopFlag false rafId requestAnimationFrame(tick) }) }页面销毁时同样要走stopScan。很多 Electron 应用关闭扫码页面后摄像头的指示灯还亮着就是因为stream.getTracks().forEach(track track.stop())没执行。这看起来是小问题但用户对“摄像头一直亮着”这件事非常敏感尤其是桌面端应用。4. 识别失败、重复触发、性能开销逐个解决4.1 解码成功率更多由拍摄条件决定同样的识别代码在光线充足和平放桌面的二维码上基本能稳定扫出在背光、反光、手持抖动的环境下识别率会断崖式下降。这是二维码本身的结构决定的不是库的优劣。jsQR 的解码本质上是对图像做定位和采样它需要三个定位角点都足够清晰可辨。可以做的改进有两个方向。一是从解码参数入手jsQR 支持inversionAttempts选项。默认的attemptBoth会同时尝试原始颜色和反色版本适合应对部分反色二维码但解码耗时也会增加。如果业务里几乎不会出现反色码可以改成dontInvert来省掉一半计算量。二是从摄像头采集端入手尽量用facingMode: environment来偏向使用后置摄像头或外接摄像头。桌面端没有“后置”概念这个参数实际效果有限所以更重要的是在 UI 上增加视频画面镜像开关。笔记本前置摄像头默认从镜像视角看而外接 USB 摄像头通常不是提前给用户一个“左右镜像”切换按钮能少收不少反馈工单。4.2 同一结果反复触发扫码一次却提交了三次扫码成功后如果不做去重结果是灾难级的。用户在扫码框前停留半秒钟requestAnimationFrame可能已经触发了十几次解码每次都能解出同一个二维码表单里瞬间出现三行相同数据。去重逻辑我用的是“结果内容 时间窗口”双重判断同一个二维码内容在 1.5 秒内只触发一次scan事件如果是不同的二维码内容则立即触发。这样既能防止重复提交又不会影响连续扫不同码的业务需求。if (code.data ! lastResult || now - lastResultTime 1500) { lastResult code.data lastResultTime now emit(scan, code.data) }如果你做的是连续扫码场景比如一个流水线上连续扫好几个码还可以在确认扫码成功后清空lastResult或者用提示音让操作者明确知道本次扫码已经完成。4.3 60 帧全量解码太浪费可以降频处理requestAnimationFrame在 60Hz 屏幕上大约每 16ms 触发一次但二维码识别并不需要这么高的频率。尤其是裁剪区域后虽然像素量降下来了但每次getImageData加 jsQR 解码仍然有几十毫秒的耗时。如果每一帧都全量解码CPU 占用率会明显升高笔记本风扇转起来的速度比你想象得快。我最后的做法是给解码循环加了一个节流计时器只有距上次解码超过 100ms 才真正执行一次解码let lastDecodeTime 0 function tick(timestamp) { if (stopFlag) return if (timestamp - lastDecodeTime 100) { decodeFrame() lastDecodeTime timestamp } rafId requestAnimationFrame(tick) }同时监听页面的可见性变化。当 Electron 窗口最小化或者切到后面时直接暂停扫码循环恢复后再启动。节省的比你想的多。5. 打包后的暗坑权限声明、摄像头排序和资源形态5.1 macOS 打包后黑屏多半是 entitlements 里没开摄像头权限开发环境能扫码打成 dmg 安装后摄像头黑屏这是 Electron 打包中最常见的回报。原因通常是 macOS 沙盒权限没有声明摄像头使用权限。用打包工具配置 macOS 的 entitlements 时必须加上两个关键项?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keycom.apple.security.app-sandbox/key true/ keycom.apple.security.device.camera/key true/ /dict /plist更隐蔽的是macOS 对签名后的应用会有额外检查。如果你在开发环境使用的是未签名版本摄像头一切正常一旦签名并启用了 hardened runtime而没有把 camera entitlement 一并签进去摄像头就会再次失效。所以打包配置里 entitlements 文件要同时应用到父进程和子进程不要只配一处。5.2 摄像头设备 ID 不稳定不能默认“第一个就是外接摄像头”桌面端摄像头设备枚举结果和移动端不太一样。笔记本自带摄像头、外接 USB 摄像头、虚拟摄像头软件它们的deviceId在每次插拔后可能完全变化。如果你在设置页里保存了设备 ID下次启动可能对不上。所以我在相机切换逻辑里不写死设备顺序而是保存“最近一次成功启用的摄像头 label”下次启动后通过enumerateDevices找到 label 匹配的设备再恢复。这在大部分场景下能兜住比硬编码 index 靠谱得多。5.3 尽量别引入原生解码依赖发布资源和跨平台问题会成倍增加一开始我也考虑过用某个原生 OCR 或二维码解码扩展来提高性能理由是纯 JS 解码在低端电脑上可能力不从心。但细想后放弃了原生模块需要针对多个操作系统分别编译打包后还要处理 asar 里的资源路径问题后续每升级一次 Electron 版本可能都要重新编译一轮。jsQR 这类纯 JS 方案在画面分辨率控制在 400x400 之后解一张图的开销通常在 30ms 以内桌面端完全没有瓶颈。与其引入原生依赖不如把精力放在取景框引导、解码频率控制这些更可控的地方。真正需要原生能力的时候一般不是解码更慢而是摄像头设备本身有问题比如驱动异常、权限被系统策略锁定。这些情况下原生模块也救不了你正确方向是给用户清晰的错误提示和系统设置引导。最后说一个我这次印象最深的经验摄像头权限、页面协议、解码循环这三件事必须作为一个整体来设计。只调 Vue 组件不改主进程画面出不来只改主进程权限却用file://加载页面getUserMedia还是可能静默失败扫描循环做完了却没有去重上线第一天就能收到数据重复的反馈。按照这个顺序把每一层打通Electron 桌面端扫码其实没有很多玄学剩下的都是耐心比对细节。
阅读完成 · 觉得有帮助?