前端二维码:从画一个码到摄像头实时扫码,我踩过的坑都在这

做过一个门店核销的小工具,前端要同时管两头:一头把一串核销码画成二维码给用户看,另一头调摄像头把店员手机上的码扫进来。听起来两个功能都是「装个库调一下」的活儿,真做起来一堆细节能把人绊倒——logo 盖上去扫不出来、暗光下识别率惨、iOS Safari 摄像头黑屏、扫描循环把 CPU 跑满。这篇把我这半年攒下来的实战经验倒一遍,代码都能直接跑。

一、生成:canvas 画码远比想象中琐碎

先说生成。二维码的编码规则(分组、纠错、掩码、Reed-Solomon)自己实现纯属跟自己过不去,直接上 qrcode 这个库最省事。但我更常用 qrcodetoCanvas,因为后面要往上盖 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,三个前提条件缺一不可,缺哪个都是黑屏或直接报错:

  1. 必须 HTTPS(或 localhost)。http 页面拿不到摄像头权限,这一条卡住过我半天,本地用 IP 访问测就是不行。
  2. facingMode: 'environment' 请求后置摄像头。但这只是「建议」,桌面端没有后置就退回默认,得容错。
  3. iOS Safari 的 <video> 一定要加 playsinlinemuted,否则它会强制全屏播放,扫码界面直接废掉。
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 端能干预的手段有限——getUserMediaadvanced 约束里有 focusMode,但支持的机型不多,我一般不指望它。更实在的做法是界面上画一个取景框,把用户的手引到合适距离,让人去配合摄像头,而不是让代码去追焦。这类「用交互设计补硬件短板」的思路,在扫码这块比堆算法有用得多。

七、诚实的边界

把丑话说前面,免得你按这篇上线后怪我:

  • 纯前端扫码不适合做安全校验。二维码内容是明文,纠错也好、加密也好,都得在后端验签。前端只负责「读出来」,别在前端判断「这个码有没有被用过」。
  • jsQR 只认二维码,条形码、DataMatrix 都不行。要扫一维码,要么靠 BarcodeDetector(它支持多种格式),要么上 ZXing 的 JS 移植版,但后者体积不小,按需加载。
  • iOS 上的兼容性依然是最大变数。同一版 iOS 的 Safari 和微信内置浏览器(WKWebView)行为可能不一致,playsinlineBarcodeDetector 支持情况都得在目标机型上真机验,模拟器和桌面 Chrome 测出来的不算数。
  • 我给的裁剪 60%、logo 1/5、720p 这些数值是经验值不是定理,你的业务如果是远距离扫大海报,或者近距离扫小标签,参数得重新调。别照抄,拿真实场景测。

这套东西我在 tudingai.cn 上的一个小工具里跑了几个月,日常量级下稳定。前端做二维码,难点从来不在「怎么画出来」,而在「各种破环境下还能扫出来」——多备几台真机,比读十篇文档都管用。

posted @ 2026-07-14 09:15  谙忆  阅读(27)  评论(0)    收藏  举报