专注于高性能网络应用开发,核心技术包括PHP、Java、GO、NodeJS等后端语言,VUE、UNI、APP等前端开发,服务器运维、数据库、实时通信、AI等领域拥有丰富经验

使用JavaScript实现网页端二维码扫描:从摄像头调用到闪光灯控制

网页端二维码扫描的实现主要依赖于以下几个Web API和技术:

  • MediaDevices API:用于访问用户设备的摄像头
  • Canvas API:用于处理视频帧和图像数据
  • jsQR库:纯JavaScript实现的二维码解码库
  • WebRTC:支持实时媒体流处理

这些技术的组合使我们能够在浏览器中创建一个功能强大的扫码应用,无需依赖任何原生应用。

项目概述

我们将构建一个具备以下功能的网页扫码应用:

  1. 调用设备摄像头,获取实时视频流
  2. 创建扫描区域,引导用户正确放置二维码
  3. 实时解码扫描区域内的二维码
  4. 提供手动输入二维码内容的备选方案
  5. 支持闪光灯控制,适应不同光线环境

实现步骤

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,部署时需要注意以下几点:

  1. HTTPS要求:现代浏览器要求使用HTTPS才能访问摄像头,因此在生产环境中必须使用SSL证书。
  2. 权限提示:首次访问时,浏览器会请求摄像头权限,需要引导用户授权。
  3. 移动设备适配:确保在移动设备上有良好的显示效果和交互体验。

2. 测试建议

  1. 多设备测试:在不同品牌和型号的设备上测试,特别是Android和iOS设备。
  2. 不同光线条件:在不同光线条件下测试扫描效果,包括强光、弱光和背光环境。
  3. 二维码类型:测试不同类型和复杂度的二维码,包括URL、文本、WiFi配置等。
  4. 错误处理:测试各种错误情况,如用户拒绝权限、设备不支持闪光灯等。

相关文章

Javascript封装WebRTC及使用教程

好的,我们将在现有基础上加入接听对话、加入多人对话、创建多人对话等功能,并重新生成完整的文章内容。WebRTC 封装及使用教程WebRTC 是一个强大的实时通信 API,允许在浏览器中进行音视频...

浏览器指纹识别的 JavaScript 库 - FingerprintJS

FingerprintJS 是一个用于浏览器指纹识别的 JavaScript 库。它通过收集浏览器和设备的各种特征来生成一个唯一的标识符,即“指纹”。这个指纹可以用于识别和跟踪用户,即使他们清除...

在JavaScript或Vue中屏蔽所有报错信息

在 JavaScript 或 Vue 中,如果你想屏蔽所有 JavaScript 报错,可以通过捕获全局的错误事件来实现。需要注意的是,尽量避免屏蔽所有错误,因为这可能会掩盖一些实际问题,影响调...

浏览器的开发工具中有个jsContext是什么

什么是 jsContext?在JavaScript中,jsContext 并不是一个官方的术语或概念。通常情况下,开发者可能会提到 context 这个词,它通常指的是执行上下文(Executi...

在HTML中为 h1-h6 标签添加序号及颜色背景色块

在HTML结构中,h1 到 h6 是常见的标题标签,通常我们会希望对这些标题进行标注或编号,使其更具层次感。在这篇文章中,我将向您展示如何通过纯JavaScript自动为 h1 到 h6 标签添...

VUE倒计时组件

常用于发送短信、邮件后1分钟倒计时,倒计时结束后又可以再次点击vue组件封装:<template> <div class="timer-btn">...