基于jsqr的前端扫码组件实现(React)

一、功能描述

调取用户相机获取相机视频画面,解析画面中的二维码并得到解析结果。

实现效果:

  • 扫码过程中,有扫描动画效果
  • 扫码出结果后,扫码动画效果结束,并在相机视频当前帧定格并在画面中框选出监测到的二维码

基于jsqr的前端扫码组件实现(React)

在线体验demo:pts-f.vercel.app/demo/qrscan (项目部署在vercel上,需要qiang)

基于jsqr的前端扫码组件实现(React)

二、实现流程

基于jsqr的前端扫码组件实现(React)

三、核心代码

  • React 组件 QRScanner
  • 入参:
    • onScanned: 扫码成功回调(成功后就会关闭相机视频,不再继续扫码行为)
    • onError: 异常回调(并不一定会中断扫码行为,只不过可以通过这个回调捕获扫码过程中的异常)
const QRScanner: React.FC<QRScannerProps> = ({
  onScanned,
  onError,
  // ...
}) => {
  // ...
  
  useEffect(() => {
    // ...
    
    const video = document.createElement("video");
    const camera = getCamera(video);
    // 开启相机
    camera.start({ torch }).then(() => {
      // 开启相机后,onFrame 注入监听回调,每帧动画即会触发该回调
      const cleanupOnFrame = onFrame(() => {
        try {
          // 每帧动画中解码QRCode
          const code = readQr?.(video);
          if (code?.data) {
            // 解码成功,触发组件 onScanned 事件
            onScanned?.(code.data);
            // 解码成功,结束扫码状态
            cleanupCamera();
          }
        } catch (e) {
          // 解码异常,触发组件 onError 事件
          onError?.(e);
        }
      });
      // 结束扫码状态:关闭相机,并清除 onFrame 中监听的回调
      cleanupCamera = () => camera.pause().finally(cleanupOnFrame);
      // 相机开启异常,触发组件 onError 事件
    }, onError);
    return cleanupCamera;
  }, [
    onScanned, 
    onError,
    // ...
  ]);
  // ...
  
  return (
    <>
      <canvas ref={canvasRef} className={styles.canvas}></canvas>
      <div className={scanFlag ? styles.line : ""} />
      {/* ... ... */}
    </>
  )
}

四、关键点

4.1 相机操作-结合video标签显示相机视频流

  • API reference: MediaDevices.getUserMedia; HTMLMediaElement.srcObject;
  • 功能描述:通过 getCamera (入参传入依赖的 video)初始化创建一个 camera ,包含 camera.start()、camera.pause() 两个方法控制设备相机开关,并和 video 标签联动。
  • 封装方法:
    • camera.start():开启相机,并在 video 标签中显示相机视频;
    • camera.pause():关闭相机,并 pause video;
  • 细节:
    • getUserMedia 方法中可通过 video.facingMode 入参控制采用设备的前置相机or后置相机(下面代码中默认优先采用 environment 后置相机,若设备没有后置相机退步采用前置);
    • video.torch 为了控制设备手电筒开关,但这个 api 似乎失效;
    • 由于 getUserMedia 是异步过程,在 getCamera 函数闭包中通过 streamP 控制是否已经请求了打开相机行为,若是则不再重复请求,等待上一次请求结果即可,直至相机关闭销毁此次状态。
  • Code:
const getCamera = (video: HTMLVideoElement) => {
  let streamP: Promise<MediaStream> | null;
  const start = async ({ useFrontCamera, torch }: ICameraStartOptions = {}) => {
    if (streamP) return streamP;
    video.play();
    const facingModes = ["environment", "user"];
    const getMedia = () =>
      (streamP = navigator.mediaDevices.getUserMedia({
        // @ts-ignore
        video: { facingMode: facingModes[+!!useFrontCamera], torch },
      }));
    try {
      streamP = getMedia();
    } catch {
      useFrontCamera = !useFrontCamera;
      streamP = getMedia();
    }
    const stream = await streamP;
    video.srcObject = stream;
    return stream;
  };
  const pause = async () => {
    video.pause();
    const stream = await streamP;
    streamP = null;
    ((video.srcObject || stream) as MediaStream)
      ?.getTracks()
      .forEach((t) => t.stop());
  };
  return { start, pause };
};

