前端二维码:从画一个码到摄像头实时扫码,我踩过的坑都在这
做过一个门店核销的小工具,前端要同时管两头:一头把一串核销码画成二维码给用户看,另一头调摄像头把店员手机上的码扫进来。听起来两个功能都是「装个库调一下」的活儿,真做起来一堆细节能把人绊倒——logo 盖上去扫不出来、暗光下识别率惨、iOS Safari 摄像头黑屏、扫描循环把 CPU 跑满。这篇把我这半年攒下来的实战经验倒一遍,代码都能直接跑。
一、生成:canvas 画码远比想象中琐碎
先说生成。二维码的编码规则(分组、纠错、掩码、Reed-Solomon)自己实现纯属跟自己过不去,直接上 qrcode 这个库最省事。但我更常用 qrcode 的 toCanvas,因为后面要往上盖 logo,必须拿到 canvas 的绘图上下文。
import QRCode from 'qrcode'
async function drawQR(text, size = 320) {
const canvas = document.createElement('canvas')
await QRCode.toCanvas(canvas, text, {
width: size,
margin: 2, // 静区,别设 0,否则很多扫码器识别不了
errorCorrectionLevel: 'H', // 纠错级别,后面细说
color: { dark: '#1a1a1a', light: '#ffffff' },
})
return canvas
}
有两个参数第一次用容易忽略。一个是 margin(静区/quiet zone),二维码四周得留一圈白边,规范建议至少 4 个模块宽。我早期为了排版好看把它压到 0,结果一部分老款扫码枪直接识别不出来——因为定位图案需要靠这圈留白做边界判定。另一个是 width 和实际内容量的关系:内容越长,二维码的「版本」越高,模块(那些小黑点)越密,同样物理尺寸下每个模块占的像素就越少。核销码我一般控制在 30 个字符以内,超过之后打印出来手机得凑很近才扫得到。
顺带说个数据编码模式的坑。二维码有数字、字母数字、字节(UTF-8)几种编码模式,同样长度下数字模式最省、字节模式最费。如果你的核销码是纯数字,qrcode 库会自动挑数字模式,码就疏;一旦掺进小写字母或中文,就落到字节模式,同样位数的码会明显变密。我后来干脆把核销码约定成「大写字母+数字」,能命中 alphanumeric 模式,比混合大小写省不少空间。这种取舍在内容长度卡边界的时候很关键——一个字符之差可能就把版本顶上去一档。
生成侧还有个高分屏别踩的坑:canvas 画出来要显示在页面上时,width 设的是 CSS 像素,Retina 屏下会被拉糊。想清晰得按 devicePixelRatio 放大画布再用 CSS 缩回去,否则用户截图保存下来的码边缘发虚,虽然还能扫但看着廉价。
二、纠错级别 L/M/Q/H,和 logo 是一对冤家
二维码有四档纠错:L(约 7%)、M(15%)、Q(25%)、H(30%)。这个百分比是「即使这么多模块被遮挡/污损,依然能还原出原始数据」的冗余度。级别越高,能塞的有效数据越少,同样内容画出来的码就越密。
为什么大家都爱在中间盖个 logo?因为纠错机制允许一定比例的模块「丢失」。但这里有个反直觉的坑:纠错能救回的是随机分布的错误,不是集中在一块的大面积遮挡。你把 logo 做得太大、正好压住了定位图案或者一大片数据区,就算数学上遮挡比例没超 30%,实际也扫不出来。
我的经验值:
- logo 只盖在正中心,直径不超过整码宽度的 1/5,H 级别下基本稳。
- logo 底下垫一层白色圆角矩形(padding 出去几个像素),让 logo 边缘和二维码模块之间有过渡,别让彩色 logo 直接压在黑白模块交界处,那会干扰二值化。
- 用了 logo 就一律上 H 级别,别省。
function drawLogo(canvas, logoImg, ratio = 0.2) {
const ctx = canvas.getContext('2d')
const size = canvas.width
const logoSize = size * ratio
const pos = (size - logoSize) / 2
const pad = logoSize * 0.12
// 先垫一块白底,圆角靠 clip 也行,这里图省事用矩形
ctx.fillStyle = '#fff'
ctx.fillRect(pos - pad, pos - pad, logoSize + pad * 2, logoSize + pad * 2)
ctx.drawImage(logoImg, pos, pos, logoSize, logoSize)
}
真要上线,务必拿几台不同的手机、几款不同的扫码 App 都试一遍。我遇到过同一个码,系统相机能扫、微信也能扫,但某个第三方物流 App 死活扫不出——最后发现是它的二值化算法对我那个半透明 logo 特别敏感,把 logo 换成不透明的就好了。扫码器的实现差异,是纸面参数覆盖不到的。
三、扫码:原生 BarcodeDetector 优先,jsQR 兜底
到了扫码这头,2024 年以后我的策略变成了「能用原生就用原生」。浏览器有个 BarcodeDetector API,底层走的是系统级的识别能力(比如 Android 上是 ML Kit),识别率和性能都比纯 JS 的库强一大截。
但它的兼容性还没铺满:Chrome/Edge 桌面和安卓上支持得不错,iOS Safari 直到较近的版本才跟上,而且部分环境里 BarcodeDetector 存在但支持的格式列表是空的。所以正确姿势是能力检测 + 降级到 jsQR:
async function createDetector() {
if ('BarcodeDetector' in window) {
try {
const formats = await window.BarcodeDetector.getSupportedFormats()
if (formats.includes('qr_code')) {
const native = new window.BarcodeDetector({ formats: ['qr_code'] })
return async (source) => {
const codes = await native.detect(source)
return codes[0]?.rawValue ?? null
}
}
} catch (e) {
// 有的环境构造函数会抛,直接落到 jsQR
}
}
// 降级:jsQR 需要 ImageData
const jsQR = (await import('jsqr')).default
return (imageData) => {
const r = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert', // 只认深色码,省一半算力
})
return r?.data ?? null
}
}
注意这两条路的输入不一样:BarcodeDetector.detect() 可以直接吃 <video>、<canvas>、ImageBitmap;jsQR 只吃 ImageData,得先把画面画到 canvas 再 getImageData 抠出来。下面的扫描循环得把这层差异抹平。
四、getUserMedia 取流,这里全是环境坑
调摄像头用 getUserMedia,三个前提条件缺一不可,缺哪个都是黑屏或直接报错:
- 必须 HTTPS(或
localhost)。http 页面拿不到摄像头权限,这一条卡住过我半天,本地用 IP 访问测就是不行。 facingMode: 'environment'请求后置摄像头。但这只是「建议」,桌面端没有后置就退回默认,得容错。- iOS Safari 的
<video>一定要加playsinline和muted,否则它会强制全屏播放,扫码界面直接废掉。
async function openCamera(video) {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
})
video.srcObject = stream
video.setAttribute('playsinline', '') // iOS 关键
video.muted = true
await video.play()
return stream
}
分辨率我一般要 720p 就够,不要贪 1080p 甚至 4K——分辨率越高,每帧要处理的像素越多,扫描循环越卡,而二维码识别根本不需要那么细。真机上 720p 的识别率和 1080p 几乎没差别,但 CPU 占用差一截。
还有个容易漏的:用完一定要关流。stream.getTracks().forEach(t => t.stop()),不关的话摄像头指示灯一直亮,用户会觉得你在偷拍,体验很糟。
五、扫描循环:requestAnimationFrame + 裁剪区域提速
核心是一个逐帧识别的循环。很多教程直接 setInterval(scan, 100),我不推荐——页面切到后台时 setInterval 还在跑,白白烧电。用 requestAnimationFrame 更自然,页面不可见时浏览器自动暂停。
关键的性能优化是只扫描画面中心那一小块,而不是整帧。用户扫码时基本会把码对准中间,你没必要拿整个 1280×720 去跑识别。裁一个中心正方形区域,识别的像素量能砍掉一大半,帧率立刻上来。
function startScanLoop(video, detect, onResult) {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })
let stopped = false
async function tick() {
if (stopped) return
if (video.readyState === video.HAVE_ENOUGH_DATA) {
const vw = video.videoWidth, vh = video.videoHeight
// 取中心 60% 的正方形作为扫描区
const side = Math.min(vw, vh) * 0.6
const sx = (vw - side) / 2, sy = (vh - side) / 2
canvas.width = side
canvas.height = side
ctx.drawImage(video, sx, sy, side, side, 0, 0, side, side)
try {
// BarcodeDetector 吃 canvas,jsQR 吃 ImageData,这里统一给 ImageData
const imageData = ctx.getImageData(0, 0, side, side)
const value = detect.length && detect.native
? await detect(canvas)
: await detect(imageData)
if (value) {
onResult(value)
return // 扫到就停,别继续
}
} catch (e) {
// getImageData 在跨域视频上会抛 SecurityError,摄像头流不会,但保险起见 catch
}
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
return () => { stopped = true }
}
willReadFrequently: true 这个 flag 值得单独提一句。频繁调 getImageData 的场景加上它,浏览器会把 canvas 放到 CPU 内存而不是 GPU,避免每帧一次 GPU→CPU 的回读,实测能省掉可观的开销。我第一次没加,扫码循环莫名其妙卡,加上之后帧率直接翻倍。
(上面为了示意把两条分支的判断写得有点糙,实战里我会在 createDetector 里就统一好输入契约,让 detect 永远只吃 ImageData,循环里不再分叉。)
六、识别率的现实:暗光、反光、畸变
代码跑通只是及格线,真正拉开差距的是识别率。几个我实测过的坑:
暗光。光线不足时摄像头自动拉高 ISO,画面噪点暴增,二值化把噪点误判成模块,识别直接崩。能做的是引导用户——检测到连续多帧没识别出来,就在界面提示「光线太暗,靠近点」。别指望前端做图像增强,把一帧图做去噪+对比度拉伸的算力,还不如提示用户换个环境。
反光。塑封的码、手机屏幕上显示的码,摄像头一照一片高光,那块区域的模块全糊了。屏幕对屏幕扫码(一个手机显示码、另一个扫)尤其容易翻车,因为还有摩尔纹。这种场景我会建议把码的显示亮度调低一点,反而更好扫。
畸变。码没对正、有倾斜角度时,jsQR 这类纯 JS 库的容忍度明显不如原生。这也是我坚持「原生优先」的原因之一——ML Kit 那套底层对透视畸变的矫正能力,纯 JS 短时间追不上。
连续识别的防抖。扫到一次就回调,但摄像头一秒几十帧,很容易同一个码触发好几次。加个简单的锁:扫到后立刻 stopped = true 停循环,或者记住上次的值 + 时间戳,几秒内相同值不重复触发。
对焦跟不上。近距离扫小码时,很多手机的自动对焦会来回拉风箱,画面一会儿清一会儿糊,识别时断时续。纯 Web 端能干预的手段有限——getUserMedia 的 advanced 约束里有 focusMode,但支持的机型不多,我一般不指望它。更实在的做法是界面上画一个取景框,把用户的手引到合适距离,让人去配合摄像头,而不是让代码去追焦。这类「用交互设计补硬件短板」的思路,在扫码这块比堆算法有用得多。
七、诚实的边界
把丑话说前面,免得你按这篇上线后怪我:
- 纯前端扫码不适合做安全校验。二维码内容是明文,纠错也好、加密也好,都得在后端验签。前端只负责「读出来」,别在前端判断「这个码有没有被用过」。
- jsQR 只认二维码,条形码、DataMatrix 都不行。要扫一维码,要么靠
BarcodeDetector(它支持多种格式),要么上 ZXing 的 JS 移植版,但后者体积不小,按需加载。 - iOS 上的兼容性依然是最大变数。同一版 iOS 的 Safari 和微信内置浏览器(WKWebView)行为可能不一致,
playsinline、BarcodeDetector支持情况都得在目标机型上真机验,模拟器和桌面 Chrome 测出来的不算数。 - 我给的裁剪 60%、logo 1/5、720p 这些数值是经验值不是定理,你的业务如果是远距离扫大海报,或者近距离扫小标签,参数得重新调。别照抄,拿真实场景测。
这套东西我在 tudingai.cn 上的一个小工具里跑了几个月,日常量级下稳定。前端做二维码,难点从来不在「怎么画出来」,而在「各种破环境下还能扫出来」——多备几台真机,比读十篇文档都管用。

浙公网安备 33010602011771号