头图

前言: 在有了获取手机摄像头权限并且记录当前画面的前置知识以后,我们现在已经可以进行下一步实现一个仿微信扫一扫的功能了。

tips: 如果你是直接看的本文,对如何打开摄像头拍照这个功能还不太熟悉,请移步 🎁前端如何打开摄像头拍照。这是你阅读本篇的必需前置知识。

一. 效果预览

这里先简单放一下整体界面效果,接下来带大家一步一步分析其中的功能如何实现。


2.gif

本篇将重点讲解多张二维码识别的处理场景。

二. 简单了解二维码

  1. 现在流行使用的二维码是 qrcode,其中 qr 两个字母其实就是 quick response 的缩写,简单来说就是快速响应的意思。三个角用来定位,黑点表示二进制的1,白色点代表0。(这里感兴趣可以自行了解) 它的本质其实就是将一个地址链接利用某种约定好的规则隐藏到一个图片当中,

    image.png
  2. 我们可以利用 chrome 自带的创建当前网站二维码的功能快速体验一下。
    qr.gif
  3. 你可以用手机自带的二维码扫码软件扫一下这个二维码,它将会将你引导到我掘金的个人主页。

    qrcode_juejin.cn.png
  4. 细心的你可能会发现二维码下面已经给你提示了你准备保存的链接地址,现在你观察一下浏览器地址栏是否正对应下面这个地址呢?
    image.png

三. 实现扫码本地图片功能

  1. 我们不需要深入了解这个二维码的转换规则,我们可以直接选用现有的插件即可完成识别功能。 这里我们选用 antfu 大佬的轮子。这里我们不过多介绍,你只需要它可以识别出图片中的二维码即可。如果感兴趣,这是具体仓库地址 qr-sanner-wechat
  2. 首先安装 npm i qr-scanner-wechat
  3. 它的使用方法也十分简单,这个依赖导出了一个方法,我们直接引入这个方法即 import { scan } from 'qr-scanner-wechat
  4. 这个函数可以接收一个 image 元素或者 canvas 元素作为参数,并且返回一个 promise 类型的值。
  5. 我们先来测试最简单的,传入一个 image 元素,利用 input 标签的 type=file 属性,就可以从本地选择图片,比较基础的知识,不过多赘述,代码如下。

    function getImageFromLocal(e: Event) {
      const inputEl = e.target as HTMLInputElement;
      if (!inputEl) return;
      console.log("inputEl.files", inputEl.files);
    }
    
    <template>
       <div>
       <input @change="getImageFromLocal" type="file" accept="image/png" />
    </div>
    </template>

    然后我们可以通过 input 元素绑定的 onChange 回调中拿到 input 元素身上的 files 属性,就可以获取到刚刚我们选择的文件信息。

    ee.gif

  6. 但是目前这个数据对象我们还无法使用,需要借助 URL.createObjectUrl 方法来创建一个普通的 url 地址。
    image.png
  7. 当拿到这个 url 地址以后该如何使用呢?🤔
    image.png
  8. 一个熟悉的老朋友,有请 img 标签出场,👏,我们只需要将 img 标签的 src 替换成刚刚保存的 url 地址即可。
    image.png

    现在整体效果应该是这样的:

    code.gif
  9. 有了 img 元素,我们直接将这个元素赋值给 qr-scanner-wechat 插件提供的 scan 函数即可。

    image.png
  10. 我们来测试一下整体流程。

    qw.gif
  11. 可以看到,scan 函数返回了一个对象,这个对象身上有两个十分重要的属性。一个叫做 rect (rectangle 长方形的单词缩写),这个属性描述了这个插件在什么位置扫描到了二维码,另外一个属性就是 text,也就是这个图片上隐藏的字符串地址

    image.png
  12. 这里我们再讲解一下 rect 属性,因为后面的功能需要你对这个属性有比较清晰的理解。我们对比一个现实世界的例子。当你掏出手机扫描二维码的时候,往往并不会正好对准一个二维码的图片,或者会遇到一个图片中存在两个二维码的情况,如下图:

    image.png
  13. 这个 qr-scanner 插件会帮你把二维码所在整张图片的相对位置告诉你,因为这个插件每次调用 scan 函数只会返回一次结果。并不是说图片上有两个二维码,它的识别结果就会有两个,所以说这个 qr-scanner 插件的识别效果也并不是百分之一百准确的。

