jz-h5-scancode
Version:
H5环境下的二维码扫描插件,提供与 uni.scanCode 完全兼容的 API 接口,支持摄像头实时扫码和图片扫码。v1.1.0优化性能,修复卡死问题
952 lines (814 loc) • 32.6 kB
JavaScript
/**
* 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
});
}
};