二维码 (QR Code) 已经成为日常生活中不可或缺的一部分,从网站链接到支付凭证,无处不在。在 Web 应用中集成一个二维码扫描功能,可以让用户直接通过浏览器访问摄像头,扫描二维码,并获取其中包含的信息。这对于构建无缝的用户体验至关重要。

本教程将引导您一步步实现一个纯前端的二维码扫描器。我们将从 HTML 结构开始,设计一个包含视频预览区域、扫描结果显示区和控制按钮的简洁界面;然后用 CSS 美化应用的外观,使其具有现代、易用的卡片式布局;最后,我们将使用 JavaScript 赋予它“智能”,使其能够访问用户的摄像头、集成一个强大的第三方二维码扫描库来实时处理视频流、解析二维码数据,并提供结果显示、复制和错误处理功能。这个项目是您巩固 HTML、CSS 和 JavaScript 基础知识,以及深入理解 navigator.mediaDevices.getUserMedia() API、WebRTC 技术和第三方库集成的绝佳实践。


前置知识

为了更好地理解本指南,建议您具备以下基础知识:

  • HTML 基础: 了解视频标签 (<video>)、按钮标签 (<button>)、通用容器 (<div>, <span>)。了解如何构建页面结构和使用 id, class 属性。
  • CSS 基础: 了解选择器、属性、值,盒模型、Flexbox 布局、基本样式设置,以及如何为元素添加阴影和圆角。对 object-fit 属性有基本了解。
  • JavaScript 基础: 了解变量、函数、条件语句(if/else),以及如何进行 DOM 操作(getElementById, textContent, style.display, classList.add/remove)。对事件处理(addEventListener, click)有深入理解。
  • WebRTC 概念: 对浏览器访问摄像头 (navigator.mediaDevices.getUserMedia()) 的基本原理和权限请求有初步概念。
  • 第三方库: 了解通过 <script> 标签引入外部 JavaScript 库的基本概念。

目录

  1. 项目概览与目标
  2. HTML 结构:构建应用的骨架
    • 2.1 创建 index.html 文件
    • 2.2 代码解释
  3. CSS 样式:美化应用界面与视频预览区
    • 3.1 创建 style.css 文件
    • 3.2 代码解释
    • 3.3 CSS 趣闻:object-fit: cover 与视频流:完美填充的艺术
  4. JavaScript 逻辑:赋予应用智能
    • 4.1 创建 script.js 文件
    • 4.2 代码解释
    • 4.3 JS 趣闻:navigator.mediaDevices.getUserMedia() API:浏览器如何访问摄像头
  5. 将所有文件连接起来
  6. 最终代码展示
  7. 拓展与改进
  8. 总结
  9. 附录:常见问题

1. 项目概览与目标

我们的目标是创建一个纯前端的二维码扫描器,它能够:

  • 摄像头访问: 请求并访问用户的设备摄像头以获取视频流。
  • 实时扫描: 使用第三方库实时处理视频流中的帧,识别二维码。
  • 结果显示: 扫描成功后,显示二维码中包含的文本数据。
  • 复制功能: 提供一个按钮,方便用户将扫描结果复制到剪贴板。
  • 状态管理: 显示扫描状态(正在扫描、扫描成功、错误)。
  • 错误处理: 处理摄像头访问失败、扫描失败等情况。
  • 控制按钮: 允许用户开始和停止扫描。
  • 响应式设计: 在不同屏幕尺寸下都能良好显示和操作。

预期效果图(文本描述):

页面中心有一个简洁的卡片式容器。
顶部是标题“二维码扫描器”。
下方是一个视频预览区域,用于显示摄像头捕获的画面。
再下方是扫描结果显示区,初始为空。
底部是控制按钮(例如“开始扫描”、“停止扫描”)。
可能会有额外的消息区域用于显示成功或错误信息。


2. HTML 结构:构建应用的骨架

首先,我们需要创建 HTML 文件来定义应用的基本结构,包括视频流显示、扫描结果输出和控制按钮。

2.1 创建 index.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>
    <!-- 引入自定义 CSS 文件 -->
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>二维码扫描器</h1>

        <div class="card scanner-section">
            <div id="reader" class="scanner-video-box">
                <!-- 摄像头视频流将在此处渲染 -->
            </div>
            <div id="loadingMessage" class="loading-message">
                正在加载摄像头...
            </div>
            <p id="cameraStatus" class="message"></p>
        </div>

        <div class="card result-section">
            <h2>扫描结果</h2>
            <div class="output-box">
                <p id="qrResult" class="result-text">等待扫描...</p>
                <button id="copyBtn" class="btn btn-secondary" style="display: none;" disabled>复制结果</button>
            </div>
        </div>
        
        <div class="card controls-section">
            <button id="startScanBtn" class="btn btn-primary">开始扫描</button>
            <button id="stopScanBtn" class="btn btn-secondary" disabled>停止扫描</button>
        </div>

        <div id="message" class="message" style="display: none;"></div>
    </div>

    <!-- 引入第三方二维码扫描库 (html5-qrcode CDN) -->
    <!-- JS 趣闻:navigator.mediaDevices.getUserMedia() API:浏览器如何访问摄像头 -->
    <script src="https://unpkg.com/html5-qrcode"></script>

    <!-- 引入 JavaScript 文件,defer 属性确保 HTML 解析完成后再执行 -->
    <script src="script.js" defer></script>