四. 理清思路

  1. 说了这么多,那么这个 rect 我们该如何利用起来呢?别着急,我们先理清思路再动手写代码,到了目前这一步会出现两种情况。

    image.png
  2. 第一种是图片压根就没有二维码,这个简单,提示用户重新放置图片即可。
  3. 关键点就在于第二张情况,当图片上扫码到存在一个二维码后,我们该如何判断是否存在第二个或多个维码呢?

    image.png
  4. 我们看一下微信的实现效果,当只有一张二维码的时候,它会直接跳转,当有多个二维码的时候,它会将整个页面暂停,并且提示用户有两张二维码,请点击选择一个进行跳转。

    image.png
  5. 但是我们上面提到了,scan 函数每次只会扫描一次图片,返回一个识别结果,它并不能准确知道图片上到底有几个二维码。那放到现实生活我们会怎么做呢?
  6. 举个例子,假如我们现在掏出手机扫一扫的功能,现在给你的图片上有两个二维码,但是我明确的知道我就想扫第二个,你会怎么做?

    image.png
  7. 这不是很简单的道理吗?我拿手挡住第一个二维码不就可以了吗?

    image.png
  8. 那么利用同样的思路,我们可以再扫描到一张二维码的时候,想办法把当前识别到的这个二维码位置给遮挡住,然后将被遮挡后的照片传递给 scan 函数再次扫描。
  9. 那么整个过程就是,我们首先将完整的照片传给 scan,然后 scan 觉得第一张二维码比较帅,就先识别了它。(tips: 这里需要提醒一下,scan 有时候会觉得第二张二维码比较帅,那我就识别第二张二维码,要注意的它的顺序性是随机的)

    image.png
  10. 然后我们想办法盖上遮挡物,然后将这个图片传给 scan,让它再次确认是否有第二个二维码。

    image.png
  11. 在哪覆盖?还记不记 rect 属性保留有这个二维码的位置信息?现在的问题就转变为如何覆盖了?
  12. 这里需要用到 canvas 元素的一丢丢基础知识,这是 mdn canvas 基础知识的介绍,十分简单的就画出了一个绿色长方体。

    image.png

    ctx.filleRect可以接收四个参数,分别是相对于画布起始轴的 xy 的距离。

    简单来讲就可以理解为每一个 canvas 就相当于一个独立的 HTML 文件,也有自己的独立坐标系系统,x,y 就相当于 margin,至于后面两个参数,其实就代表着你要画的长方形宽度高度
    image.png

13.那这不巧了吗,scan 的返回值 rect 恰好就有这几个值。

image.png

  1. 话不多说,马上开始实践。⛽️

