前端实现微信扫一扫的思路
前言: 在有了获取手机摄像头权限并且记录当前画面的前置知识以后,我们现在已经可以进行下一步实现一个仿微信扫一扫的功能了。
一. 效果预览
这里先简单放一下整体界面效果,接下来带大家一步一步分析其中的功能如何实现。
本篇将重点讲解多张二维码识别的处理场景。
二. 简单了解二维码
三. 实现扫码本地图片功能
-
我们不需要深入了解这个二维码的转换规则,我们可以直接选用现有的插件即可完成识别功能。 这里我们选用 antfu 大佬的轮子。这里我们不过多介绍,你只需要它可以识别出图片中的二维码即可。如果感兴趣,这是具体仓库地址 qr-sanner-wechat。
-
首先安装
npm i qr-scanner-wechat
。 -
它的使用方法也十分简单,这个依赖导出了一个方法,我们直接引入这个方法即
import { scan } from 'qr-scanner-wechat
。 -
这个函数可以接收一个 image 元素或者 canvas 元素作为参数,并且返回一个 promise 类型的值。
-
我们先来测试最简单的,传入一个 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 属性,就可以获取到刚刚我们选择的文件信息。
-
但是目前这个数据对象我们还无法使用,需要借助 URL.createObjectUrl 方法来创建一个普通的 url 地址。
-
当拿到这个 url 地址以后该如何使用呢?🤔
-
一个熟悉的老朋友,有请 img 标签出场,👏,我们只需要将 img 标签的 src 替换成刚刚保存的 url 地址即可。 现在整体效果应该是这样的:
-
有了 img 元素,我们直接将这个元素赋值给
qr-scanner-wechat
插件提供的scan
函数即可。 -
我们来测试一下整体流程。
-
可以看到,
scan
函数返回了一个对象,这个对象身上有两个十分重要的属性。一个叫做rect
(rectangle 长方形的单词缩写),这个属性描述了这个插件在什么位置扫描到了二维码,另外一个属性就是text
,也就是这个图片上隐藏的字符串地址。 -
这里我们再讲解一下 rect 属性,因为后面的功能需要你对这个属性有比较清晰的理解。我们对比一个现实世界的例子。当你掏出手机扫描二维码的时候,往往并不会正好对准一个二维码的图片,或者会遇到一个图片中存在两个二维码的情况,如下图:
-
这个
qr-scanner
插件会帮你把二维码所在整张图片的相对位置告诉你,因为这个插件每次调用scan
函数只会返回一次结果。并不是说图片上有两个二维码,它的识别结果就会有两个,所以说这个qr-scanner
插件的识别效果也并不是百分之一百准确的。
四. 理清思路
-
说了这么多,那么这个 rect 我们该如何利用起来呢?别着急,我们先理清思路再动手写代码,到了目前这一步会出现两种情况。
-
第一种是图片压根就没有二维码,这个简单,提示用户重新放置图片即可。
-
关键点就在于第二张情况,当图片上扫码到存在一个二维码后,我们该如何判断是否存在第二个或多个维码呢?
-
我们看一下微信的实现效果,当只有一张二维码的时候,它会直接跳转,当有多个二维码的时候,它会将整个页面暂停,并且提示用户有两张二维码,请点击选择一个进行跳转。
-
但是我们上面提到了,
scan
函数每次只会扫描一次图片,返回一个识别结果,它并不能准确知道图片上到底有几个二维码。那放到现实生活我们会怎么做呢? -
举个例子,假如我们现在掏出手机扫一扫的功能,现在给你的图片上有两个二维码,但是我明确的知道我就想扫第二个,你会怎么做?
-
这不是很简单的道理吗?我拿手挡住第一个二维码不就可以了吗?
-
那么利用同样的思路,我们可以再扫描到一张二维码的时候,想办法把当前识别到的这个二维码位置给遮挡住,然后将被遮挡后的照片传递给
scan
函数再次扫描。 -
那么整个过程就是,我们首先将完整的照片传给
scan
,然后scan
觉得第一张二维码比较帅,就先识别了它。(tips: 这里需要提醒一下,scan 有时候会觉得第二张二维码比较帅,那我就识别第二张二维码,要注意的它的顺序性是随机的) -
然后我们想办法盖上遮挡物,然后将这个图片传给
scan
,让它再次确认是否有第二个二维码。 -
在哪覆盖?还记不记 rect 属性保留有这个二维码的位置信息?现在的问题就转变为如何覆盖了?
-
这里需要用到
canvas
元素的一丢丢基础知识,这是 mdn canvas 基础知识的介绍,十分简单的就画出了一个绿色长方体。 ctx.filleRect可以接收四个参数,分别是相对于画布起始轴的 x 和 y 的距离。 简单来讲就可以理解为每一个 canvas 就相当于一个独立的 HTML 文件,也有自己的独立坐标系系统,x,y 就相当于 margin,至于后面两个参数,其实就代表着你要画的长方形的宽度和高度。
13.那这不巧了吗,scan
的返回值 rect 恰好就有这几个值。
- 话不多说,马上开始实践。⛽️
五. 处理存在多张二维码的图片
六. 弹出可以点击的小蓝块
-
有了坐标信息和位置信息,并且我们的 canvas 和 img 元素的坐标轴系统是一一对应的,那么我们就可以写一个函数来遍历这个 resultMap,然后根据位置信息在 img 元素所在的 div 上打印出我们想要的样式。
-
首先在 img 元素外面包一层 div,打上 ref 叫做 imgWrapper 。因为之后我们要用它当作小蓝块的定位元素,所以先设置 position:relative。
-
绘画代码如下,都是基础的方法,不再过多赘述。
//多个二维码时添加动态小蓝点 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); }); }
-
然后再 for 循环以后开始绘画小蓝块。
-
让我们预览一下现在的效果:
-
让我们测试一下相对应的点击事件
七. 源码
<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🎁~
如果文章对你有帮助,不妨赠人玫瑰,手有余香,预计将会在下篇更新较为完整的微信扫一扫界面和功能。