</body>
</html>
2.2 代码解释
  • <!DOCTYPE html>, <html>, <head>, <body>: 标准的 HTML5 结构。
  • <link rel="stylesheet" href="style.css">: 关联自定义 CSS 样式文件。
  • <div class="container">: 整体容器,包裹应用的所有元素,便于布局和样式设置。
  • <h1>二维码扫描器</h1>: 应用主标题。
  • 扫描区域 (.scanner-section.card):
    • <div id="reader" class="scanner-video-box">: 关键! 这是 html5-qrcode 库将把摄像头视频流渲染进去的容器。库会自动在这里创建一个 <video> 元素。
    • <div id="loadingMessage" class="loading-message">: 加载摄像头时的提示信息。
    • <p id="cameraStatus" class="message"></p>: 显示摄像头状态或错误信息。
  • 结果显示区域 (.result-section.card):
    • <h2>扫描结果</h2>: 区域标题。
    • <div class="output-box">: 扫描结果的容器。
      • <p id="qrResult" class="result-text">: 显示扫描到的二维码数据。
      • <button id="copyBtn" class="btn btn-secondary" style="display: none;" disabled>复制结果</button>: 复制结果按钮,初始隐藏且禁用。
  • 控制按钮区域 (.controls-section.card):
    • <button id="startScanBtn" class="btn btn-primary">开始扫描</button>: 启动扫描的按钮。
    • <button id="stopScanBtn" class="btn btn-secondary" disabled>停止扫描</button>: 停止扫描的按钮,初始禁用。
  • <div id="message" class="message" style="display: none;">: 通用的消息显示区域,初始隐藏。
  • <script src="https://unpkg.com/html5-qrcode"></script>: 关键! 引入了 html5-qrcode 这个第三方库。它是一个功能强大的 JavaScript 库,用于在浏览器中轻松实现二维码和条形码扫描。
  • <script src="script.js" defer></script>: 关联自定义 JavaScript 文件。defer 属性确保 HTML 解析完成后再执行脚本。

3. CSS 样式:美化应用界面与视频预览区

现在,我们来创建 style.css 文件,为二维码扫描器添加美观的视觉效果。

3.1 创建 style.css 文件
/* style.css */

/* 定义 CSS 变量 */
:root {
    --primary-color: #3498db; /* 蓝色 */
    --secondary-color: #2ecc71; /* 绿色 */
    --accent-color: #e74c3c; /* 红色用于错误 */
    --text-color: #333;
    --light-bg: #f0f2f5;
    --card-bg: #ffffff;
    --card-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
    --border-color: #eee;
    --transition-speed: 0.2s ease;
}

/* 全局样式和重置 */
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    color: var(--text-color);
    background-color: var(--light-bg);
    line-height: 1.6;
    display: flex;
    justify-content: center;
    align-items: flex-start; /* 顶部对齐 */
    min-height: 100vh;
    padding: 30px;
}

.container {
    background-color: var(--card-bg);
    padding: 40px;
    border-radius: 12px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
    max-width: 550px; /* 调整最大宽度 */
    width: 100%;
    text-align: center;
}

h1 {
    color: var(--primary-color);
    margin-bottom: 30px;
    font-size: 2.5em;
}

h2 {
    color: var(--text-color);
    margin-bottom: 15px;
    font-size: 1.4em;
    border-bottom: 1px solid var(--border-color);
    padding-bottom: 8px;
    text-align: left;
}

/* 卡片样式 */
.card {
    background-color: var(--card-bg);
    padding: 25px;
    border-radius: 10px;
    box-shadow: var(--card-shadow);
    margin-bottom: 20px;
    text-align: left;
}

/* 扫描区域样式 */
.scanner-section {
    position: relative;
    padding: 0; /* 视频流直接填充容器 */
    overflow: hidden; /* 隐藏视频流超出部分 */
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 300px; /* 确保有足够高度显示视频 */
}

.scanner-video-box {
    width: 100%;
    height: 100%;
    min-height: 300px; /* 同步父级最小高度 */
    display: flex; /* 让视频在其中居中 */
    justify-content: center;
    align-items: center;
    background-color: #333; /* 视频未加载时的背景 */
    border-radius: 10px;
    position: relative;
}

/* html5-qrcode 库生成的视频元素 */
.scanner-video-box video {
    width: 100%;
    height: 100%;
    display: block;
    border-radius: 10px;
    /* CSS 趣闻:object-fit: cover 与视频流:完美填充的艺术 */
    object-fit: cover; /* 确保视频填充容器,可能会裁剪边缘 */
    transform: scaleX(-1); /* 通常摄像头是镜像的,这样让用户看到“自己” */
}

.loading-message {
    position: absolute;
    color: white;
    font-size: 1.2em;
    z-index: 1;
    text-align: center;
    padding: 15px;
    background-color: rgba(0, 0, 0, 0.7);
    border-radius: 8px;
}

#cameraStatus.message {
    text-align: center;
    margin-top: 15px;
    font-size: 0.9em;
    color: var(--accent-color); /* 错误信息使用红色 */
    display: block !important; /* 强制显示,因为它可能被display: none覆盖 */
}

/* 结果显示区 */
.output-box {
    min-height: 80px;
    padding: 15px;
    border: 1px dashed var(--border-color);
    border-radius: 8px;
    background-color: #fcfcfc;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    word-break: break-all; /* 防止长链接溢出 */
}

.result-text {
    font-size: 1.1em;
    font-weight: 500;
    color: var(--text-color);
    margin-bottom: 10px;
    text-align: center;
}

/* 按钮样式 */
.btn {
    padding: 12px 25px;
    border: none;
    border-radius: 8px;
    font-size: 1em;
    font-weight: 600;
    cursor: pointer;
    transition: background-color var(--transition-speed), transform 0.1s ease, opacity var(--transition-speed);
    width: auto;
    display: inline-block;
    margin-right: 10px;
}

.btn:last-child {
    margin-right: 0;
}

.btn-primary {
    background-color: var(--primary-color);
    color: white;
}

.btn-primary:hover:not(:disabled) {
    background-color: #2980b9;
    transform: translateY(-2px);
}

.btn-secondary {
    background-color: var(--secondary-color);
    color: white;
}

.btn-secondary:hover:not(:disabled) {
    background-color: #27ae60;
    transform: translateY(-2px);
}

.btn:active:not(:disabled) {
    transform: translateY(0);
}

.btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
    transform: none;
}