五. 处理存在多张二维码的图片

  1. 注意: 以下内容我统一选用从本地照片上传作为演示,从摄像头获取图片是同样的道理,详细介绍请移步 🎁前端如何打开摄像头拍照。在下面的讲解过程,我会默认你已经阅读了前置知识。
  2. 这里我就继续沿用之前提到的图片,我将他们拼接到了一张图片上。

    二.png
  3. 下面应该是你目前从本地选择二维码图片识别的代码。

    async function getImageFromLocal(e: Event) {
      const inputEl = e.target as HTMLInputElement;
      if (!inputEl) return;
      if (!inputEl.files?.length) return;
      const image = inputEl.files[0];
      const url = URL.createObjectURL(image);
      src.value = url;
      const result = await scan(imgEl.value!);
      console.log("result", result);
    }
    1. 接下来我们需要先创建一个 canvas 来将当前的照片拷贝到画布上,然后准备利用得到的 rect 信息在这个 canvas 元素上绘画。

    image.png

  4. 为了方便展示,我调用 appendChildren 方法将这个 canvas 元素打印到了界面上。

    1.gif
  5. 然后用 resultrect坐标宽度信息,去调用我们提到的 canvasfillStyle fillRect 方法。

    image.png

    下面是目前实现的效果:

    1.gif
  6. 注意scan 函数不仅仅可以接受 imgElment 作为扫描的参数,它还可以接受 canvas 元素作为扫描的参数。聪明的你看到这里,或许已经猜到我们下一步准备做什么了。
  7. 那么此时我们就可以将这个已经被黑色涂鸦覆盖过的 canvas 进行二次扫描。(暂时不要考虑代码的优雅性,这里只是更清晰的说明我们在干什么,之后我们会封装几个方法,然后整理一下代码)

    image.png

    让我们再看一下效果:

    2.gif
  8. 通过多次重复上面的操作,就可以将图片上所有的二维码都尽量识别出来。

    image.png

    现在实现的效果:

    11.gif

    同时图片上相对应的识别内容也全都被正确的被获取到了。

    image.png
  9. 此时我们创建一个 Map 来保存这些数据。Mapkey 就是 text ,对应的 value 就是 rect 坐标信息。

    image.png

    image.png

