使用JavaScript实现网页端二维码扫描:从摄像头调用到闪光灯控制
网页端二维码扫描的实现主要依赖于以下几个Web API和技术:
- MediaDevices API:用于访问用户设备的摄像头
- Canvas API:用于处理视频帧和图像数据
- jsQR库:纯JavaScript实现的二维码解码库
- WebRTC:支持实时媒体流处理
这些技术的组合使我们能够在浏览器中创建一个功能强大的扫码应用,无需依赖任何原生应用。
项目概述
我们将构建一个具备以下功能的网页扫码应用:
- 调用设备摄像头,获取实时视频流
- 创建扫描区域,引导用户正确放置二维码
- 实时解码扫描区域内的二维码
- 提供手动输入二维码内容的备选方案
- 支持闪光灯控制,适应不同光线环境
实现步骤
1. 基础HTML结构
首先,我们需要构建一个语义化的HTML结构:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网页二维码扫描器</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="scanner-container">
<video id="video" autoplay playsinline></video>
<canvas id="overlay"></canvas>
<div class="scan-region">
<div class="scan-area"></div>
<div class="scan-line"></div>
</div>
<div class="controls">
<button id="manual-input" class="control-btn">手动输入</button>
<button id="toggle-flash" class="control-btn">闪光灯</button>
</div>
<div id="result-modal" class="modal">
<div class="modal-content">
<h3>扫描结果</h3>
<p id="result-text"></p>
<div class="modal-actions">
<button id="close-modal" class="action-btn">关闭</button>
<button id="open-link" class="action-btn">访问链接</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script>
<script src="scanner.js"></script>
</body>
</html>
2. CSS样式设计
为了提升用户体验,我们需要设计一个直观、美观的界面:
/* 基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', sans-serif;
overflow: hidden;
background-color: #000;
color: #fff;
}
/* 扫描器容器 */
.scanner-container {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 视频和画布 */
#video {
width: 100%;
height: 100%;
object-fit: cover;
}
#overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* 扫描区域 */
.scan-region {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
height: 30%;
max-width: 400px;
max-height: 200px;
}
.scan-area {
position: absolute;
width: 100%;
height: 100%;
border: 2px solid #4CAF50;
border-radius: 8px;
box-shadow: 0 0 0 4000px rgba(0, 0, 0, 0.5);
}
.scan-line {
position: absolute;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #4CAF50, transparent);
top: 0;
animation: scan 2s linear infinite;
}
@keyframes scan {
0% { top: 0; }
100% { top: calc(100% - 2px); }
}
/* 控制按钮 */
.controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 15px;
}
.control-btn {
background-color: rgba(0, 0, 0, 0.6);
color: white;
border: none;
padding: 12px 24px;
border-radius: 24px;
font-size: 16px;
cursor: pointer;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.control-btn:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.05);
}
/* 结果模态框 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: #333;
padding: 24px;
border-radius: 12px;
max-width: 80%;
width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.action-btn {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.action-btn:hover {
background-color: #45a049;
}
3. JavaScript核心实现
JavaScript是实现扫码功能的核心,下面我们将逐步实现各项功能:
3.1 初始化与摄像头访问
// scanner.js
document.addEventListener('DOMContentLoaded', () => {
// DOM元素引用
const video = document.getElementById('video');
const canvas = document.getElementById('overlay');
const ctx = canvas.getContext('2d');
const scanArea = document.querySelector('.scan-area');
const manualInputBtn = document.getElementById('manual-input');
const flashBtn = document.getElementById('toggle-flash');
const resultModal = document.getElementById('result-modal');
const resultText = document.getElementById('result-text');
const closeModalBtn = document.getElementById('close-modal');
const openLinkBtn = document.getElementById('open-link');
// 状态变量
let stream = null;
let scanning = true;
let flashEnabled = false;
let currentResult = null;
// 初始化扫描器
initScanner();
// 事件监听器
manualInputBtn.addEventListener('click', handleManualInput);
flashBtn.addEventListener('click', toggleFlash);
closeModalBtn.addEventListener('click', () => {
resultModal.style.display = 'none';
scanning = true;
requestAnimationFrame(scan);
});
openLinkBtn.addEventListener('click', () => {
if (currentResult) {
window.open(currentResult, '_blank');
}
});
// 初始化扫描器函数
async function initScanner() {
try {
// 请求摄像头权限
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
// 设置视频源
video.srcObject = stream;
// 等待视频元数据加载
video.addEventListener('loadedmetadata', () => {
// 设置画布尺寸与视频相同
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 开始扫描循环
requestAnimationFrame(scan);
});
} catch (err) {
console.error('无法访问摄像头:', err);
alert('无法访问摄像头,请确保已授予权限且设备正常工作。');
}
}
});
3.2 二维码扫描与解码
// 扫描函数
function scan() {
if (!scanning) return;
// 如果视频已准备好,继续处理
if (video.readyState === video.HAVE_ENOUGH_DATA) {
// 绘制视频帧到画布
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 获取扫描区域的位置和尺寸
const scanAreaRect = scanArea.getBoundingClientRect();
const videoRect = video.getBoundingClientRect();
// 计算扫描区域在视频中的相对位置
const scaleX = video.videoWidth / videoRect.width;
const scaleY = video.videoHeight / videoRect.height;
const x = (scanAreaRect.left - videoRect.left) * scaleX;
const y = (scanAreaRect.top - videoRect.top) * scaleY;
const width = scanAreaRect.width * scaleX;
const height = scanAreaRect.height * scaleY;
// 获取扫描区域的图像数据
const imageData = ctx.getImageData(x, y, width, height);
// 使用jsQR解码二维码
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "dontInvert",
});
// 如果检测到二维码
if (code) {
handleScanResult(code.data);
return;
}
}
// 继续下一帧扫描
requestAnimationFrame(scan);
}
// 处理扫描结果
function handleScanResult(data) {
scanning = false;
currentResult = data;
resultText.textContent = data;
resultModal.style.display = 'flex';
// 检查结果是否为URL
try {
const url = new URL(data);
openLinkBtn.style.display = 'inline-block';
} catch (e) {
openLinkBtn.style.display = 'none';
}
}
3.3 闪光灯控制与手动输入
// 切换闪光灯
async function toggleFlash() {
if (!stream) return;
try {
// 检查设备是否支持闪光灯
const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
if (!('torch' in capabilities)) {
alert('您的设备不支持闪光灯功能。');
return;
}
// 切换闪光灯状态
flashEnabled = !flashEnabled;
await track.applyConstraints({
advanced: [{ torch: flashEnabled }]
});
// 更新按钮文本
flashBtn.textContent = flashEnabled ? '关闭闪光灯' : '闪光灯';
} catch (err) {
console.error('无法控制闪光灯:', err);
alert('无法控制闪光灯,请确保设备支持此功能。');
}
}
// 处理手动输入
function handleManualInput() {
scanning = false;
const input = prompt('请输入二维码内容:');
if (input && input.trim() !== '') {
handleScanResult(input.trim());
} else {
scanning = true;
requestAnimationFrame(scan);
}
}
高级优化
1. 性能优化
为了提高扫描性能和用户体验,我们可以添加以下优化:
// 添加节流控制,限制扫描频率
let lastScanTime = 0;
const scanInterval = 200; // 毫秒
function scan() {
if (!scanning) return;
const now = Date.now();
if (now - lastScanTime < scanInterval) {
requestAnimationFrame(scan);
return;
}
lastScanTime = now;
// 其余扫描代码...
}
// 添加扫描区域高亮效果
function drawScanAreaHighlight() {
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
// 添加四个角的高亮
const cornerLength = 20;
ctx.beginPath();
// 左上角
ctx.moveTo(x, y + cornerLength);
ctx.lineTo(x, y);
ctx.lineTo(x + cornerLength, y);
// 右上角
ctx.moveTo(x + width - cornerLength, y);
ctx.lineTo(x + width, y);
ctx.lineTo(x + width, y + cornerLength);
// 右下角
ctx.moveTo(x + width, y + height - cornerLength);
ctx.lineTo(x + width, y + height);
ctx.lineTo(x + width - cornerLength, y + height);
// 左下角
ctx.moveTo(x + cornerLength, y + height);
ctx.lineTo(x, y + height);
ctx.lineTo(x, y + height - cornerLength);
ctx.stroke();
}
2. 错误处理与兼容性
为了增强应用的健壮性,我们需要添加完善的错误处理和兼容性检查:
// 检查浏览器兼容性
function checkCompatibility() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('您的浏览器不支持访问摄像头,请使用现代浏览器如Chrome、Firefox或Safari。');
return false;
}
if (typeof jsQR === 'undefined') {
alert('无法加载二维码解码库,请检查网络连接。');
return false;
}
return true;
}
// 添加摄像头切换功能
async function switchCamera() {
if (!stream) return;
// 获取当前摄像头类型
const currentTrack = stream.getVideoTracks()[0];
const currentFacingMode = currentTrack.getSettings().facingMode;
// 确定新的摄像头类型
const newFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment';
try {
// 停止当前流
currentTrack.stop();
// 获取新的摄像头流
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: newFacingMode }
});
// 更新视频源
video.srcObject = stream;
// 重新开始扫描
video.addEventListener('loadedmetadata', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
scanning = true;
requestAnimationFrame(scan);
});
} catch (err) {
console.error('无法切换摄像头:', err);
alert('无法切换摄像头,请确保设备有多个摄像头可用。');
}
}
完整代码
以下是整合了所有功能和优化的完整代码:
// scanner.js
document.addEventListener('DOMContentLoaded', () => {
// DOM元素引用
const video = document.getElementById('video');
const canvas = document.getElementById('overlay');
const ctx = canvas.getContext('2d');
const scanArea = document.querySelector('.scan-area');
const manualInputBtn = document.getElementById('manual-input');
const flashBtn = document.getElementById('toggle-flash');
const resultModal = document.getElementById('result-modal');
const resultText = document.getElementById('result-text');
const closeModalBtn = document.getElementById('close-modal');
const openLinkBtn = document.getElementById('open-link');
const switchCameraBtn = document.getElementById('switch-camera');
// 状态变量
let stream = null;
let scanning = true;
let flashEnabled = false;
let currentResult = null;
let lastScanTime = 0;
const scanInterval = 200; // 毫秒
// 检查浏览器兼容性
if (!checkCompatibility()) return;
// 初始化扫描器
initScanner();
// 事件监听器
manualInputBtn.addEventListener('click', handleManualInput);
flashBtn.addEventListener('click', toggleFlash);
closeModalBtn.addEventListener('click', () => {
resultModal.style.display = 'none';
scanning = true;
requestAnimationFrame(scan);
});
openLinkBtn.addEventListener('click', () => {
if (currentResult) {
window.open(currentResult, '_blank');
}
});
// 如果存在切换摄像头按钮,添加事件监听
if (switchCameraBtn) {
switchCameraBtn.addEventListener('click', switchCamera);
}
// 检查浏览器兼容性
function checkCompatibility() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('您的浏览器不支持访问摄像头,请使用现代浏览器如Chrome、Firefox或Safari。');
return false;
}
if (typeof jsQR === 'undefined') {
alert('无法加载二维码解码库,请检查网络连接。');
return false;
}
return true;
}
// 初始化扫描器函数
async function initScanner() {
try {
// 请求摄像头权限
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
// 设置视频源
video.srcObject = stream;
// 等待视频元数据加载
video.addEventListener('loadedmetadata', () => {
// 设置画布尺寸与视频相同
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 开始扫描循环
requestAnimationFrame(scan);
});
} catch (err) {
console.error('无法访问摄像头:', err);
alert('无法访问摄像头,请确保已授予权限且设备正常工作。');
}
}
// 扫描函数
function scan() {
if (!scanning) return;
// 节流控制,限制扫描频率
const now = Date.now();
if (now - lastScanTime < scanInterval) {
requestAnimationFrame(scan);
return;
}
lastScanTime = now;
// 如果视频已准备好,继续处理
if (video.readyState === video.HAVE_ENOUGH_DATA) {
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制视频帧到画布
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 获取扫描区域的位置和尺寸
const scanAreaRect = scanArea.getBoundingClientRect();
const videoRect = video.getBoundingClientRect();
// 计算扫描区域在视频中的相对位置
const scaleX = video.videoWidth / videoRect.width;
const scaleY = video.videoHeight / videoRect.height;
const x = (scanAreaRect.left - videoRect.left) * scaleX;
const y = (scanAreaRect.top - videoRect.top) * scaleY;
const width = scanAreaRect.width * scaleX;
const height = scanAreaRect.height * scaleY;
// 绘制扫描区域高亮
drawScanAreaHighlight(x, y, width, height);
// 获取扫描区域的图像数据
const imageData = ctx.getImageData(x, y, width, height);
// 使用jsQR解码二维码
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "dontInvert",
});
// 如果检测到二维码
if (code) {
handleScanResult(code.data);
return;
}
}
// 继续下一帧扫描
requestAnimationFrame(scan);
}
// 绘制扫描区域高亮效果
function drawScanAreaHighlight(x, y, width, height) {
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
// 添加四个角的高亮
const cornerLength = 20;
ctx.beginPath();
// 左上角
ctx.moveTo(x, y + cornerLength);
ctx.lineTo(x, y);
ctx.lineTo(x + cornerLength, y);
// 右上角
ctx.moveTo(x + width - cornerLength, y);
ctx.lineTo(x + width, y);
ctx.lineTo(x + width, y + cornerLength);
// 右下角
ctx.moveTo(x + width, y + height - cornerLength);
ctx.lineTo(x + width, y + height);
ctx.lineTo(x + width - cornerLength, y + height);
// 左下角
ctx.moveTo(x + cornerLength, y + height);
ctx.lineTo(x, y + height);
ctx.lineTo(x, y + height - cornerLength);
ctx.stroke();
}
// 处理扫描结果
function handleScanResult(data) {
scanning = false;
currentResult = data;
resultText.textContent = data;
resultModal.style.display = 'flex';
// 检查结果是否为URL
try {
const url = new URL(data);
openLinkBtn.style.display = 'inline-block';
} catch (e) {
openLinkBtn.style.display = 'none';
}
}
// 切换闪光灯
async function toggleFlash() {
if (!stream) return;
try {
// 检查设备是否支持闪光灯
const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
if (!('torch' in capabilities)) {
alert('您的设备不支持闪光灯功能。');
return;
}
// 切换闪光灯状态
flashEnabled = !flashEnabled;
await track.applyConstraints({
advanced: [{ torch: flashEnabled }]
});
// 更新按钮文本
flashBtn.textContent = flashEnabled ? '关闭闪光灯' : '闪光灯';
} catch (err) {
console.error('无法控制闪光灯:', err);
alert('无法控制闪光灯,请确保设备支持此功能。');
}
}
// 处理手动输入
function handleManualInput() {
scanning = false;
const input = prompt('请输入二维码内容:');
if (input && input.trim() !== '') {
handleScanResult(input.trim());
} else {
scanning = true;
requestAnimationFrame(scan);
}
}
// 切换摄像头
async function switchCamera() {
if (!stream) return;
// 获取当前摄像头类型
const currentTrack = stream.getVideoTracks()[0];
const currentFacingMode = currentTrack.getSettings().facingMode;
// 确定新的摄像头类型
const newFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment';
try {
// 停止当前流
currentTrack.stop();
// 获取新的摄像头流
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: newFacingMode }
});
// 更新视频源
video.srcObject = stream;
// 重新开始扫描
video.addEventListener('loadedmetadata', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
scanning = true;
requestAnimationFrame(scan);
});
} catch (err) {
console.error('无法切换摄像头:', err);
alert('无法切换摄像头,请确保设备有多个摄像头可用。');
}
}
});
部署与测试
1. 部署注意事项
由于我们的应用使用了摄像头等敏感API,部署时需要注意以下几点:
- HTTPS要求:现代浏览器要求使用HTTPS才能访问摄像头,因此在生产环境中必须使用SSL证书。
- 权限提示:首次访问时,浏览器会请求摄像头权限,需要引导用户授权。
- 移动设备适配:确保在移动设备上有良好的显示效果和交互体验。
2. 测试建议
- 多设备测试:在不同品牌和型号的设备上测试,特别是Android和iOS设备。
- 不同光线条件:在不同光线条件下测试扫描效果,包括强光、弱光和背光环境。
- 二维码类型:测试不同类型和复杂度的二维码,包括URL、文本、WiFi配置等。
- 错误处理:测试各种错误情况,如用户拒绝权限、设备不支持闪光灯等。
版权声明:本文为原创文章,版权归 全栈开发技术博客 所有。
本文链接:https://www.lvtao.net/dev/javascript-web-qr-code-scanner-tutorial.html
转载时须注明出处及本声明
- 上一篇: 构建实时聊天系统:基于Workerman和MongoDB的完整实现
- 下一篇: 没有了