/* 通用消息样式 */
#message.message {
    margin-top: 20px;
    padding: 12px;
    border-radius: 8px;
    font-size: 0.95em;
    text-align: center;
    margin-bottom: 20px;
}

.message.success {
    background-color: #d4edda;
    color: #155724;
    border: 1px solid #c3e6cb;
}

.message.error {
    background-color: #f8d7da;
    color: #721c24;
    border: 1px solid #f5c6cb;
}


/* 响应式调整 */
@media (max-width: 600px) {
    body {
        padding: 20px;
    }
    .container {
        padding: 25px;
        border-radius: 8px;
        box-shadow: none;
    }
    h1 {
        font-size: 2em;
        margin-bottom: 25px;
    }
    h2 {
        font-size: 1.3em;
        margin-bottom: 10px;
    }
    .card {
        padding: 20px;
        margin-bottom: 15px;
    }
    .btn {
        width: 100%;
        margin-bottom: 10px;
        font-size: 0.95em;
        margin-right: 0;
    }
    .controls-section .btn:last-child {
        margin-bottom: 0;
    }
    .scanner-video-box {
        min-height: 250px;
    }
    .loading-message {
        font-size: 1em;
        padding: 10px;
    }
    .output-box {
        min-height: 60px;
    }
    .result-text {
        font-size: 1em;
    }
}
3.2 代码解释
  • -- CSS 变量::root 中定义了一系列 CSS 变量,用于存储颜色、尺寸等常用值,便于统一管理和主题切换。
  • body.container 样式: 设置页面背景色、字体,并使用 Flexbox 将主容器居中。container 定义了整体卡片式外观。
  • h1, h2 样式:设置标题和子标题的字体样式和颜色。
  • .card 样式: 定义了扫描区、结果区和控制区这些模块的通用卡片式外观。
  • .scanner-section.scanner-video-box 样式:
    • min-height: 确保视频预览区有足够的尺寸,即使视频尚未加载也能保持布局稳定。
    • overflow: hidden;: 确保视频流不会超出容器的圆角边界。
    • display: flex; justify-content: center; align-items: center;: 使视频元素在容器内居中。
  • .scanner-video-box video 样式:
    • width: 100%; height: 100%;: 让视频元素填充其父容器。
    • border-radius: 10px;: 与父容器保持一致的圆角。
    • object-fit: cover; CSS 趣闻! 确保视频流在填充容器时能保持其宽高比,可能会裁剪视频的边缘,但不会留黑边或拉伸。
    • transform: scaleX(-1);: 小技巧! 许多前置摄像头默认是镜像的(就像照镜子),添加这个样式可以使视频在用户眼中看起来更自然(非镜像)。
  • .loading-message 样式: 摄像头加载时的提示信息样式,使用 position: absolute; 覆盖在视频区域中央。
  • #cameraStatus.message 样式: 专门用于显示摄像头权限或设备错误的提示信息,使用 !important 确保其样式优先级。
  • .output-box 样式: 扫描结果显示区的容器,使用虚线边框和 Flexbox 居中显示文本。word-break: break-all; 防止长链接导致文本溢出。
  • 按钮样式:
    • .btn, .btn-primary, .btn-secondary: 定义按钮的通用样式、主题色和悬停/激活效果。
    • :disabled 伪类:为禁用状态的按钮添加样式,使其看起来不可点击。
  • 通用消息样式 (#message.message): 定义不同类型的消息提示样式(成功/错误)。
  • @media 响应式调整: 针对不同屏幕宽度调整 padding, font-size 和按钮宽度,确保在移动设备上也能良好显示和操作。
3.3 CSS 趣闻:object-fit: cover 与视频流:完美填充的艺术
  • 图片与视频的填充需求:
    • 趣闻: 无论是图片 (<img>) 还是视频 (<video>),在网页布局中,我们经常希望它们能优雅地填充其容器,而不是被拉伸变形或者在容器内留下空白。
  • object-fit 属性再探:
    • 在前面的教程中,我们介绍了 object-fit 用于图片的适应。它同样适用于 <video> 元素,并且在这里尤为重要。
    • object-fit: cover; 的魔力:
      • 当视频流的原始宽高比与其容器的宽高比不匹配时,object-fit: cover; 会使视频内容在保持其原始宽高比的情况下,尽可能大地填充整个容器。
      • 这意味着视频会裁剪掉超出容器的部分,以确保完全覆盖容器,而不会出现黑边。这对于实时视频流(如摄像头画面)来说,通常是最佳的用户体验,因为它能提供一个全屏且无缝的视觉效果。
    • object-fit: contain; 的区别:
      • contain 会确保视频内容完全显示在容器内,不会被裁剪,但可能会在容器的两侧或上下留下空白(黑边)。在二维码扫描器中,虽然 contain 也能用,但 cover 通常视觉效果更好,因为它能更充分地利用屏幕空间。
  • transform: scaleX(-1); 的小秘密:
    • 这是一个非常常见但又容易被忽视的技巧。
    • 背景: 大多数设备的前置摄像头(自拍摄像头)默认捕获的图像是镜像的,就像你在镜子里看到自己一样。如果你不处理,用户会发现屏幕上的动作方向和他们的实际动作方向是反的。
    • 效果: transform: scaleX(-1); 会沿着 X 轴(水平方向)将元素翻转。这有效地“反转”了摄像头的镜像效果,使得用户在屏幕上看到的自己是正常的方向,从而提供了更直观和舒适的互动体验。
  • 总结: object-fit: cover;transform: scaleX(-1); 共同为 Web 摄像头应用提供了美观且用户友好的视频流展示方案,一个确保了视频填充的视觉质量,另一个则优化了用户与前置摄像头交互的体验。

4. JavaScript 逻辑:赋予应用智能

最后,我们来创建 script.js 文件,实现摄像头访问、二维码扫描、结果处理和用户交互功能。

4.1 创建 script.js 文件
// script.js

// 1. 获取所有需要操作的 DOM 元素
const qrResultText = document.getElementById('qrResult');
const copyBtn = document.getElementById('copyBtn');
const startScanBtn = document.getElementById('startScanBtn');
const stopScanBtn = document.getElementById('stopScanBtn');
const messageDisplay = document.getElementById('message');
const loadingMessage = document.getElementById('loadingMessage');
const cameraStatusText = document.getElementById('cameraStatus'); // 用于显示摄像头状态/错误

// 2. 初始化 Html5QrcodeScanner 实例
// 第一个参数是渲染扫描器的元素的ID (即 HTML 中的 'reader' div)
// 第二个参数是配置对象
const html5QrCode = new Html5Qrcode("reader", {
    formats: [Html5QrcodeSupportedFormats.QR_CODE], // 仅扫描二维码
    verbose: false // 不在控制台输出过多信息
});

// 3. 定义全局变量来跟踪扫描状态
let isScanning = false;
let currentCameraId = null; // 存储当前使用的摄像头ID

// 4. 函数:显示通用消息 (成功或错误)
function displayMessage(message, type) {
    messageDisplay.textContent = message;
    messageDisplay.className = `message ${type}`; // 添加类型类 (success 或 error)
    messageDisplay.style.display = 'block';
    // 3秒后自动隐藏消息
    setTimeout(() => {
        messageDisplay.style.display = 'none';
    }, 3000);
}

// 5. 函数:更新按钮和UI状态
function updateUIForScanning(scanning) {
    isScanning = scanning;
    startScanBtn.disabled = scanning;
    stopScanBtn.disabled = !scanning;
    loadingMessage.style.display = scanning ? 'block' : 'none'; // 扫描开始时显示加载信息,停止时隐藏

    // 扫描开始时,隐藏结果,禁用复制
    if (scanning) {
        qrResultText.textContent = '等待扫描...';
        copyBtn.style.display = 'none';
        copyBtn.disabled = true;
        cameraStatusText.textContent = ''; // 清除旧的摄像头状态
    }
}

// 6. 扫描成功回调函数
const onScanSuccess = (decodedText, decodedResult) => {
    // 确保只处理一次扫描结果
    if (isScanning) {
        // 在这里停止扫描以避免重复识别
        html5QrCode.stop().then(ignore => {
            updateUIForScanning(false); // 更新UI为停止扫描状态
            loadingMessage.style.display = 'none'; // 隐藏加载信息
            qrResultText.textContent = decodedText;
            copyBtn.style.display = 'inline-block';
            copyBtn.disabled = false;
            displayMessage('二维码扫描成功!', 'success');
        }).catch(err => {
            console.error("Failed to stop scanning.", err);
            cameraStatusText.textContent = "停止扫描失败:" + err;
            displayMessage('停止扫描失败!', 'error');
        });
    }
};

// 7. 扫描错误回调函数 (非致命错误,例如未找到二维码)
const onScanError = (errorMessage) => {
    // console.warn(`QR Code Scan Error: ${errorMessage}`);
    // 对于不重要的错误,可以不显示,或只在调试时显示
    // loadingMessage.textContent = "正在扫描..."; // 保持加载状态,因为还在扫描
};


// 8. 函数:开始扫描
async function startScan() {
    updateUIForScanning(true);
    loadingMessage.textContent = "正在启动摄像头...";
    qrResultText.textContent = '等待扫描...';
    cameraStatusText.textContent = ''; // 清除之前的状态

    try {
        // 获取所有可用的视频输入设备
        const videoInputDevices = await Html5Qrcode.getCameras();

        if (videoInputDevices && videoInputDevices.length) {
            // 选择第一个摄像头作为默认 (或者让用户选择)
            currentCameraId = videoInputDevices[0].id; // 存储摄像头ID

            // JS 趣闻:navigator.mediaDevices.getUserMedia() API:浏览器如何访问摄像头
            await html5QrCode.start(
                currentCameraId, // 摄像头 ID
                {
                    fps: 10, // 每秒扫描帧数
                    qrbox: { width: 250, height: 250 } // 扫描框大小
                },
                onScanSuccess,
                onScanError
            );
            loadingMessage.style.display = 'none'; // 隐藏加载信息
            displayMessage('摄像头已启动,正在扫描...', 'success');
            cameraStatusText.textContent = "摄像头已启动。";

        } else {
            loadingMessage.style.display = 'none';
            cameraStatusText.textContent = '未找到任何摄像头设备。';
            displayMessage('未找到任何摄像头设备!', 'error');
            updateUIForScanning(false);
        }
    } catch (err) {
        console.error("Failed to start scanning.", err);
        loadingMessage.style.display = 'none';
        cameraStatusText.textContent = `启动摄像头失败:${err.name || '未知错误'}. 请检查摄像头权限。`;
        displayMessage('启动摄像头失败,请检查权限。', 'error');
        updateUIForScanning(false);
    }
}

// 9. 函数:停止扫描
async function stopScan() {
    if (!isScanning) return; // 如果没有在扫描,则不执行

    loadingMessage.textContent = "正在停止摄像头...";
    loadingMessage.style.display = 'block';
    
    try {
        await html5QrCode.stop();
        updateUIForScanning(false);
        qrResultText.textContent = '扫描已停止。';
        copyBtn.style.display = 'none';
        copyBtn.disabled = true;
        loadingMessage.style.display = 'none';
        cameraStatusText.textContent = '扫描已停止。';
        displayMessage('扫描已停止。', 'success');
    } catch (err) {
        console.error("Failed to stop scanning.", err);
        loadingMessage.style.display = 'none';
        cameraStatusText.textContent = "停止扫描失败:" + err;
        displayMessage('停止扫描失败!', 'error');
        // 即使停止失败,UI也应该重置
        updateUIForScanning(false);
    }
}

// 10. 函数:复制扫描结果到剪贴板
async function copyResult() {
    const textToCopy = qrResultText.textContent;
    if (textToCopy && textToCopy !== '等待扫描...' && textToCopy !== '扫描已停止。') {
        try {
            await navigator.clipboard.writeText(textToCopy);
            displayMessage('结果已复制到剪贴板!', 'success');
        } catch (err) {
            console.error('无法复制到剪贴板:', err);
            displayMessage('复制失败,请手动复制。', 'error');
        }
    } else {
        displayMessage('没有可复制的结果。', 'error');
    }
}

// 11. 添加事件监听器
startScanBtn.addEventListener('click', startScan);
stopScanBtn.addEventListener('click', stopScan);
copyBtn.addEventListener('click', copyResult);

// 12. 页面加载时的初始状态设置
window.addEventListener('load', () => {
    updateUIForScanning(false); // 初始禁用停止按钮和隐藏加载信息
    loadingMessage.style.display = 'none'; // 初始隐藏加载信息
    cameraStatusText.textContent = ''; // 清空摄像头状态
});

4.2 代码解释
  1. 获取 DOM 元素: 获取所有必要的 HTML 元素的引用,包括结果显示、复制按钮、开始/停止扫描按钮、消息显示、加载信息和摄像头状态文本。
  2. 初始化 Html5Qrcode 实例:
    • new Html5Qrcode("reader", { ... }): 创建 html5-qrcode 库的实例。第一个参数 "reader" 是库将渲染视频流的 HTML 元素的 ID。
    • formats: 配置要扫描的码制,这里只设置为 QR_CODE
    • verbose: false: 减少控制台输出,使调试信息更清晰。
  3. 全局变量:
    • isScanning: 跟踪当前是否正在进行扫描。
    • currentCameraId: 存储当前正在使用的摄像头设备的 ID。
  4. displayMessage() 函数: 通用的消息显示函数,支持成功和错误类型,并自动隐藏。
  5. updateUIForScanning() 函数: 根据扫描状态(scanning 参数),动态更新按钮的禁用状态和加载信息的显示,以及结果文本的重置。
  6. onScanSuccess() 回调函数:
    • html5-qrcode 库成功扫描到二维码时,会调用此函数。
    • decodedText: 扫描到的二维码文本数据。
    • 为了防止在一次扫描成功后继续不必要地扫描(这可能导致重复结果或性能问题),我们立即调用 html5QrCode.stop() 来停止摄像头。
    • 更新 UI,显示扫描结果,启用复制按钮。
  7. onScanError() 回调函数:
    • html5-qrcode 库在扫描过程中遇到非致命错误(例如,未检测到二维码但摄像头仍在运行)时调用。为了避免过多打扰用户,这里选择不显示这些错误,只在控制台输出。
  8. startScan() 函数 (核心逻辑):
    • 调用 updateUIForScanning(true) 更新 UI。
    • Html5Qrcode.getCameras() 异步获取设备上所有可用的视频输入设备列表。
    • 摄像头选择: 如果找到摄像头,我们默认选择第一个。在更高级的应用中,您可以让用户选择使用哪个摄像头。
    • html5QrCode.start(cameraId, config, successCallback, errorCallback) 关键! 启动摄像头并开始扫描。
      • cameraId: 要使用的摄像头 ID。
      • config: 包含 fps (每秒扫描帧数) 和 qrbox (扫描区域大小) 等配置。
      • onScanSuccess: 扫描成功时调用的函数。
      • onScanError: 扫描错误时调用的函数。
    • 错误处理: 使用 try...catch 捕获启动摄像头可能发生的错误(如权限拒绝、设备未找到),并显示相应的错误消息。
  9. stopScan() 函数:
    • 调用 html5QrCode.stop() 停止摄像头和扫描过程。
    • 更新 UI 状态,重置结果显示。
  10. copyResult() 函数:
    • 使用 navigator.clipboard.writeText() API 将扫描结果文本异步复制到用户的剪贴板。
    • 提供成功或失败的提示。
  11. 事件监听器:
    • startScanBtn.addEventListener('click', startScan);: 监听“开始扫描”按钮点击。
    • stopScanBtn.addEventListener('click', stopScan);: 监听“停止扫描”按钮点击。
    • copyBtn.addEventListener('click', copyResult);: 监听“复制结果”按钮点击。
  12. 页面初始化 (window.addEventListener('load', ...) ): 设置初始的 UI 状态,例如禁用停止按钮,隐藏加载信息。
4.3 JS 趣闻:navigator.mediaDevices.getUserMedia() API:浏览器如何访问摄像头
  • WebRTC 的基石:
    • 趣闻: 在 Web 技术出现之前,访问用户的摄像头或麦克风通常需要浏览器插件(如 Flash)。但随着 WebRTC (Web Real-Time Communication) 技术的发展,现代浏览器可以直接通过 JavaScript 访问这些硬件设备,实现实时音视频通信。
  • getUserMedia() 的作用:
    • navigator.mediaDevices.getUserMedia(constraints) 是 WebRTC API 的核心方法之一。它用于请求用户授予访问其媒体输入设备(如摄像头和麦克风)的权限。
    • constraints 对象: 这是一个关键参数,用于指定你想要访问的媒体类型和它们的具体要求:
      • { video: true }: 请求访问摄像头。
      • { audio: true }: 请求访问麦克风。
      • { video: { width: 1280, height: 720, facingMode: "user" } }: 可以更具体地指定视频的宽度、高度、以及使用前置 (user) 还是后置 (environment) 摄像头。
    • 权限请求:getUserMedia() 被调用时,浏览器会弹出一个权限请求窗口,询问用户是否允许访问摄像头。这是出于用户隐私和安全考虑。
      • 安全上下文: 为了保护用户隐私,getUserMedia() 及其相关 API 只能在安全上下文 (secure contexts) 中使用,这意味着你的网页必须通过 HTTPS 协议提供服务(或者在开发阶段通过 localhost 访问)。如果不是安全上下文,请求会失败。
  • html5-qrcode 库如何简化它:
    • 挑战: 虽然 getUserMedia() 强大,但直接使用它来构建一个二维码扫描器仍然需要处理很多细节:
      1. 获取视频流 (MediaStream)。
      2. 将视频流附加到 <video> 元素。
      3. 定期从视频流中捕获帧(例如,通过 <canvas> )。
      4. 对捕获的帧进行图像处理,寻找二维码模式。
      5. 解析二维码数据。
      6. 处理摄像头切换、分辨率调整、权限错误等。
    • 库的价值: html5-qrcode (以及其他类似库如 jsQRZXing) 抽象了所有这些底层细节。它内部调用了 getUserMedia() 来获取视频流,然后使用复杂的图像处理算法在每一帧中寻找并解码二维码。开发者只需要提供一个 HTML 元素 ID 和回调函数,库就能完成大部分繁重的工作。
  • 总结: navigator.mediaDevices.getUserMedia() API 是 Web 应用程序访问用户摄像头和麦克风的强大工具,但它需要用户明确的权限授予并在安全上下文中使用。像 html5-qrcode 这样的第三方库则在此基础上提供了更高级别的抽象,极大地简化了在 Web 应用中实现二维码扫描等基于摄像头的实时功能。

5. 将所有文件连接起来

确保您的项目文件夹结构如下:

your-qr-code-scanner-project/
├── index.html
├── style.css
└── script.js

然后,用浏览器打开 index.html 文件(可以直接双击,或在 VS Code 中使用 “Open with Live Server” 插件)。请注意,为了安全和隐私,浏览器通常只允许通过 HTTPS 或者在 localhost 上运行的网页访问摄像头。 如果您遇到摄像头权限问题,请确保您的服务是通过这些安全上下文提供的。

您应该能看到一个功能完善的二维码扫描器应用!点击“开始扫描”,授予摄像头权限后,将摄像头对准二维码,即可看到扫描结果。


6. 最终代码展示

index.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="style.css">
</head>
<body>
    <div class="container">
        <h1>二维码扫描器</h1>

        <div class="card scanner-section">
            <div id="reader" class="scanner-video-box">
                <!-- 摄像头视频流将在此处渲染 -->
            </div>
            <div id="loadingMessage" class="loading-message">
                正在加载摄像头...
            </div>
            <p id="cameraStatus" class="message"></p>
        </div>

        <div class="card result-section">
            <h2>扫描结果</h2>
            <div class="output-box">
                <p id="qrResult" class="result-text">等待扫描...</p>
                <button id="copyBtn" class="btn btn-secondary" style="display: none;" disabled>复制结果</button>
            </div>
        </div>
        
        <div class="card controls-section">
            <button id="startScanBtn" class="btn btn-primary">开始扫描</button>
            <button id="stopScanBtn" class="btn btn-secondary" disabled>停止扫描</button>
        </div>

        <div id="message" class="message" style="display: none;"></div>
    </div>

    <script src="https://unpkg.com/html5-qrcode"></script>
    <script src="script.js" defer></script>
</body>
</html>

style.css

:root {
    --primary-color: #3498db;
    --secondary-color: #2ecc71;
    --accent-color: #e74c3c;
    --text-color: #333;
    --light-bg: #f0f2f5;
    --card-bg: #ffffff;
    --card-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
    --border-color: #eee;
    --transition-speed: 0.2s ease;
}

* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    color: var(--text-color);
    background-color: var(--light-bg);
    line-height: 1.6;
    display: flex;
    justify-content: center;
    align-items: flex-start;
    min-height: 100vh;
    padding: 30px;
}