六. 弹出可以点击的小蓝块

  1. 有了坐标信息和位置信息,并且我们的 canvasimg 元素的坐标轴系统是一一对应的,那么我们就可以写一个函数来遍历这个 resultMap,然后根据位置信息在 img 元素所在的 div 上打印出我们想要的样式。
  2. 首先在 img 元素外面包一层 div,打上 ref 叫做 imgWrapper 。因为之后我们要用它当作小蓝块的定位元素,所以先设置 position:relative

    image.png
  3. 绘画代码如下,都是基础的方法,不再过多赘述。

    //多个二维码时添加动态小蓝点
    function draw() {
      resultMap.forEach((rect, link) => {
    if (!imgWrapper.value) return;
    const dom = document.createElement("div");
    const { x, y, width, height } = rect;
    const _x = (x || 0) + width / 2 - 20;
    const _y = (y || 0) + height / 2 - 20;
    dom.className = "blue-chunk";
    dom.style.width = "40px";
    dom.style.height = "40px";
    dom.style.background = "#2ec1cc";
    dom.style.position = "absolute";
    dom.style.zIndex = "9999999";
    dom.style.top = _y + "px";
    dom.style.left = _x + "px";
    dom.style.color = "#fff";
    dom.style.textAlign = "center";
    dom.style.borderRadius = "100px";
    dom.style.borderBlockColor = "#fff";
    dom.style.borderColor = "unset";
    dom.style.borderRightStyle = "solid";
    dom.style.borderWidth = "3px";
    dom.style.animation = "scale-animation 2s infinite";
    dom.addEventListener("click", () => {
      console.log(link);
    });
    imgWrapper.value.appendChild(dom);
      });
    }
  4. 然后再 for 循环以后开始绘画小蓝块。

    image.png
  5. 让我们预览一下现在的效果:

    112.gif
  6. 让我们测试一下相对应的点击事件

    3.gif

    六. 源码

    <script lang="ts" setup>
    import { ref, onMounted } from "vue";
    import { scan } from "qr-scanner-wechat";
    
    const wrapper = ref<HTMLDivElement>();
    const videoEl = ref<HTMLVideoElement>();
    
    async function checkCamera() {
      const navigator = window.navigator.mediaDevices;
      const devices = await navigator.enumerateDevices();
      if (devices) {
     const stream = await navigator.getUserMedia({
       audio: false,
       video: {
         width: 300,
         height: 300,
         // facingMode: { exact: "environment" }, //强制后置摄像头
         facingMode: "user", //前置摄像头
       },
     });
     if (!videoEl.value) return;
    
     videoEl.value.srcObject = stream;
     // videoEl.value.play();
      }
    }
    
    function shoot() {
      if (!videoEl.value || !wrapper.value) return;
      const canvas = document.createElement("canvas");
      canvas.width = videoEl.value.videoWidth;
      canvas.height = videoEl.value.videoHeight;
      //拿到 canvas 上下文对象
      const ctx = canvas.getContext("2d");
      ctx?.drawImage(videoEl.value, 0, 0, canvas.width, canvas.height);
      wrapper.value.appendChild(canvas);
    }
    
    const src = ref("");
    const imgEl = ref<HTMLImageElement>();
    const resultMap = new Map();
    const imgWrapper = ref<HTMLDivElement>();
    
    async function getImageFromLocal(e: Event) {
      const inputEl = e.target as HTMLInputElement;
      if (!inputEl) return;
      if (!inputEl.files?.length) return;
      const image = inputEl.files[0];
      const url = URL.createObjectURL(image);
      src.value = url;
    
      const temCanvas = document.createElement("canvas");
      temCanvas.width = 300;
      temCanvas.height = 300;
      const ctx = temCanvas.getContext("2d", { willReadFrequently: true });
    
      if (!imgEl.value) return;
      imgEl.value.onload = async () => {
     if (!ctx) return;
     ctx.drawImage(imgEl.value!, 0, 0, 300, 300);
     wrapper.value?.appendChild(temCanvas);
    
     ctx.fillStyle = "black";
     for (let i = 0; i < 5; i++) {
       const result = await scan(temCanvas);
       console.log("result", result);
       if (!result?.rect || !result.text) continue;
       resultMap.set(result.text, result.rect);
       const { x, y, height, width } = result.rect;
       ctx.fillRect(x, y, width, height);
     }
     draw();
      };
    }
    
    //多个二维码时添加动态小蓝点
    function draw() {
      resultMap.forEach((rect, link) => {
     if (!imgWrapper.value) return;
     const dom = document.createElement("div");
     const { x, y, width, height } = rect;
     const _x = (x || 0) + width / 2 - 20;
     const _y = (y || 0) + height / 2 - 20;
     dom.className = "blue-chunk";
     dom.style.width = "40px";
     dom.style.height = "40px";
     dom.style.background = "#2ec1cc";
     dom.style.position = "absolute";
     dom.style.zIndex = "9999999";
     dom.style.top = _y + "px";
     dom.style.left = _x + "px";
     dom.style.color = "#fff";
     dom.style.textAlign = "center";
     dom.style.borderRadius = "100px";
     dom.style.borderBlockColor = "#fff";
     dom.style.borderColor = "unset";
     dom.style.borderRightStyle = "solid";
     dom.style.borderWidth = "3px";
     dom.style.animation = "scale-animation 2s infinite";
     dom.addEventListener("click", () => {
       console.log(link);
     });
     imgWrapper.value.appendChild(dom);
      });
    }
    
    onMounted(() => {
      checkCamera();
    });
    </script>
    <template>
      <div ref="wrapper" class="w-full h-full bg-red flex flex-col items-center">
     <video ref="videoEl" />
    
     <div ref="imgWrapper" class="relative">
       <img
         ref="imgEl"
         :src="src"
         alt="qrcode"
         class="w-300px h-300px object-contain"
       />
     </div>
    
     <div>
       <input @change="getImageFromLocal" type="file" accept="image/*" />
     </div>
      </div>
    </template>

七.总结

本篇文章的关键点就是讲解了我在实现处理多张二维码的场景时的思路,利用 canvas 遮挡识别过的二维码这个思路是 pbk-bin 大佬最先想到的,在实现这个需求以后还是很感叹这个思路的巧妙。👏

再次特别感谢pbk-bin🎁~

如果文章对你有帮助,不妨赠人玫瑰,手有余香,预计将会在下篇更新较为完整的微信扫一扫界面和功能。


FFF方
453 声望12 粉丝