UNPKG

jz-h5-scancode

Version:

H5环境下的二维码扫描插件,提供与 uni.scanCode 完全兼容的 API 接口,支持摄像头实时扫码和图片扫码。v1.1.0优化性能,修复卡死问题

952 lines (814 loc) 32.6 kB
/** * jz-h5-scanCode 扫码插件主入口 * 提供与uni.scanCode一致的API接口 * H5环境使用自定义扫码实现,其他平台使用uni原生扫码 */ import CameraScanner from './cameraScanner.js' import QRCodeDecoder from './qrCodeDecoder.js' import UIManager from './uiManager.js' class JzH5ScanCode { constructor() { this.cameraScanner = new CameraScanner(); this.qrCodeDecoder = new QRCodeDecoder(); this.uiManager = new UIManager(); this.isScanning = false; this.isInitializing = false; this.currentOptions = null; this.scanAnimationId = null; this.scanLoopActive = false; this.lastScanTime = 0; this.scanInterval = 300; // 增加扫码间隔到300ms,减少CPU占用 this.isDecoding = false; // 添加解码状态标记,避免重复解码 // 绑定事件处理器,确保this指向正确且可以正确移除 this.boundHandleUIClose = this.handleUIClose.bind(this); this.boundHandleFileSelected = this.handleFileSelected.bind(this); // 预初始化解码器(后台进行) this.preInitializeDecoder(); } /** * 预初始化解码器(后台异步进行) */ async preInitializeDecoder() { try { // 使用 setTimeout 确保不阻塞主线程 setTimeout(async () => { await this.qrCodeDecoder.init(); console.log('解码器预初始化完成'); }, 100); } catch (error) { console.warn('解码器预初始化失败:', error); } } /** * 检查当前是否为H5环境 */ isH5Environment() { // 检查是否在uni-app的H5环境中 if (typeof uni !== 'undefined' && uni.getSystemInfoSync) { try { const systemInfo = uni.getSystemInfoSync(); return systemInfo.platform === 'devtools' || systemInfo.uniPlatform === 'web'; } catch (error) { console.warn('获取系统信息失败:', error); } } // 检查是否在浏览器环境中 return typeof window !== 'undefined' && typeof document !== 'undefined' && !window.plus && !window.wx && !window.my; } /** * 扫码方法 - 与uni.scanCode API保持一致 * @param {Object} options - 扫码参数 */ scanCode(options = {}) { // 防止重复调用 if (this.isScanning || this.isInitializing) { console.warn('扫码正在进行中,忽略重复调用'); if (options.fail) { options.fail({ errMsg: 'scanCode:fail busy' }); } if (options.complete) { options.complete({ errMsg: 'scanCode:fail busy' }); } return; } // 如果不是H5环境,直接使用uni.scanCode if (!this.isH5Environment()) { return this.useUniScanCode(options); } // H5环境使用自定义扫码实现 return this.useH5ScanCode(options); } /** * 使用uni原生扫码接口 */ useUniScanCode(options) { if (typeof uni === 'undefined' || !uni.scanCode) { const error = '当前环境不支持uni.scanCode'; if (options.fail) { options.fail({ errMsg: error }); } if (options.complete) { options.complete({ errMsg: error }); } return; } console.log('使用uni原生扫码接口'); uni.scanCode(options); } /** * 使用H5自定义扫码实现 */ async useH5ScanCode(options) { if (this.isInitializing) { console.warn('扫码初始化中,请稍后重试'); if (options.fail) { options.fail({ errMsg: 'scanCode:fail initializing' }); } if (options.complete) { options.complete({ errMsg: 'scanCode:fail initializing' }); } return; } this.isInitializing = true; try { console.log('使用H5自定义扫码实现'); this.currentOptions = options; // 使用 Promise 和 setTimeout 避免阻塞 await new Promise(resolve => setTimeout(resolve, 0)); // 快速检查摄像头支持情况(不等待权限) const hasMediaDevices = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); // 如果明确要求仅相册或没有摄像头API,直接进入图片选择模式 if (options.onlyFromCamera === false || !hasMediaDevices) { await this.startImagePickerMode(); return; } // 并行执行UI创建和摄像头检查 const [uiResult, cameraResult] = await Promise.allSettled([ this.createScannerUI(options), this.checkAndInitCamera(options) ]); // 检查UI创建结果 if (uiResult.status === 'rejected') { throw new Error('UI创建失败: ' + uiResult.reason.message); } // 检查摄像头初始化结果 if (cameraResult.status === 'rejected') { console.log('摄像头初始化失败,降级到图片选择模式:', cameraResult.reason.message); // 清理已创建的UI this.uiManager.removeScannerUI(); await this.startImagePickerMode(); return; } const canvasElement = uiResult.value; // 绑定事件(必须在UI创建后) this.bindUIEvents(); // 启动扫码循环 this.isScanning = true; this.startScanLoop(); console.log('H5扫码已启动'); } catch (error) { console.error('启动扫码失败:', error); // 清理状态 this.cleanup(); // 尝试降级到图片选择模式 if (this.isCameraError(error)) { console.log('摄像头相关错误,尝试降级到图片选择模式'); try { await this.startImagePickerMode(); return; } catch (fallbackError) { console.error('图片选择模式也失败:', fallbackError); } } const errorMsg = 'scanCode:fail ' + error.message; if (options.fail) { options.fail({ errMsg: errorMsg }); } if (options.complete) { options.complete({ errMsg: errorMsg }); } } finally { this.isInitializing = false; } } /** * 创建扫码UI(异步,非阻塞) */ async createScannerUI(options) { return new Promise((resolve, reject) => { try { // 使用 setTimeout 确保不阻塞主线程 setTimeout(() => { try { const uiOptions = { scanFrameColor: options.scanFrameColor || '#00ff00' }; const canvasElement = this.uiManager.createScannerUI(uiOptions); // 根据参数配置UI this.configureUI(options); resolve(canvasElement); } catch (error) { reject(error); } }, 10); } catch (error) { reject(error); } }); } /** * 检查并初始化摄像头(异步,带超时) */ async checkAndInitCamera(options, timeout = 5000) { return new Promise(async (resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error('摄像头初始化超时')); }, timeout); try { // 检查摄像头可用性(快速检查) const cameraSupported = await this.checkCameraAvailability(); if (!cameraSupported) { clearTimeout(timeoutId); reject(new Error('摄像头不可用')); return; } // 等待解码器就绪(带超时) await this.waitForDecoderReady(2000); // 初始化摄像头 const cameraConfig = this.buildCameraConfig(options); // 创建临时canvas用于初始化 const tempCanvas = document.createElement('canvas'); await this.cameraScanner.init(tempCanvas, cameraConfig); clearTimeout(timeoutId); resolve(); } catch (error) { clearTimeout(timeoutId); reject(error); } }); } /** * 检查摄像头可用性(优化版,减少等待时间) */ async checkCameraAvailability() { try { // 快速检查基础API支持 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { return false; } // 使用Promise.race添加超时机制 const checkPromise = this.performCameraCheck(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('检查超时')), 3000) ); const result = await Promise.race([checkPromise, timeoutPromise]); return result; } catch (error) { console.log('摄像头检查失败:', error.message); return false; } } /** * 执行摄像头检查 */ async performCameraCheck() { try { // 检查设备列表 const devices = await navigator.mediaDevices.enumerateDevices(); const hasCamera = devices.some(device => device.kind === 'videoinput'); if (!hasCamera) { return false; } // 尝试获取摄像头权限(快速测试) const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 640 }, height: { ideal: 480 } } }); // 立即关闭流 stream.getTracks().forEach(track => track.stop()); return true; } catch (error) { console.log('摄像头权限或可用性检查失败:', error.message); return false; } } /** * 启动图片选择模式(优化版) */ async startImagePickerMode() { try { console.log('启动图片选择模式'); // 确保解码器就绪 await this.waitForDecoderReady(3000); // 使用 Promise 包装文件选择,避免阻塞 return new Promise((resolve, reject) => { // 使用 setTimeout 确保不阻塞主线程 setTimeout(() => { try { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.capture = 'environment'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); const cleanup = () => { if (document.body.contains(fileInput)) { document.body.removeChild(fileInput); } }; fileInput.addEventListener('change', async (event) => { const file = event.target.files[0]; cleanup(); if (file) { try { await this.processSelectedImage(file); resolve(); } catch (error) { reject(error); } } else { this.handleScanCancel(); resolve(); } }); // 添加取消处理 const handleCancel = () => { cleanup(); this.handleScanCancel(); resolve(); }; // 监听窗口焦点,检测用户是否取消了文件选择 let focusHandler; const setupCancelDetection = () => { focusHandler = () => { setTimeout(() => { if (!fileInput.files.length) { window.removeEventListener('focus', focusHandler); handleCancel(); } }, 300); }; window.addEventListener('focus', focusHandler); }; setupCancelDetection(); fileInput.click(); } catch (error) { reject(error); } }, 10); }); } catch (error) { console.error('启动图片选择模式失败:', error); throw error; } } /** * 处理选择的图片(优化版) */ async processSelectedImage(file) { return new Promise((resolve, reject) => { try { console.log('开始处理选择的图片:', file.name); const img = new Image(); const reader = new FileReader(); // 设置超时 const timeoutId = setTimeout(() => { reject(new Error('图片处理超时')); }, 10000); const cleanup = () => { clearTimeout(timeoutId); }; img.onload = async () => { cleanup(); try { // 使用 setTimeout 避免阻塞 setTimeout(async () => { try { const scanTypes = this.currentOptions?.scanType || []; const result = await this.qrCodeDecoder.decode(img, scanTypes); if (result) { console.log('图片解码成功:', result.text); this.handleScanSuccess(result, 'album'); resolve(result); } else { const error = '图片中未找到指定类型的条码'; this.handleScanError(error); reject(new Error(error)); } } catch (error) { this.handleScanError('图片解码失败: ' + error.message); reject(error); } }, 10); } catch (error) { reject(error); } }; img.onerror = () => { cleanup(); const error = '图片加载失败'; this.handleScanError(error); reject(new Error(error)); }; reader.onload = (e) => { img.src = e.target.result; }; reader.onerror = () => { cleanup(); const error = '文件读取失败'; this.handleScanError(error); reject(new Error(error)); }; reader.readAsDataURL(file); } catch (error) { reject(error); } }); } /** * 处理扫码取消 */ handleScanCancel() { this.cleanup(); if (this.currentOptions) { const errorMsg = 'scanCode:fail cancel'; if (this.currentOptions.fail) { this.currentOptions.fail({ errMsg: errorMsg }); } if (this.currentOptions.complete) { this.currentOptions.complete({ errMsg: errorMsg }); } } } /** * 清理资源和状态 */ cleanup() { this.isScanning = false; this.isInitializing = false; this.scanLoopActive = false; this.isDecoding = false; // 重置解码状态 if (this.scanAnimationId) { cancelAnimationFrame(this.scanAnimationId); this.scanAnimationId = null; } } /** * 判断是否为摄像头相关错误 */ isCameraError(error) { const errorMessage = error.message || error.toString(); const cameraErrorKeywords = [ 'camera', 'getUserMedia', 'permission', 'NotAllowedError', 'NotFoundError', 'NotReadableError', 'OverconstrainedError', 'AbortError', '摄像头', '权限', '超时' ]; return cameraErrorKeywords.some(keyword => errorMessage.toLowerCase().includes(keyword.toLowerCase()) ); } /** * 根据参数配置UI */ configureUI(options) { // 使用 setTimeout 避免阻塞 setTimeout(() => { const { onlyFromCamera } = options; if (onlyFromCamera) { const albumBtn = document.querySelector('.jz-scanner-select'); if (albumBtn) { albumBtn.style.display = 'none'; } } }, 10); } /** * 构建摄像头配置 */ buildCameraConfig(options) { return { width: 1280, height: 720, facingMode: 'environment' }; } /** * 绑定UI事件 */ bindUIEvents() { document.addEventListener('jz-scanner-close', this.boundHandleUIClose); document.addEventListener('jz-scanner-file-selected', this.boundHandleFileSelected); console.log('UI事件已绑定'); } /** * 解绑UI事件 */ unbindUIEvents() { document.removeEventListener('jz-scanner-close', this.boundHandleUIClose); document.removeEventListener('jz-scanner-file-selected', this.boundHandleFileSelected); console.log('UI事件已解绑'); } /** * 等待解码器初始化完成(优化版) */ async waitForDecoderReady(timeout = 3000) { const startTime = Date.now(); while (!(await this.qrCodeDecoder.isReady())) { if (Date.now() - startTime > timeout) { throw new Error('解码器初始化超时'); } // 使用更短的等待间隔,但通过 requestAnimationFrame 优化 await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 50))); } console.log('解码器已就绪:', this.qrCodeDecoder.getDecoderType()); } /** * 停止扫码 */ async stopScan() { try { this.cleanup(); await this.cameraScanner.stop(); this.uiManager.removeScannerUI(); this.unbindUIEvents(); console.log('扫码已停止'); } catch (error) { console.error('停止扫码失败:', error); } } /** * 处理UI关闭事件 */ handleUIClose() { console.log('处理UI关闭事件'); if (this.isScanning || this.isInitializing) { this.stopScan(); if (this.currentOptions) { const errorMsg = 'scanCode:fail cancel'; if (this.currentOptions.fail) { this.currentOptions.fail({ errMsg: errorMsg }); } if (this.currentOptions.complete) { this.currentOptions.complete({ errMsg: errorMsg }); } } } else { this.uiManager.removeScannerUI(); this.unbindUIEvents(); } } /** * 处理选择图片事件 */ async handleFileSelected(event) { const file = event.detail.file; if (!file || !this.qrCodeDecoder) { return; } try { console.log('开始扫描图片文件:', file.name); // 异步处理,避免阻塞 setTimeout(async () => { const img = new Image(); img.onload = async () => { try { const scanTypes = this.currentOptions?.scanType || []; const result = await this.qrCodeDecoder.decode(img, scanTypes); if (result) { console.log('UI图片扫码成功:', result.text); this.handleScanSuccess(result, 'album'); } else { this.uiManager.showErrorMessage('图片中未找到指定类型的条码'); } } catch (error) { console.error('图片扫码失败:', error); this.uiManager.showErrorMessage('图片扫码失败'); } }; img.onerror = () => { this.uiManager.showErrorMessage('图片加载失败'); }; const reader = new FileReader(); reader.onload = (e) => { img.src = e.target.result; }; reader.readAsDataURL(file); }, 10); } catch (error) { console.error('处理图片文件失败:', error); this.uiManager.showErrorMessage('处理图片文件失败'); } } /** * 扫码循环(优化版,防止卡顿) */ startScanLoop() { if (!this.isScanning || this.scanLoopActive) { return; } this.scanLoopActive = true; const scanFrame = async () => { if (!this.isScanning || !this.scanLoopActive) { this.scanLoopActive = false; return; } const now = Date.now(); // 控制扫码频率,避免过于频繁 if (now - this.lastScanTime < this.scanInterval) { this.scanAnimationId = requestAnimationFrame(scanFrame); return; } // 如果正在解码,跳过本次扫码 if (this.isDecoding) { this.scanAnimationId = requestAnimationFrame(scanFrame); return; } this.lastScanTime = now; try { const imageData = this.cameraScanner.getImageData(); if (imageData) { const scanTypes = this.currentOptions?.scanType || []; // 标记开始解码 this.isDecoding = true; // 使用 setTimeout 确保解码不阻塞渲染,并增加延迟 setTimeout(async () => { try { const result = await this.qrCodeDecoder.decode(imageData, scanTypes); if (result && this.isScanning) { console.log('摄像头扫码成功:', result); this.handleScanSuccess(result, 'camera'); return; } } catch (error) { // 忽略解码错误,继续扫码 } finally { // 解码完成,重置标记 this.isDecoding = false; } }, 50); // 增加延迟到50ms } } catch (error) { console.warn('扫码过程中出错:', error); this.isDecoding = false; } // 继续下一帧 this.scanAnimationId = requestAnimationFrame(scanFrame); }; this.scanAnimationId = requestAnimationFrame(scanFrame); } /** * 处理扫码成功 */ handleScanSuccess(result, imageChannel = 'camera') { console.log('扫码成功:', result.text); // 立即停止扫码循环 this.cleanup(); // 震动反馈 if (navigator.vibrate) { navigator.vibrate(200); } // 构建返回数据 const scanResult = this.buildScanResult(result, imageChannel); // 异步执行清理和回调,避免阻塞 setTimeout(() => { this.stopScan(); if (this.currentOptions) { if (this.currentOptions.success) { this.currentOptions.success(scanResult); } if (this.currentOptions.complete) { this.currentOptions.complete(scanResult); } } }, 0); } /** * 构建扫码结果,与uni.scanCode返回格式一致 */ buildScanResult(result, imageChannel) { const scanResult = { result: result.text, scanType: result.scanType || this.detectScanType(result.text), charSet: 'UTF-8', errMsg: 'scanCode:ok' }; if (imageChannel) { scanResult.imageChannel = imageChannel; } if (result.format) { scanResult.format = result.format; } if (result.data) { try { scanResult.rawData = btoa(result.text); } catch (error) { scanResult.rawData = result.text; } } return scanResult; } /** * 检测扫码类型 */ detectScanType(content) { return 'qrCode'; } /** * 处理扫码错误 */ handleScanError(error) { console.error('扫码错误:', error); this.cleanup(); // 异步执行清理和回调 setTimeout(() => { this.stopScan(); if (this.currentOptions) { const errorMsg = 'scanCode:fail ' + error; if (this.currentOptions.fail) { this.currentOptions.fail({ errMsg: errorMsg }); } if (this.currentOptions.complete) { this.currentOptions.complete({ errMsg: errorMsg }); } } }, 0); } /** * 检查是否支持摄像头 */ static async isCameraSupported() { try { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { return false; } const devices = await navigator.mediaDevices.enumerateDevices(); return devices.some(device => device.kind === 'videoinput'); } catch (error) { return false; } } /** * 获取可用摄像头列表 */ static async getCameras() { return await CameraScanner.getCameras(); } /** * 检查是否支持原生条码检测 */ static isBarcodeDetectorSupported() { return QRCodeDecoder.isBarcodeDetectorSupported(); } /** * 获取支持的条码格式 */ static async getSupportedFormats() { return await QRCodeDecoder.getSupportedFormats(); } /** * 获取插件信息 */ static getPluginInfo() { return { name: 'jz-h5-scanCode', version: '1.0.0', description: 'H5环境下的二维码扫描插件,与uni.scanCode API兼容', author: 'JZ', cameraSupported: navigator.mediaDevices && navigator.mediaDevices.getUserMedia, barcodeDetectorSupported: 'BarcodeDetector' in window, platforms: { h5: '自定义扫码实现', app: 'uni.scanCode', 'mp-weixin': 'uni.scanCode', 'mp-alipay': 'uni.scanCode', 'mp-baidu': 'uni.scanCode' } }; } } // 创建单例实例 const jzH5ScanCode = new JzH5ScanCode(); // 导出与uni.scanCode兼容的接口 export default { scanCode: (options) => jzH5ScanCode.scanCode(options), // 静态方法 isCameraSupported: JzH5ScanCode.isCameraSupported, getCameras: JzH5ScanCode.getCameras, isBarcodeDetectorSupported: JzH5ScanCode.isBarcodeDetectorSupported, getSupportedFormats: JzH5ScanCode.getSupportedFormats, getPluginInfo: JzH5ScanCode.getPluginInfo, // 便捷方法:直接扫描图片 scanFromImage: (options = {}) => { return jzH5ScanCode.scanCode({ ...options, onlyFromCamera: false, }); }, // 便捷方法:检查设备能力并选择最佳扫码模式 smartScan: async (options = {}) => { const cameraSupported = await JzH5ScanCode.isCameraSupported(); console.log('设备摄像头支持:', cameraSupported); if (!cameraSupported) { console.log('摄像头不可用,使用图片选择模式'); return jzH5ScanCode.scanCode({ ...options, onlyFromCamera: false, }); } else { console.log('摄像头可用,使用标准扫码模式'); return jzH5ScanCode.scanCode(options); } }, // 兼容旧版本的方法名 startScan: (options) => { console.warn('startScan方法已废弃,请使用scanCode方法'); return jzH5ScanCode.scanCode({ success: options.onSuccess, fail: options.onError, ...options }); } };