.container {
    background-color: var(--card-bg);
    padding: 40px;
    border-radius: 12px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
    max-width: 550px;
    width: 100%;
    text-align: center;
}

h1 {
    color: var(--primary-color);
    margin-bottom: 30px;
    font-size: 2.5em;
}

h2 {
    color: var(--text-color);
    margin-bottom: 15px;
    font-size: 1.4em;
    border-bottom: 1px solid var(--border-color);
    padding-bottom: 8px;
    text-align: left;
}

.card {
    background-color: var(--card-bg);
    padding: 25px;
    border-radius: 10px;
    box-shadow: var(--card-shadow);
    margin-bottom: 20px;
    text-align: left;
}

.scanner-section {
    position: relative;
    padding: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 300px;
}

.scanner-video-box {
    width: 100%;
    height: 100%;
    min-height: 300px;
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: #333;
    border-radius: 10px;
    position: relative;
}

.scanner-video-box video {
    width: 100%;
    height: 100%;
    display: block;
    border-radius: 10px;
    object-fit: cover;
    transform: scaleX(-1);
}

.loading-message {
    position: absolute;
    color: white;
    font-size: 1.2em;
    z-index: 1;
    text-align: center;
    padding: 15px;
    background-color: rgba(0, 0, 0, 0.7);
    border-radius: 8px;
}