4.2 基于canvas&jsqr实现解码

    • 实践得出:jsqr 解析 qr 时长和图像分辨率有关,分辨率高则越快,因此需保证 canvas 绘制图像时在不产生数据冗余的情况下分辨率尽可能高(即确保canvas和视频画面的宽高一致)
    • 解析 qr 需要逐帧解析,因此最好通过闭包初始创建一次canvasContext2D即可,不要频繁创建。
    • alpha 入参是为了调整单张图片分辨率从而提高 jsqr 解析成功率加的,在视频画面解析时alpha 默认为1即可,不做调整。
  • Code:
const readQrByCanvas = (
  canvas: HTMLCanvasElement,
  enableDrawBox?: IEnableDrawBoxOptions
) => {
  const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
  // @ts-ignore innerFn, ctx drawLine, controlled by `enableDrawBox` param.
  const drawLine = (o, d) => {
    ctx.beginPath();
    ctx.moveTo(o.x, o.y);
    ctx.lineTo(d.x, d.y);
    enableDrawBox &&
      (() => {
        ctx.lineWidth = enableDrawBox.lineWidth;
        ctx.strokeStyle = enableDrawBox.strokeStyle;
      })();
    ctx.stroke();
  };
  // @ts-ignore innerFn, ctx drawBox, based on `drawLine`.
  const drawBox = (loc) => {
    drawLine(loc.topLeftCorner, loc.topRightCorner);
    drawLine(loc.topRightCorner, loc.bottomRightCorner);
    drawLine(loc.bottomRightCorner, loc.bottomLeftCorner);
    drawLine(loc.bottomLeftCorner, loc.topLeftCorner);
  };
  return function (
    image: HTMLImageElement | HTMLVideoElement,
    alpha: number = 1
  ) {
    // 需保证 canvas 绘制图像和 video 或 img 一个分辨率
    // @ts-ignore
    canvas.width = image.videoWidth || image.width;
    // @ts-ignore
    canvas.height = image.videoHeight || image.height;
    const { width, height } = canvas;
    if (!width || !height || alpha <= 0) return null;
    const oAlpha = Math.max(1 - alpha, 0) / 2;
    ctx.drawImage(
      image,
      oAlpha * width,
      oAlpha * height,
      alpha * width,
      alpha * height
    );
    const imageData = ctx.getImageData(0, 0, width, height);
    // jsQR 解析 qr 时长和图像分辨率有关,分辨率高则越快,因此需保证 canvas 绘制图像和 video 或 img 一个分辨率
    const code = jsQR(imageData.data, width, height);
    code?.location && drawBox(code?.location);
    return code;
  };
};

4.3 逐帧动画添加/销毁监听回调

  • API reference: requestAnimationFrame;
  • 功能描述:通过 onFrame 传入监听回调 cb,并返回对应的清除函数 cleanupOnFrame。在此后每帧动画渲染过程中都会触发传入的 cb 回调函数,除非调用 cleanupOnFrame 手动清除。
  • Code:
const onFrame = (cb: (t: number) => void) => {
  let isCleanup = false;
  const frame = () =>
    requestAnimationFrame((time) => {
      if (isCleanup) return;
      cb(time);
      frame();
    });
  frame();
  return () => {
    isCleanup = true;
  };
};

五、功能扩展

  • 增加二维码图片上传解析的功能,调用解码函数 readQr 解析图片得到解码结果即可。(在在线体验demo中已经实现,可自行体验)
  • 待增:
    • 增加设备手电筒功能,目前用 getUserMedia 入参中的 video.torch 方法控制兼容性非常不好,需要寻找一种兼容性好的方案
    • 增加图像多个二维码同时存在的识别解析问题,目前受限于 jsqr 第三方库的功能实现,智能解析单个二维码

原文链接:https://juejin.cn/post/7317278802727272459 作者:wzdong

(0)
上一篇 2023年12月28日 下午4:29
下一篇 2023年12月28日 下午4:40

相关推荐

发表回复

登录后才能评论