#cameraStatus.message {
    text-align: center;
    margin-top: 15px;
    font-size: 0.9em;
    color: var(--accent-color);
    display: block !important;
}

.output-box {
    min-height: 80px;
    padding: 15px;
    border: 1px dashed var(--border-color);
    border-radius: 8px;
    background-color: #fcfcfc;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    word-break: break-all;
}

.result-text {
    font-size: 1.1em;
    font-weight: 500;
    color: var(--text-color);
    margin-bottom: 10px;
    text-align: center;
}

.btn {
    padding: 12px 25px;
    border: none;
    border-radius: 8px;
    font-size: 1em;
    font-weight: 600;
    cursor: pointer;
    transition: background-color var(--transition-speed), transform 0.1s ease, opacity var(--transition-speed);
    width: auto;
    display: inline-block;
    margin-right: 10px;
}

.btn:last-child {
    margin-right: 0;
}

.btn-primary {
    background-color: var(--primary-color);
    color: white;
}

.btn-primary:hover:not(:disabled) {
    background-color: #2980b9;
    transform: translateY(-2px);
}

.btn-secondary {
    background-color: var(--secondary-color);
    color: white;
}

.btn-secondary:hover:not(:disabled) {
    background-color: #27ae60;
    transform: translateY(-2px);
}

.btn:active:not(:disabled) {
    transform: translateY(0);
}

.btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
    transform: none;
}

#message.message {
    margin-top: 20px;
    padding: 12px;
    border-radius: 8px;
    font-size: 0.95em;
    text-align: center;
    margin-bottom: 20px;
}

.message.success {
    background-color: #d4edda;
    color: #155724;
    border: 1px solid #c3e6cb;
}

.message.error {
    background-color: #f8d7da;
    color: #721c24;
    border: 1px solid #f5c6cb;
}

@media (max-width: 600px) {
    body {
        padding: 20px;
    }
    .container {
        padding: 25px;
        border-radius: 8px;
        box-shadow: none;
    }
    h1 {
        font-size: 2em;
        margin-bottom: 25px;
    }
    h2 {
        font-size: 1.3em;
        margin-bottom: 10px;
    }
    .card {
        padding: 20px;
        margin-bottom: 15px;
    }
    .btn {
        width: 100%;
        margin-bottom: 10px;
        font-size: 0.95em;
        margin-right: 0;
    }
    .controls-section .btn:last-child {
        margin-bottom: 0;
    }
    .scanner-video-box {
        min-height: 250px;
    }
    .loading-message {
        font-size: 1em;
        padding: 10px;
    }
    .output-box {
        min-height: 60px;
    }
    .result-text {
        font-size: 1em;
    }
}

script.js

const qrResultText = document.getElementById('qrResult');
const copyBtn = document.getElementById('copyBtn');
const startScanBtn = document.getElementById('startScanBtn');
const stopScanBtn = document.getElementById('stopScanBtn');
const messageDisplay = document.getElementById('message');
const loadingMessage = document.getElementById('loadingMessage');
const cameraStatusText = document.getElementById('cameraStatus');

const html5QrCode = new Html5Qrcode("reader", {
    formats: [Html5QrcodeSupportedFormats.QR_CODE],
    verbose: false
});

let isScanning = false;
let currentCameraId = null;

function displayMessage(message, type) {
    messageDisplay.textContent = message;
    messageDisplay.className = `message ${type}`;
    messageDisplay.style.display = 'block';
    setTimeout(() => {
        messageDisplay.style.display = 'none';
    }, 3000);
}

function updateUIForScanning(scanning) {
    isScanning = scanning;
    startScanBtn.disabled = scanning;
    stopScanBtn.disabled = !scanning;
    loadingMessage.style.display = scanning ? 'block' : 'none';

    if (scanning) {
        qrResultText.textContent = '等待扫描...';
        copyBtn.style.display = 'none';
        copyBtn.disabled = true;
        cameraStatusText.textContent = '';
    }
}

const onScanSuccess = (decodedText, decodedResult) => {
    if (isScanning) {
        html5QrCode.stop().then(ignore => {
            updateUIForScanning(false);
            loadingMessage.style.display = 'none';
            qrResultText.textContent = decodedText;
            copyBtn.style.display = 'inline-block';
            copyBtn.disabled = false;
            displayMessage('二维码扫描成功!', 'success');
        }).catch(err => {
            console.error("Failed to stop scanning.", err);
            cameraStatusText.textContent = "停止扫描失败:" + err;
            displayMessage('停止扫描失败!', 'error');
        });
    }
};

const onScanError = (errorMessage) => {
    // console.warn(`QR Code Scan Error: ${errorMessage}`);
};

async function startScan() {
    updateUIForScanning(true);
    loadingMessage.textContent = "正在启动摄像头...";
    qrResultText.textContent = '等待扫描...';
    cameraStatusText.textContent = '';

    try {
        const videoInputDevices = await Html5Qrcode.getCameras();

        if (videoInputDevices && videoInputDevices.length) {
            currentCameraId = videoInputDevices[0].id;

            await html5QrCode.start(
                currentCameraId,
                {
                    fps: 10,
                    qrbox: { width: 250, height: 250 }
                },
                onScanSuccess,
                onScanError
            );
            loadingMessage.style.display = 'none';
            displayMessage('摄像头已启动,正在扫描...', 'success');
            cameraStatusText.textContent = "摄像头已启动。";

        } else {
            loadingMessage.style.display = 'none';
            cameraStatusText.textContent = '未找到任何摄像头设备。';
            displayMessage('未找到任何摄像头设备!', 'error');
            updateUIForScanning(false);
        }
    } catch (err) {
        console.error("Failed to start scanning.", err);
        loadingMessage.style.display = 'none';
        cameraStatusText.textContent = `启动摄像头失败:${err.name || '未知错误'}. 请检查摄像头权限。`;
        displayMessage('启动摄像头失败,请检查权限。', 'error');
        updateUIForScanning(false);
    }
}

async function stopScan() {
    if (!isScanning) return;

    loadingMessage.textContent = "正在停止摄像头...";
    loadingMessage.style.display = 'block';
    
    try {
        await html5QrCode.stop();
        updateUIForScanning(false);
        qrResultText.textContent = '扫描已停止。';
        copyBtn.style.display = 'none';
        copyBtn.disabled = true;
        loadingMessage.style.display = 'none';
        cameraStatusText.textContent = '扫描已停止。';
        displayMessage('扫描已停止。', 'success');
    } catch (err) {
        console.error("Failed to stop scanning.", err);
        loadingMessage.style.display = 'none';
        cameraStatusText.textContent = "停止扫描失败:" + err;
        displayMessage('停止扫描失败!', 'error');
        updateUIForScanning(false);
    }
}

async function copyResult() {
    const textToCopy = qrResultText.textContent;
    if (textToCopy && textToCopy !== '等待扫描...' && textToCopy !== '扫描已停止。') {
        try {
            await navigator.clipboard.writeText(textToCopy);
            displayMessage('结果已复制到剪贴板!', 'success');
        } catch (err) {
            console.error('无法复制到剪贴板:', err);
            displayMessage('复制失败,请手动复制。', 'error');
        }
    } else {
        displayMessage('没有可复制的结果。', 'error');
    }
}

startScanBtn.addEventListener('click', startScan);
stopScanBtn.addEventListener('click', stopScan);
copyBtn.addEventListener('click', copyResult);

window.addEventListener('load', () => {
    updateUIForScanning(false);
    loadingMessage.style.display = 'none';
    cameraStatusText.textContent = '';
});

7. 拓展与改进

  • 多摄像头选择: 允许用户从设备上的多个摄像头中选择一个(例如前置或后置)。Html5Qrcode.getCameras() 方法可以获取所有摄像头列表。
  • 闪光灯/手电筒控制: 对于支持的设备,可以添加控制摄像头闪光灯(手电筒)的按钮。
  • 文件上传扫描: 除了实时视频流,还可以允许用户上传本地图片文件进行扫描。
  • 历史记录: 将扫描过的二维码内容存储在 localStorage 中,方便用户查看历史记录。
  • 高级 UI/UX: 在扫描区域显示一个扫描框动画,或在成功扫描时给出视觉反馈。
  • 错误码/类型显示: 扫描结果除了内容,还可以显示二维码的类型(URL、文本、联系人等)。
  • Web Workers: 对于性能要求高的场景,可以将图像处理逻辑放到 Web Workers 中,避免阻塞主线程。

8. 总结

恭喜您!您已经成功地使用 HTML、CSS 和 JavaScript 构建了一个实用的浏览器端二维码扫描器。在这个过程中,您:

  • 学会了如何构建清晰的 HTML 结构,包括视频预览区、结果显示区和控制按钮。
  • 掌握了如何利用 CSS 美化应用界面,特别是对视频元素的布局和样式调整,包括 object-fit: covertransform: scaleX(-1) 的应用。
  • 深入理解了 JavaScript 如何通过第三方库 (html5-qrcode) 来简化对 navigator.mediaDevices.getUserMedia() API 的使用,从而实现摄像头访问和实时视频流处理。
  • 掌握了如何处理扫描成功和失败的回调,以及如何管理应用状态(开始/停止扫描、加载、错误)。
  • 实现了将扫描结果复制到剪贴板的功能。
  • 学习了如何进行错误处理、显示加载状态和更新按钮状态,以提供良好的用户体验。

这个项目是您 Web 开发旅程中的一个重要里程碑,它涵盖了 UI 设计、浏览器 API 调用、第三方库集成、实时数据处理和用户交互的许多核心概念。继续练习、探索和构建,您将很快成为一名熟练的开发者!


9. 附录:常见问题

Q: 为什么我的二维码扫描器无法访问摄像头?
A: 最常见的原因是:

  1. 权限拒绝: 浏览器通常会弹出一个请求访问摄像头的提示。如果您不小心拒绝了,或者之前拒绝过,您需要在浏览器设置中手动重新授予权限。
  2. 非安全上下文: 大多数现代浏览器(如 Chrome, Firefox)只允许通过 HTTPS 协议 (https://) 加载的页面访问 getUserMedia() API。如果您在本地文件系统 (file://) 或通过 HTTP (http://) 运行,可能会被阻止。在开发阶段,localhost 通常被视为安全上下文。
  3. 没有摄像头设备: 您的设备可能没有内置或连接的摄像头。
  4. 摄像头被占用: 其他应用程序(如 Zoom, Skype)可能正在使用摄像头。请关闭其他应用后重试。
  5. 浏览器兼容性: 确保您使用的浏览器支持 getUserMedia() API(现代浏览器通常都支持)。

Q: html5-qrcode 库除了二维码还能扫描其他条形码吗?
A: 是的,html5-qrcode 库支持多种条形码格式。在初始化 Html5Qrcode 实例时,您可以配置 formats 选项来指定要扫描的格式列表。例如:
formats: [Html5QrcodeSupportedFormats.QR_CODE, Html5QrcodeSupportedFormats.CODE_39, Html5QrcodeSupportedFormats.EAN_13]
库支持的格式列表可以在其官方文档中找到,通常包括 CODE_128, EAN_8, EAN_13, CODE_39, QR_CODE, DATA_MATRIX, AZTEC, PDF_417 等。

Q: 在移动设备上使用这个扫描器有什么特殊注意事项吗?
A: 在移动设备上使用时,主要有以下几点需要注意:

  1. 摄像头选择: 移动设备通常有前置和后置摄像头。html5-qrcode 库允许您通过配置 facingMode 选项(如 facingMode: { exact: "environment" } 表示后置摄像头,facingMode: "user" 表示前置摄像头)来指定使用哪个摄像头,或者让用户通过 Html5Qrcode.getCameras() 动态选择。
  2. 性能: 移动设备处理视频流和实时图像解码可能比桌面设备更耗电或性能更低。可以调整 fps 参数来优化性能。
  3. UI 适配: 确保您的 CSS 响应式设计能够良好地适应小屏幕和不同方向(横屏/竖屏)。
  4. 手电筒控制: 部分移动浏览器和设备 API 允许访问手电筒功能,这对于在光线不足环境下扫描非常有用。html5-qrcode 库也提供了相应的方法(如 applyVideoConstraints())来尝试控制手电筒。

Q: 为什么扫描成功后要立即调用 html5QrCode.stop()
A: 主要有以下几个原因:

  1. 避免重复扫描: 一旦成功识别了一个二维码,通常我们不希望立即再次识别同一个二维码或其周围的另一个二维码。停止扫描可以防止在用户还没来得及处理结果时就产生多个重复的扫描事件。
  2. 节省资源: 实时视频流和图像处理是耗费 CPU 和电池资源的。成功扫描并获取到结果后,停止摄像头可以立即释放这些资源,提高设备性能和续航。
  3. 用户体验: 在很多场景下,用户期望在扫描成功后,应用程序能暂停并显示结果,而不是继续在后台扫描。如果需要继续扫描,可以由用户手动触发。
Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