6.2 结合Service Worker实现离线文件处理
Service Worker使Web应用能够在离线状态下运行,结合File/Blob API可以创建功能完善的离线文件处理系统:
// 离线文件处理器(Service Worker端)
class OfflineFileProcessor {
constructor() {
this.cacheName = 'file-processor-v1';
this.supportedFormats = {
'image/jpeg': true,
'image/png': true,
'image/webp': true,
'application/pdf': true
};
this.setupEventListeners();
}
setupEventListeners() {
// 安装Service Worker
self.addEventListener('install', (event) => {
event.waitUntil(this.install());
});
// 激活Service Worker
self.addEventListener('activate', (event) => {
event.waitUntil(this.activate());
});
// 拦截网络请求
self.addEventListener('fetch', (event) => {
event.respondWith(this.handleFetch(event.request));
});
// 监听消息事件
self.addEventListener('message', (event) => {
this.handleMessage(event);
});
}
async install() {
console.log('OfflineFileProcessor installing...');
// 缓存核心资源
const cache = await caches.open(this.cacheName);
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline-icon.svg'
]);
}
async activate() {
console.log('OfflineFileProcessor activating...');
// 清理旧缓存
const cacheNames = await caches.keys();
for (const name of cacheNames) {
if (name !== this.cacheName) {
await caches.delete(name);
}
}
// 激活后立即控制所有客户端
return self.clients.claim();
}
async handleFetch(request) {
// 处理文件处理请求
if (request.url.includes('/process-file')) {
return this.handleFileProcessingRequest(request);
}
// 常规请求使用网络优先策略
try {
const response = await fetch(request);
// 缓存成功的GET请求
if (request.method === 'GET' && response.status === 200) {
const cache = await caches.open(this.cacheName);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// 网络失败时使用缓存
const cachedResponse = await caches.match(request);
return cachedResponse || this.getOfflineResponse(request);
}
}
async handleFileProcessingRequest(request) {
const formData = await request.formData();
const file = formData.get('file');
const operation = formData.get('operation');
if (!file || !operation) {
return new Response(JSON.stringify({ error: '缺少文件或操作参数' }), {
headers: { 'Content-Type': 'application/json' },
status: 400
});
}
try {
// 读取文件内容
const arrayBuffer = await file.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: file.type });
// 根据操作类型处理文件
let processedBlob, outputType;
switch (operation) {
case 'compress-image':
// 图片压缩处理
const quality = parseFloat(formData.get('quality') || '0.8');
[processedBlob, outputType] = await this.compressImage(blob, file.type, quality);
break;
case 'convert-to-webp':
// 转换为WebP格式
[processedBlob, outputType] = await this.convertToWebp(blob);
break;
case 'pdf-to-images':
// PDF转图片(需要PDF.js库支持)
return this.pdfToImages(blob, formData);
case 'resize-image':
// 图片尺寸调整
const width = parseInt(formData.get('width') || '0');
const height = parseInt(formData.get('height') || '0');
[processedBlob, outputType] = await this.resizeImage(blob, file.type, width, height);
break;
default:
return new Response(JSON.stringify({ error: `不支持的操作: ${operation}` }), {
headers: { 'Content-Type': 'application/json' },
status: 400
});
}
// 创建处理结果响应
const fileName = file.name.replace(/\.[^/.]+$/, "") + `.${outputType.split('/')[1] || 'bin'}`;
return new Response(processedBlob, {
headers: {
'Content-Type': outputType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`
}
});
} catch (error) {
console.error('文件处理失败:', error);
return new Response(JSON.stringify({ error: `文件处理失败: ${error.message}` }), {
headers: { 'Content-Type': 'application/json' },
status: 500
});
}
}
// 图片压缩
async compressImage(blob, mimeType, quality = 0.8) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
// 创建画布
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 设置画布尺寸(保持原始比例)
canvas.width = img.width;
canvas.height = img.height;
// 绘制图片
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 转换为压缩后的Blob
canvas.toBlob(
(compressedBlob) => {
if (!compressedBlob) {
reject(new Error('图片压缩失败'));
return;
}
resolve([compressedBlob, mimeType]);
},
mimeType,
quality
);
};
img.onerror = () => reject(new Error('无法加载图片'));
img.src = URL.createObjectURL(blob);
});
}
// 转换为WebP格式
async convertToWebp(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 转换为WebP
canvas.toBlob(
(webpBlob) => {
if (!webpBlob) {
reject(new Error('转换为WebP失败'));
return;
}
resolve([webpBlob, 'image/webp']);
},
'image/webp',
0.85 // WebP压缩质量
);
};
img.onerror = () => reject(new Error('无法加载图片'));
img.src = URL.createObjectURL(blob);
});
}
// 调整图片尺寸
async resizeImage(blob, mimeType, targetWidth, targetHeight) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
// 计算调整后的尺寸(保持比例)
let width = img.width;
let height = img.height;
// 如果只指定了一个维度,计算另一个维度
if (targetWidth && !targetHeight) {
const ratio = targetWidth / width;
height *= ratio;
width = targetWidth;
} else if (targetHeight && !targetWidth) {
const ratio = targetHeight / height;
width *= ratio;
height = targetHeight;
} else if (targetWidth && targetHeight) {
// 同时指定了宽高,直接使用
width = targetWidth;
height = targetHeight;
}
// 创建画布并绘制调整后的图片
const canvas = document.createElement('canvas');
canvas.width = Math.round(width);
canvas.height = Math.round(height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 转换为Blob
canvas.toBlob(
(resizedBlob) => {
if (!resizedBlob) {
reject(new Error('调整图片尺寸失败'));
return;
}
resolve([resizedBlob, mimeType]);
},
mimeType,
0.9 // 质量
);
};
img.onerror = () => reject(new Error('无法加载图片'));
img.src = URL.createObjectURL(blob);
});
}
// PDF转图片(需要引入PDF.js库)
async pdfToImages(blob, formData) {
// 检查是否加载了PDF.js
if (typeof pdfjsLib === 'undefined') {
return new Response(JSON.stringify({
error: 'PDF处理功能未加载,请引入PDF.js库'
}), {
headers: { 'Content-Type': 'application/json' },
status: 500
});
}
try {
// 读取PDF数据
const data = await blob.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data });
const pdf = await loadingTask.promise;
const pageNumbers = formData.get('pages') || 'all';
const scale = parseFloat(formData.get('scale') || '1.5');
const outputFormat = formData.get('format') || 'image/png';
// 准备响应数据
const images = [];
// 确定要处理的页面范围
let startPage = 1;
let endPage = pdf.numPages;
if (pageNumbers !== 'all' && pageNumbers.includes('-')) {
const [start, end] = pageNumbers.split('-').map(Number);
startPage = Math.max(1, start);
endPage = Math.min(pdf.numPages, end);
} else if (pageNumbers !== 'all') {
startPage = endPage = Math.min(Math.max(1, parseInt(pageNumbers)), pdf.numPages);
}
// 处理每一页
for (let pageNum = startPage; pageNum <= endPage; pageNum++) {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale });
// 创建画布
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
// 渲染PDF页面到画布
const renderContext = {
canvasContext: context,
viewport: viewport
};
await page.render(renderContext).promise;
// 转换为Blob
const pageBlob = await new Promise(resolve =>
canvas.toBlob(resolve, outputFormat, 0.9)
);
images.push({
page: pageNum,
blob: pageBlob,
type: outputFormat
});
}
// 如果只有一页,直接返回图片
if (images.length === 1) {
const { blob, type } = images[0];
return new Response(blob, {
headers: {
'Content-Type': type,
'Content-Disposition': 'attachment; filename="page-1.png"'
}
});
}
// 多页PDF返回ZIP文件(需要JSZip库支持)
if (typeof JSZip === 'undefined') {
return new Response(JSON.stringify({
error: '多页PDF处理需要JSZip库支持'
}), {
headers: { 'Content-Type': 'application/json' },
status: 500
});
}
// 创建ZIP文件
const zip = new JSZip();
const imageFolder = zip.folder('pdf-images');
for (const { page, blob, type } of images) {
const ext = type.split('/')[1];
imageFolder.file(`page-${page}.${ext}`, blob);
}
// 生成ZIP Blob
const zipBlob = await zip.generateAsync({ type: 'blob' });
return new Response(zipBlob, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="pdf-images.zip"'
}
});
} catch (error) {
console.error('PDF处理失败:', error);
return new Response(JSON.stringify({ error: `PDF处理失败: ${error.message}` }), {
headers: { 'Content-Type': 'application/json' },
status: 500
});
}
}
// 获取离线响应
getOfflineResponse(request) {
// 根据请求类型返回不同的离线响应
if (request.mode === 'navigate') {
return caches.match('/index.html');
}
return new Response(JSON.stringify({
error: '离线模式下无法完成请求'
}), {
headers: { 'Content-Type': 'application/json' },
status: 503
});
}
// 处理来自客户端的消息
async handleMessage(event) {
const { type, data } = event.data;
switch (type) {
case 'cache-files':
// 缓存指定文件
await this.cacheAdditionalFiles(data.files);
event.source.postMessage({
type: 'cache-complete',
success: true
}, '*');
break;
case 'clear-cache':
// 清除缓存
await this.clearCache();
event.source.postMessage({
type: 'cache-cleared',
success: true
}, '*');
break;
case 'list-cached-files':
// 列出缓存文件
const files = await this.listCachedFiles();
event.source.postMessage({
type: 'cached-files',
files
}, '*');
break;
}
}
// 缓存额外文件
async cacheAdditionalFiles(fileUrls) {
if (!Array.isArray(fileUrls) || fileUrls.length === 0) return;
const cache = await caches.open(this.cacheName);
const requests = fileUrls.map(url => new Request(url));
// 只缓存不存在的文件
for (const request of requests) {
const cached = await caches.match(request);
if (!cached) {
try {
const response = await fetch(request);
if (response.status === 200) {
await cache.put(request, response);
}
} catch (error) {
console.warn(`缓存文件失败: ${request.url}`, error);
}
}
}
}
// 清除缓存
async clearCache() {
const cache = await caches.open(this.cacheName);
const keys = await cache.keys();
await Promise.all(keys.map(key => cache.delete(key)));
}
// 列出缓存文件
async listCachedFiles() {
const cache = await caches.open(this.cacheName);
const keys = await cache.keys();
return keys.map(key => key.url);
}
// 获取离线响应
getOfflineResponse(request) {
// 返回离线页面或资源
if (request.headers.get('Accept').includes('text/html')) {
return caches.match('/offline.html') || new Response(
'<!DOCTYPE html><html><body><h1>离线模式</h1><p>无法连接到网络,请检查您的连接。</p></body></html>',
{ headers: { 'Content-Type': 'text/html' } }
);
}
return new Response(JSON.stringify({ offline: true }), {
headers: { 'Content-Type': 'application/json' }
});
}
}
// 初始化离线文件处理器
if ('serviceWorker' in self) {
const processor = new OfflineFileProcessor();
}
6.3 结合WebCodecs API实现高级媒体处理
WebCodecs API提供了对音频和视频编码/解码的直接访问,结合File/Blob API可以构建强大的客户端媒体处理应用:
// 基于WebCodecs的媒体文件处理器
class MediaFileProcessor {
constructor() {
this.supported = this.checkSupport();
this.codecs = {
video: {
'avc1.42001E': 'H.264 Baseline',
'avc1.4D401E': 'H.264 Main',
'avc1.64001F': 'H.264 High',
'vp09.00.10.08': 'VP9',
'av01.0.05M.08': 'AV1'
},
audio: {
'mp4a.40.2': 'AAC LC',
'mp4a.40.5': 'AAC HE',
'opus': 'Opus'
}
};
}
// 检查浏览器支持情况
checkSupport() {
return 'VideoDecoder' in window &&
'VideoEncoder' in window &&
'AudioDecoder' in window &&
'AudioEncoder' in window &&
'MediaSource' in window;
}
// 解码视频文件
async decodeVideo(blob, progressCallback) {
if (!this.supported) {
throw new Error('您的浏览器不支持WebCodecs API');
}
const start = performance.now();
const tracks = [];
let videoTrack, audioTrack;
try {
// 创建媒体源
const mediaSource = new MediaSource();
const url = URL.createObjectURL(blob);
// 使用VideoFrame和AudioData解码
const demuxer = await this.demuxFile(blob);
// 检查是否有视频轨道
if (demuxer.videoTracks.length > 0) {
videoTrack = demuxer.videoTracks[0];
tracks.push(...await this.decodeVideoTrack(videoTrack, progressCallback));
}
// 检查是否有音频轨道
if (demuxer.audioTracks.length > 0) {
audioTrack = demuxer.audioTracks[0];
tracks.push(...await this.decodeAudioTrack(audioTrack, progressCallback));
}
URL.revokeObjectURL(url);
const duration = (performance.now() - start) / 1000;
console.log(`媒体解码完成,耗时: ${duration.toFixed(2)}s`);
return {
tracks,
duration,
videoTrack,
audioTrack
};
} catch (error) {
console.error('媒体解码失败:', error);
throw error;
}
}
// 解复用文件(简化实现,实际应用需使用更完善的解复用库)
async demuxFile(blob) {
// 在实际应用中,这里会使用如mp4box.js等库进行文件解复用
// 这里返回模拟的轨道信息
return {
videoTracks: [{
type: 'video',
codec: 'avc1.42001E',
width: 1920,
height: 1080,
duration: 10, // 秒
frames: []
}],
audioTracks: [{
type: 'audio',
codec: 'mp4a.40.2',
sampleRate: 44100,
channels: 2,
duration: 10 // 秒
}]
};
}
// 解码视频轨道
async decodeVideoTrack(track, progressCallback) {
const frames = [];
const decoderConfig = {
codec: track.codec,
width: track.width,
height: track.height,
hardwareAcceleration: 'prefer-software',
optimizeForLatency: false
};
// 创建视频解码器
const decoder = new VideoDecoder({
output: (frame) => {
frames.push(frame);
progressCallback && progressCallback({
type: 'video-decode',
progress: (frames.length / track.frames.length) * 100,
framesDecoded: frames.length,
totalFrames: track.frames.length
});
},
error: (error) => {
console.error('视频解码错误:', error);
throw error;
}
});
// 配置解码器
await decoder.configure(decoderConfig);
// 处理视频块(简化实现)
for (let i = 0; i < track.frames.length; i++) {
const chunk = track.frames[i];
// 将视频块送入解码器
decoder.decode(new EncodedVideoChunk({
type: i === 0 ? 'key' : 'delta',
data: chunk.data,
timestamp: chunk.timestamp,
duration: chunk.duration
}));
// 防止解码器缓冲区溢出
if (i % 10 === 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
// 完成解码并等待所有帧输出
await decoder.flush();
decoder.close();
return [{
type: 'video',
codec: track.codec,
codecName: this.codecs.video[track.codec] || track.codec,
width: track.width,
height: track.height,
frames,
duration: track.duration
}];
}
// 解码音频轨道
async decodeAudioTrack(track, progressCallback) {
const samples = [];
const decoderConfig = {
codec: track.codec,
sampleRate: track.sampleRate,
numberOfChannels: track.channels
};
// 创建音频解码器
const decoder = new AudioDecoder({
output: (data) => {
samples.push(data);
progressCallback && progressCallback({
type: 'audio-decode',
progress: (samples.length / track.samples.length) * 100,
samplesDecoded: samples.length,
totalSamples: track.samples.length
});
},
error: (error) => {
console.error('音频解码错误:', error);
throw error;
}
});
// 配置解码器
await decoder.configure(decoderConfig);
// 处理音频块
for (let i = 0; i < track.samples.length; i++) {
const chunk = track.samples[i];
decoder.decode(new EncodedAudioChunk({
type: i === 0 ? 'key' : 'delta',
data: chunk.data,
timestamp: chunk.timestamp,
duration: chunk.duration
}));
}
// 完成解码
await decoder.flush();
decoder.close();
return [{
type: 'audio',
codec: track.codec,
codecName: this.codecs.audio[track.codec] || track.codec,
sampleRate: track.sampleRate,
channels: track.channels,
samples,
duration: track.duration
}];
}
// 编码视频文件
async encodeVideo(frames, config, progressCallback) {
if (!this.supported) {
throw new Error('您的浏览器不支持WebCodecs API');
}
const encodedChunks = [];
const startTime = performance.now();
// 默认编码配置
const encodeConfig = {
codec: config.codec || 'avc1.42001E', // H.264 Baseline
width: config.width,
height: config.height,
bitrate: config.bitrate || 5_000_000, // 5 Mbps
framerate: config.framerate || 30,
hardwareAcceleration: config.hardwareAcceleration || 'prefer-hardware',
latencyMode: 'quality'
};
// 创建视频编码器
const encoder = new VideoEncoder({
output: (chunk) => {
encodedChunks.push(chunk);
progressCallback && progressCallback({
type: 'video-encode',
progress: (encodedChunks.length / frames.length) * 100,
chunksEncoded: encodedChunks.length,
totalFrames: frames.length
});
},
error: (error) => {
console.error('视频编码错误:', error);
throw error;
}
});
// 配置编码器
await encoder.configure(encodeConfig);
// 编码每一帧
const frameDuration = Math.floor(1_000_000 / encodeConfig.framerate); // 微秒
let timestamp = 0;
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
// 调整帧大小以匹配编码配置
const resizedFrame = this.resizeVideoFrame(frame, encodeConfig.width, encodeConfig.height);
// 设置时间戳
resizedFrame.timestamp = timestamp;
timestamp += frameDuration;
// 编码帧
encoder.encode(resizedFrame);
// 释放原始帧
frame.close();
resizedFrame.close();
// 防止编码器缓冲区溢出
if (i % 10 === 0) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
// 完成编码
await encoder.flush();
encoder.close();
// 计算编码统计信息
const duration = (performance.now() - startTime) / 1000;
const totalSize = encodedChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const bitrate = Math.round((totalSize * 8) / duration / 1000); // kbps
// 将编码后的块转换为Blob
const data = new Uint8Array(totalSize);
let offset = 0;
encodedChunks.forEach(chunk => {
data.set(new Uint8Array(chunk.data), offset);
offset += chunk.data.byteLength;
});
return {
blob: new Blob([data], { type: 'video/mp4' }),
duration,
size: totalSize,
bitrate,
codec: encodeConfig.codec,
codecName: this.codecs.video[encodeConfig.codec] || encodeConfig.codec,
width: encodeConfig.width,
height: encodeConfig.height
};
}
// 调整视频帧大小
resizeVideoFrame(frame, targetWidth, targetHeight) {
// 如果尺寸已经匹配,直接返回
if (frame.width === targetWidth && frame.height === targetHeight) {
return frame;
}
// 创建调整大小的画布
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
const ctx = canvas.getContext('2d');
// 绘制并缩放原始帧
ctx.drawImage(frame, 0, 0, targetWidth, targetHeight);
// 从画布创建新的视频帧
return new VideoFrame(canvas, {
timestamp: frame.timestamp,
duration: frame.duration
});
}
// 从视频帧创建GIF动画
async createGifFromFrames(frames, options = {}) {
const {
delay = 100, // 每帧延迟(毫秒)
loop = 0, // 0 = 无限循环
width = frames[0].width,
height = frames[0].height,
quality = 10 // 1-20,越低质量越高
} = options;
// 检查是否有GIF编码器库(如gif.js)
if (typeof GIFEncoder === 'undefined') {
throw new Error('GIF编码需要gif.js库支持');
}
// 创建GIF编码器
const encoder = new GIFEncoder();
const chunks = [];
// 收集编码后的GIF数据
encoder.on('data', (chunk) => chunks.push(chunk));
// 配置编码器
encoder.setRepeat(loop);
encoder.setDelay(delay);
encoder.setQuality(quality);
encoder.setSize(width, height);
encoder.start();
// 将视频帧添加到GIF
for (let i = 0; i < frames.length; i += Math.max(1, Math.floor(frames.length / 50))) {
const frame = frames[i];
// 将VideoFrame转换为ImageBitmap
const imageBitmap = await createImageBitmap(frame);
// 创建临时画布绘制帧
const canvas = new OffscreenCanvas(frame.width, frame.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(imageBitmap, 0, 0);
// 调整大小并添加到GIF
const resizedCanvas = new OffscreenCanvas(width, height);
const resizedCtx = resizedCanvas.getContext('2d');
resizedCtx.drawImage(canvas, 0, 0, width, height);
// 将像素数据添加到GIF编码器
const imageData = resizedCtx.getImageData(0, 0, width, height);
encoder.addFrame(imageData.data, true);
// 释放资源
frame.close();
imageBitmap.close();
}
// 完成GIF编码
encoder.finish();
// 创建Blob
const blob = new Blob(chunks, { type: 'image/gif' });
return {
blob,
width,
height,
frameCount: chunks.length,
size: blob.size
};
}
}
// WebCodecs媒体处理器应用示例
class MediaEditor {
constructor() {
this.processor = new MediaFileProcessor();
this.mediaData = null;
this.supported = this.processor.supported;
}
// 加载媒体文件
async loadFile(file, progressCallback) {
if (!this.supported) {
throw new Error('您的浏览器不支持WebCodecs API');
}
this.mediaData = await this.processor.decodeVideo(file, progressCallback);
return this.mediaData;
}
// 视频转GIF
async convertToGif(options = {}, progressCallback) {
if (!this.mediaData || !this.mediaData.tracks || this.mediaData.tracks.length === 0) {
throw new Error('没有加载媒体文件');
}
// 找到视频轨道
const videoTrack = this.mediaData.tracks.find(track => track.type === 'video');
if (!videoTrack) {
throw new Error('媒体文件中没有视频轨道');
}
progressCallback && progressCallback({
type: 'gif-conversion',
status: 'started',
message: '开始视频转GIF处理'
});
// 创建GIF
const gifResult = await this.processor.createGifFromFrames(
videoTrack.frames,
options,
(progress) => {
progressCallback && progressCallback({
type: 'gif-conversion',
...progress
});
}
);
progressCallback && progressCallback({
type: 'gif-conversion',
status: 'complete',
message: 'GIF转换完成',
result: gifResult
});
return gifResult;
}
// 压缩视频文件
async compressVideo(options = {}, progressCallback) {
if (!this.mediaData || !this.mediaData.tracks || this.mediaData.tracks.length === 0) {
throw new Error('没有加载媒体文件');
}
// 找到视频轨道
const videoTrack = this.mediaData.tracks.find(track => track.type === 'video');
if (!videoTrack) {
throw new Error('媒体文件中没有视频轨道');
}
progressCallback && progressCallback({
type: 'video-compression',
status: 'started',
message: '开始视频压缩处理'
});
// 编码压缩后的视频
const compressedVideo = await this.processor.encodeVideo(
videoTrack.frames,
options,
(progress) => {
progressCallback && progressCallback({
type: 'video-compression',
...progress
});
}
);
progressCallback && progressCallback({
type: 'video-compression',
status: 'complete',
message: '视频压缩完成',
result: compressedVideo
});
return compressedVideo;
}
// 提取视频帧
extractFrames(count = 10, progressCallback) {
if (!this.mediaData || !this.mediaData.tracks || this.mediaData.tracks.length === 0) {
throw new Error('没有加载媒体文件');
}
// 找到视频轨道
const videoTrack = this.mediaData.tracks.find(track => track.type === 'video');
if (!videoTrack) {
throw new Error('媒体文件中没有视频轨道');
}
const totalFrames = videoTrack.frames.length;
const step = Math.max(1, Math.floor(totalFrames / count));
const extractedFrames = [];
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = videoTrack.width;
canvas.height = videoTrack.height;
// 提取帧
for (let i = 0; i < totalFrames && extractedFrames.length < count; i += step) {
const frame = videoTrack.frames[i];
// 绘制帧到画布
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
// 转换为Blob
const blob = await new Promise(resolve => {
canvas.toBlob(resolve, 'image/png');
});
extractedFrames.push({
index: i,
timestamp: frame.timestamp,
blob,
width: canvas.width,
height: canvas.height
});
progressCallback && progressCallback({
type: 'frame-extraction',
progress: (extractedFrames.length / count) * 100,
extracted: extractedFrames.length,
total: count,
currentFrame: i
});
}
return extractedFrames;
}
// 释放资源
dispose() {
if (this.mediaData && this.mediaData.tracks) {
// 释放所有帧资源
this.mediaData.tracks.forEach(track => {
if (track.frames && track.frames.length > 0) {
track.frames.forEach(frame => {
try {
frame.close();
} catch (error) {
console.warn('释放帧资源失败:', error);
}
});
track.frames = [];
}
});
}
this.mediaData = null;
}
}
七、性能优化与最佳实践
7.1 大文件处理的内存管理策略
处理大型File和Blob对象时,内存管理至关重要。不当的处理方式可能导致浏览器崩溃或严重的性能问题:
// 大文件处理内存管理器
class LargeFileMemoryManager {
constructor() {
this.activeObjects = new Map();
this.memoryLimit = this.calculateMemoryLimit();
this.memoryUsage = 0;
this.cleanupInterval = setInterval(() => this.cleanupUnusedObjects(), 30000); // 每30秒清理一次
this.setupMemoryMonitoring();
}
// 计算内存限制(基于浏览器和系统)
calculateMemoryLimit() {
// 检测浏览器环境
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isChrome = /Chrome/i.test(navigator.userAgent) && !/Edge/i.test(navigator.userAgent);
// 根据环境设置不同的内存限制(MB)
if (isMobile) {
return 256; // 移动设备限制256MB
} else if (isChrome) {
return 1024; // Chrome限制1GB
} else {
return 512; // 其他浏览器限制512MB
}
}
// 设置内存监控
setupMemoryMonitoring() {
if (performance.memory) {
setInterval(() => {
const memory = performance.memory;
this.memoryUsage = memory.usedJSHeapSize / (1024 * 1024); // MB
// 内存使用超过限制的80%时触发警告
if (this.memoryUsage > this.memoryLimit * 0.8) {
console.warn(`内存使用警告: ${this.memoryUsage.toFixed(2)}MB / ${this.memoryLimit}MB`);
this.cleanupUnusedObjects(true); // 强制清理
}
}, 5000);
}
}
// 跟踪File/Blob对象
trackObject(id, object, metadata = {}) {
if (!(object instanceof Blob || object instanceof File)) {
throw new Error('只能跟踪Blob或File对象');
}
// 计算对象大小(近似值)
const size = object.size || object.byteLength;
// 如果添加此对象会超出内存限制,则先清理
if (this.memoryUsage + size / (1024 * 1024) > this.memoryLimit) {
console.warn('即将超出内存限制,尝试清理未使用对象');
this.cleanupUnusedObjects(true);
}
// 记录对象信息
this.activeObjects.set(id, {
object,
metadata,
size,
lastAccessed: Date.now(),
references: 1,
isPersistent: metadata.persistent || false
});
// 更新内存使用量
this.memoryUsage += size / (1024 * 1024);
return {
id,
release: () => this.releaseObject(id),
get: () => this.getObject(id)
};
}
// 获取跟踪的对象
getObject(id) {
const entry = this.activeObjects.get(id);
if (!entry) {
throw new Error(`未找到ID为 ${id} 的对象`);
}
// 更新最后访问时间
entry.lastAccessed = Date.now();
entry.references++;
return entry.object;
}
// 释放对象引用
releaseObject(id) {
const entry = this.activeObjects.get(id);
if (!entry) return false;
entry.references--;
console.log(`释放对象引用: ${id} (剩余引用: ${entry.references})`);
// 如果引用计数为0且不是持久对象,则可以清理
if (entry.references <= 0 && !entry.isPersistent) {
this.activeObjects.delete(id);
// 更新内存使用量
this.memoryUsage -= entry.size / (1024 * 1024);
console.log(`对象 ${id} 已从内存中清理 (大小: ${(entry.size / (1024 * 1024)).toFixed(2)}MB)`);
return true;
}
return false;
}
// 清理未使用的对象
cleanupUnusedObjects(force = false) {
const now = Date.now();
const threshold = force ? 0 : 60000; // 非强制清理时,清理60秒未使用的对象
let cleanedCount = 0;
let cleanedSize = 0;
for (const [id, entry] of this.activeObjects.entries()) {
// 跳过持久对象和最近使用的对象
if (entry.isPersistent) continue;
if (force || now - entry.lastAccessed > threshold) {
// 如果引用计数为0或强制清理
if (entry.references <= 0 || force) {
this.activeObjects.delete(id);
cleanedCount++;
cleanedSize += entry.size;
this.memoryUsage -= entry.size / (1024 * 1024);
}
}
}
if (cleanedCount > 0) {
console.log(`清理了 ${cleanedCount} 个未使用对象,释放了 ${(cleanedSize / (1024 * 1024)).toFixed(2)}MB 内存`);
}
return { cleanedCount, cleanedSize };
}
// 强制清理所有可清理对象
forceCleanup() {
return this.cleanupUnusedObjects(true);
}
// 持久化存储对象(不会被自动清理)
persistObject(id) {
const entry = this.activeObjects.get(id);
if (!entry) return false;
entry.isPersistent = true;
return true;
}
// 取消对象持久化
unpersistObject(id) {
const entry = this.activeObjects.get(id);
if (!entry) return false;
entry.isPersistent = false;
entry.lastAccessed = Date.now();
return true;
}
// 获取当前内存状态
getMemoryStatus() {
return {
usage: this.memoryUsage,
limit: this.memoryLimit,
usagePercentage: (this.memoryUsage / this.memoryLimit) * 100,
trackedObjects: this.activeObjects.size,
totalTrackedSize: Array.from(this.activeObjects.values())
.reduce((sum, entry) => sum + entry.size, 0) / (1024 * 1024)
};
}
// 销毁管理器
destroy() {
clearInterval(this.cleanupInterval);
this.activeObjects.clear();
this.memoryUsage = 0;
}
}
// 大文件分块处理工具
class LargeFileProcessor {
constructor(memoryManager) {
this.memoryManager = memoryManager || new LargeFileMemoryManager();
this.chunkSize = 64 * 1024; // 默认64KB块大小
this.maxParallelChunks = navigator.hardwareConcurrency || 4; // 基于CPU核心数的并行处理限制
}
// 设置块大小(根据文件类型和处理需求调整)
setChunkSize(size) {
if (size < 1024 || size > 10 * 1024 * 1024) {
throw new Error('块大小必须在1KB到10MB之间');
}
this.chunkSize = size;
return this;
}
// 处理大文件(带进度和取消支持)
async processFile(file, processor, options = {}) {
const {
progressCallback,
cancelSignal,
chunkSize = this.chunkSize,
parallel = this.maxParallelChunks,
metadata = {}
} = options;
// 跟踪文件处理
const fileId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const fileHandle = this.memoryManager.trackObject(fileId, file, {
...metadata,
type: 'source-file',
processing: true
});
try {
const totalSize = file.size;
const totalChunks = Math.ceil(totalSize / chunkSize);
const results = new Array(totalChunks);
let processedChunks = 0;
let startTime = Date.now();
let lastProgressTime = 0;
// 检查取消信号
if (cancelSignal && cancelSignal.aborted) {
throw new Error('处理已被取消');
}
// 创建取消处理函数
const onCancel = () => {
throw new Error('处理已被取消');
};
cancelSignal?.addEventListener('abort', onCancel);
// 更新进度的辅助函数
const updateProgress = (chunkIndex, chunkResult) => {
processedChunks++;
const progress = (processedChunks / totalChunks) * 100;
const now = Date.now();
const elapsed = now - startTime;
const speed = processedChunks * chunkSize / (elapsed / 1000); // B/s
// 限制进度更新频率(最多每秒10次)
if (now - lastProgressTime > 100 || progress >= 100) {
lastProgressTime = now;
progressCallback?.({
percent: progress,
processed: processedChunks,
total: totalChunks,
chunkIndex,
chunkResult,
speed,
estimatedTime: progress > 0 ? (elapsed / progress) * (100 - progress) : 0
});
}
};
// 创建块处理函数
const processChunk = async (chunkIndex) => {
// 检查取消信号
if (cancelSignal && cancelSignal.aborted) {
return;
}
try {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, totalSize);
const chunk = file.slice(start, end);
// 跟踪块对象
const chunkId = `${fileId}-chunk-${chunkIndex}`;
const chunkHandle = this.memoryManager.trackObject(chunkId, chunk, {
type: 'file-chunk',
fileId,
chunkIndex,
start,
end
});
// 处理块
const result = await processor({
chunk: chunkHandle.get(),
index: chunkIndex,
totalChunks,
start,
end,
file
});
// 存储结果并更新进度
results[chunkIndex] = result;
updateProgress(chunkIndex, result);
// 释放块资源
chunkHandle.release();
return result;
} catch (error) {
console.error(`处理块 ${chunkIndex} 失败:`, error);
// 如果是可恢复错误,尝试重试
if (options.maxRetries && error.retryable !== false) {
const retryCount = (error.retryCount || 0) + 1;
if (retryCount <= options.maxRetries) {
console.log(`重试处理块 ${chunkIndex} (第 ${retryCount} 次)`);
error.retryCount = retryCount;
return processChunk(chunkIndex); // 递归重试
}
}
// 传播错误
throw error;
}
};
// 创建块索引数组
const chunkIndices = Array.from({ length: totalChunks }, (_, i) => i);
// 并行处理块(控制并发数量)
const parallelBatches = [];
for (let i = 0; i < chunkIndices.length; i += parallel) {
parallelBatches.push(chunkIndices.slice(i, i + parallel));
}
// 按批次处理
for (const batch of parallelBatches) {
await Promise.all(batch.map(index => processChunk(index)));
}
// 处理完成,移除取消监听
cancelSignal?.removeEventListener('abort', onCancel);
// 整理结果
const finalResult = {
file,
totalSize,
totalChunks,
processedChunks,
duration: Date.now() - startTime,
results: results.filter(Boolean) // 过滤空结果
};
progressCallback?.({
percent: 100,
processed: processedChunks,
total: totalChunks,
complete: true,
result: finalResult
});
return finalResult;
} finally {
// 释放文件资源
fileHandle.release();
this.memoryManager.persistObject(fileId, false);
}
}
// 合并处理结果
async mergeResults(results, merger, options = {}) {
const {
progressCallback,
cancelSignal,
metadata = {}
} = options;
// 检查取消信号
if (cancelSignal && cancelSignal.aborted) {
throw new Error('合并已被取消');
}
const mergeId = `merge-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
let progress = 0;
const totalResults = results.length;
// 更新进度的辅助函数
const updateProgress = (step, message, data = {}) => {
progress = Math.min(100, (step / totalResults) * 100);
progressCallback?.({
percent: progress,
step,
total: totalResults,
message,
...data
});
};
try {
updateProgress(0, '开始合并结果');
// 执行合并器函数
const mergedResult = await merger(results, {
updateProgress: (step, message, data) => {
updateProgress(step, message, data);
},
cancelSignal
});
// 如果合并结果是Blob/File对象,进行跟踪
if (mergedResult instanceof Blob || mergedResult instanceof File) {
const resultHandle = this.memoryManager.trackObject(mergeId, mergedResult, {
...metadata,
type: 'merged-result',
timestamp: Date.now()
});
updateProgress(totalResults, '合并完成', {
resultId: mergeId,
size: mergedResult.size,
type: mergedResult.type
});
return {
...mergedResult,
manager: {
id: mergeId,
release: () => resultHandle.release(),
persist: () => this.memoryManager.persistObject(mergeId)
}
};
}
updateProgress(totalResults, '合并完成');
return mergedResult;
} catch (error) {
if (cancelSignal && cancelSignal.aborted) {
updateProgress(progress, '合并已取消');
} else {
updateProgress(progress, `合并失败: ${error.message}`, { error });
console.error('结果合并失败:', error);
}
throw error;
}
}
}
7.2 浏览器兼容性处理与降级方案
File和Blob API虽然已被现代浏览器广泛支持,但在处理高级特性时仍需考虑兼容性问题:
// File/Blob API兼容性处理工具
class FileApiCompat {
constructor() {
this.support = {
blobConstructor: 'Blob' in window && typeof Blob === 'function',
blobStream: 'stream' in Blob.prototype,
fileSystemAccess: 'showOpenFilePicker' in window,
readableStream: 'ReadableStream' in window,
webCodecs: 'VideoDecoder' in window && 'VideoEncoder' in window,
arrayBuffer: 'ArrayBuffer' in window,
textEncoder: 'TextEncoder' in window,
textDecoder: 'TextDecoder' in window,
fileConstructor: 'File' in window && typeof File === 'function',
urlObject: 'URL' in window && 'createObjectURL' in URL,
directoryPicker: 'showDirectoryPicker' in window,
structuredClone: 'structuredClone' in window
};
// 记录兼容性检测结果
console.log('File/Blob API兼容性检测结果:', this.support);
}
// 创建Blob对象(兼容旧浏览器)
createBlob(data, options = {}) {
if (this.support.blobConstructor) {
return new Blob(data, options);
}
// 旧浏览器降级方案(IE10-)
const BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MSBlobBuilder;
if (!BlobBuilder) {
throw new Error('您的浏览器不支持Blob API');
}
const builder = new BlobBuilder();
for (const chunk of data) {
builder.append(chunk);
}
return options.type ? builder.getBlob(options.type) : builder.getBlob();
}
// 将Blob转换为ArrayBuffer(提供多种降级方案)
async blobToArrayBuffer(blob) {
// 优先使用现代API
if (blob.arrayBuffer) {
return blob.arrayBuffer();
}
// 降级方案: FileReader
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(blob);
});
}
// 将Blob转换为文本(提供多种降级方案)
async blobToText(blob, encoding = 'utf-8') {
// 优先使用现代API
if (blob.text) {
return blob.text();
}
// 降级方案: FileReader
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsText(blob, encoding);
});
}
// 创建Blob URL(带兼容性处理)
createObjectURL(blob) {
if (!this.support.urlObject) {
throw new Error('您的浏览器不支持URL.createObjectURL');
}
return URL.createObjectURL(blob);
}
// 释放Blob URL(带兼容性处理)
revokeObjectURL(url) {
if (this.support.urlObject) {
URL.revokeObjectURL(url);
}
}
// 打开文件选择器(提供文件系统访问API和传统input的降级方案)
async openFilePicker(options = {}) {
const {
multiple = false,
types = [],
excludeAcceptAllOption = true,
legacyInput = null
} = options;
// 优先使用文件系统访问API
if (this.support.fileSystemAccess && window.showOpenFilePicker) {
try {
const fileHandles = await window.showOpenFilePicker({
multiple,
types,
excludeAcceptAllOption
});
// 获取File对象数组
return Promise.all(
fileHandles.map(handle => handle.getFile())
);
} catch (error) {
// 如果用户取消选择或发生错误,尝试降级方案
console.warn('文件系统访问API访问失败,尝试降级方案:', error);
}
}
// 降级方案: 使用<input type="file">元素
return new Promise((resolve, reject) => {
// 创建或复用input元素
let input = legacyInput;
if (!input) {
input = document.createElement('input');
input.type = 'file';
input.style.display = 'none';
// 添加到文档以确保能触发事件
document.body.appendChild(input);
// 注册清理函数
const cleanup = () => {
document.body.removeChild(input);
};
}
// 配置input元素
input.multiple = multiple;
input.accept = types.map(type => type.accept).join(',') || '';
// 处理文件选择事件
const handleChange = (e) => {
const files = Array.from(e.target.files);
// 移除事件监听器
input.removeEventListener('change', handleChange);
input.removeEventListener('cancel', handleCancel);
// 如果没有提供外部input元素,清理创建的元素
if (!legacyInput) {
cleanup();
}
resolve(files);
};
const handleCancel = () => {
input.removeEventListener('change', handleChange);
input.removeEventListener('cancel', handleCancel);
if (!legacyInput) {
cleanup();
}
resolve([]);
};
// 添加事件监听器
input.addEventListener('change', handleChange);
input.addEventListener('cancel', handleCancel);
// 触发文件选择对话框
input.click();
});
}
// 保存Blob到文件(提供多种方案)
async saveBlobToFile(blob, suggestedName) {
// 方案1: 使用File System Access API的文件保存功能
if (this.support.fileSystemAccess && window.showSaveFilePicker) {
try {
// 提取文件扩展名
const ext = suggestedName.split('.').pop() || '';
const mimeType = blob.type || '';
// 配置文件选择器选项
const options = {
suggestedName,
types: [{
description: `${mimeType.split('/')[0]} file`,
accept: {
[mimeType]: ext ? [`.${ext}`] : []
}
}]
};
// 显示保存对话框
const handle = await window.showSaveFilePicker(options);
// 写入文件内容
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return {
success: true,
handle,
name: handle.name,
using: 'file-system-access-api'
};
} catch (error) {
console.warn('文件系统访问API保存失败,尝试降级方案:', error);
}
}
// 方案2: 使用a标签下载(传统方法)
if (this.support.urlObject) {
try {
const url = this.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = suggestedName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
this.revokeObjectURL(url);
}, 100);
return {
success: true,
using: 'a-tag-download'
};
} catch (error) {
console.warn('a标签下载失败,尝试降级方案:', error);
}
}
// 方案3: 仅提供Blob URL(最低级降级方案)
if (this.support.urlObject) {
const url = this.createObjectURL(blob);
console.warn('无法自动保存文件,请复制以下链接到新窗口手动保存:', url);
return {
success: true,
url,
using: 'blob-url-only',
message: '无法自动保存文件,请复制链接到新窗口手动保存'
};
}
// 所有方案均失败
throw new Error('您的浏览器不支持文件保存功能');
}
// 检测特定MIME类型的支持情况
async isMimeTypeSupported(mimeType) {
// 对于图像类型,使用Image构造函数检测
if (mimeType.startsWith('image/')) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
// 使用数据URL测试支持情况
img.src = `data:${mimeType};base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`;
});
}
// 对于视频/音频类型,使用MediaSource检测
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
if ('MediaSource' in window) {
return MediaSource.isTypeSupported(mimeType);
}
return false;
}
// 其他类型无法直接检测,返回true表示乐观支持
return true;
}
// 获取最佳兼容的MIME类型
async getBestCompatibleMimeType(candidates) {
if (!Array.isArray(candidates) || candidates.length === 0) {
throw new Error('候选MIME类型数组不能为空');
}
// 测试每个候选类型
for (const mimeType of candidates) {
if (await this.isMimeTypeSupported(mimeType)) {
return mimeType;
}
}
// 如果没有找到支持的类型,返回第一个候选
console.warn('没有找到支持的MIME类型,返回第一个候选类型');
return candidates[0];
}
}
7.3 安全考量与权限管理
在处理本地文件时,安全性是首要考虑因素。现代浏览器实施了严格的安全策略,我们的应用必须在这些限制下工作:
// 文件操作安全管理器
class FileSecurityManager {
constructor() {
this.permissionStates = new Map();
this.allowedOrigins = new Set();
this.setupPermissionMonitoring();
}
// 初始化权限监控
setupPermissionMonitoring() {
// 监控权限变化(如果浏览器支持)
if ('permissions' in navigator) {
navigator.permissions.query({ name: 'readily-available' })
.then(permission => {
this.permissionStates.set('readily-available', permission.state);
permission.onchange = () => {
this.permissionStates.set('readily-available', permission.state);
this.onPermissionChange('readily-available', permission.state);
};
})
.catch(error => console.warn('无法监控权限状态:', error));
}
}
// 权限变化回调(可被子类重写或通过事件监听)
onPermissionChange(permissionName, state) {
console.log(`权限变化: ${permissionName} -> ${state}`);
// 可以在这里触发自定义事件
}
// 验证文件访问权限
async verifyFileAccess(file) {
// 对于通过File System Access API获取的文件,检查权限状态
if (file && file.handle && 'queryPermission' in file.handle) {
try {
const permission = await file.handle.queryPermission({ mode: 'read' });
if (permission !== 'granted') {
// 尝试请求权限
const requestResult = await file.handle.requestPermission({ mode: 'read' });
if (requestResult !== 'granted') {
throw new Error('没有文件读取权限');
}
}
return true;
} catch (error) {
console.error('文件权限验证失败:', error);
return false;
}
}
// 对于传统File对象,无法直接验证权限,但它们的存在本身意味着用户已授予访问权
if (file instanceof File) {
return true;
}
return false;
}
// 验证目录访问权限
async verifyDirectoryAccess(directoryHandle) {
if (!directoryHandle || !('queryPermission' in directoryHandle)) {
return false;
}
try {
const permission = await directoryHandle.queryPermission({ mode: 'read' });
if (permission !== 'granted') {
const requestResult = await directoryHandle.requestPermission({ mode: 'read' });
if (requestResult !== 'granted') {
throw new Error('没有目录读取权限');
}
}
return true;
} catch (error) {
console.error('目录权限验证失败:', error);
return false;
}
}
// 验证文件类型安全性
validateFileType(file, allowedTypes = []) {
if (!file || !allowedTypes.length) return true;
// 检查MIME类型
const mimeType = file.type.toLowerCase();
const extension = file.name.split('.').pop()?.toLowerCase() || '';
// 检查是否在允许列表中
const isAllowed = allowedTypes.some(type => {
if (type.startsWith('.')) {
// 扩展名匹配
return type.toLowerCase() === `.${extension}`;
} else if (type.includes('/')) {
// MIME类型匹配
const [mainType, subType] = type.split('/');
if (subType === '*') {
return mimeType.startsWith(`${mainType}/`);
}
return mimeType === type.toLowerCase();
}
return false;
});
if (!isAllowed) {
console.warn(`不允许的文件类型: ${mimeType} (.${extension})`);
return false;
}
// 额外的安全检查:验证文件内容(简单检查)
if (allowedTypes.some(type => type.startsWith('image/')) && mimeType.startsWith('image/')) {
return this.validateImageFile(file);
}
return true;
}
// 验证图像文件内容(防止伪装文件)
async validateImageFile(file) {
return new Promise(resolve => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
// 基本尺寸检查
const isValid = img.width > 0 && img.height > 0;
URL.revokeObjectURL(url);
resolve(isValid);
};
img.onerror = () => {
console.warn('图像文件验证失败:文件可能不是有效的图像');
URL.revokeObjectURL(url);
resolve(false);
};
img.src = url;
});
}
// 验证文件大小
validateFileSize(file, maxSizeBytes) {
if (!file || !maxSizeBytes) return true;
if (file.size > maxSizeBytes) {
console.warn(`文件过大: ${file.name} (${file.size} bytes),超过限制 ${maxSizeBytes} bytes`);
return false;
}
return true;
}
// 注册可信来源(用于跨域文件访问)
registerTrustedOrigin(origin) {
if (typeof origin === 'string') {
this.allowedOrigins.add(origin);
return true;
}
return false;
}
// 验证URL来源是否可信
isTrustedOrigin(url) {
try {
const origin = new URL(url).origin;
return this.allowedOrigins.has(origin) || origin === window.location.origin;
} catch (error) {
console.error('解析URL失败:', error);
return false;
}
}
// 安全的文件内容处理(防止XSS等攻击)
sanitizeFileContent(content, mimeType) {
// 根据MIME类型应用不同的清理策略
if (mimeType.includes('html') || mimeType.includes('xml')) {
// 对于HTML/XML内容,使用DOMPurify等库进行清理
if (typeof DOMPurify !== 'undefined') {
return DOMPurify.sanitize(content);
} else {
console.warn('DOMPurify未加载,无法安全清理HTML/XML内容');
return content.replace(/<script.*?>.*?<\/script>/gi, ''); // 简单的脚本标签移除
}
}
// 对于文本内容,转义HTML特殊字符
if (mimeType.startsWith('text/')) {
return content
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 二进制内容无需清理
return content;
}
}
八、实际应用案例与架构设计
8.1 客户端CSV/Excel数据处理工具
利用File和Blob API构建的客户端CSV/Excel处理工具,无需服务器即可实现数据导入、解析和可视化:
// 客户端CSV/Excel处理工具
class ClientSideDataProcessor {
constructor(options = {}) {
this.securityManager = new FileSecurityManager();
this.memoryManager = new LargeFileMemoryManager();
this.compat = new FileApiCompat();
this.csvParser = options.csvParser || this.defaultCsvParser;
this.maxFileSize = options.maxFileSize || 100 * 1024 * 1024; // 默认100MB
this.supportedFormats = options.supportedFormats || [
'.csv', '.tsv', '.txt',
'text/csv', 'text/tab-separated-values', 'text/plain'
];
}
// 默认CSV解析器
defaultCsvParser(text, delimiter = ',') {
const lines = text.split(/\r?\n/).filter(line => line.trim() !== '');
if (lines.length === 0) return { headers: [], rows: [] };
// 检测分隔符(如果未指定)
if (!delimiter) {
const commaCount = (text.match(/,/g) || []).length;
const tabCount = (text.match(/\t/g) || []).length;
delimiter = commaCount > tabCount ? ',' : '\t';
}
// 解析表头
const headers = lines[0].split(delimiter).map(header => header.trim());
// 解析数据行
const rows = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const values = line.split(delimiter).map(value => {
// 尝试转换为适当的类型
const trimmed = value.trim();
// 数字检测
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return parseFloat(trimmed);
}
// 布尔值检测
if (trimmed.toLowerCase() === 'true') return true;
if (trimmed.toLowerCase() === 'false') return false;
// 日期检测
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const date = new Date(trimmed);
if (!isNaN(date.getTime())) return date;
}
return trimmed;
});
// 创建对象
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] !== undefined ? values[index] : '';
});
rows.push(row);
}
return { headers, rows, delimiter };
}
// 加载并解析数据文件
async loadAndParseFile(file, options = {}) {
const {
delimiter,
progressCallback,
maxRows = Infinity,
skipValidation = false
} = options;
// 安全检查
if (!skipValidation) {
// 验证文件类型
if (!this.securityManager.validateFileType(file, this.supportedFormats)) {
throw new Error(`不支持的文件类型: ${file.name}`);
}
// 验证文件大小
if (!this.securityManager.validateFileSize(file, this.maxFileSize)) {
throw new Error(`文件过大: ${file.name},最大支持 ${this.formatFileSize(this.maxFileSize)}`);
}
// 验证访问权限
if (!await this.securityManager.verifyFileAccess(file)) {
throw new Error('没有文件访问权限');
}
}
// 跟踪文件
const fileId = `data-file-${Date.now()}`;
const fileHandle = this.memoryManager.trackObject(fileId, file, {
type: 'data-file',
parser: 'csv',
timestamp: Date.now()
});
try {
progressCallback?.({
stage: 'loading',
percent: 10,
message: `正在读取文件: ${file.name}`
});
// 读取文件内容
const text = await this.compat.blobToText(fileHandle.get());
progressCallback?.({
stage: 'parsing',
percent: 40,
message: `正在解析文件: ${file.name}`
});
// 解析CSV内容
const result = this.csvParser(text, delimiter);
// 如果设置了最大行数限制,截断结果
if (maxRows && result.rows.length > maxRows) {
result.rows = result.rows.slice(0, maxRows);
result.truncated = true;
result.totalRows = result.rows.length;
}
// 生成统计信息
result.stats = {
fileSize: file.size,
fileType: file.type,
fileName: file.name,
parseTime: Date.now() - fileHandle.metadata.timestamp,
rowCount: result.rows.length,
columnCount: result.headers.length,
memoryUsage: this.memoryManager.getMemoryStatus().usage
};
progressCallback?.({
stage: 'complete',
percent: 100,
message: `解析完成: ${result.rows.length} 行数据`,
stats: result.stats
});
return {
...result,
fileInfo: {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified
},
export: (format = 'json') => this.exportData(result, format),
analyze: () => this.analyzeData(result)
};
} finally {
// 释放文件句柄,但保留数据
fileHandle.release();
}
}
// 数据分析功能
analyzeData(parsedResult) {
const { headers, rows } = parsedResult;
const analysis = {
columns: {},
rowCount: rows.length,
stats: {
numericColumns: 0,
stringColumns: 0,
dateColumns: 0,
booleanColumns: 0,
emptyValues: 0,
totalValues: rows.length * headers.length
}
};
// 分析每一列
headers.forEach(header => {
const columnData = rows.map(row => row[header]);
const columnAnalysis = {
name: header,
type: 'unknown',
values: {
unique: new Set(),
missing: 0,
total: columnData.length
},
stats: {}
};
// 检测列类型和统计信息
columnData.forEach(value => {
if (value === null || value === undefined || value === '') {
columnAnalysis.values.missing++;
analysis.stats.emptyValues++;
return;
}
columnAnalysis.values.unique.add(value);
// 检测值类型
if (typeof value === 'number') {
if (!columnAnalysis.type || columnAnalysis.type === 'number') {
columnAnalysis.type = 'number';
// 数值统计
if (isNaN(columnAnalysis.stats.sum)) columnAnalysis.stats.sum = 0;
if (isNaN(columnAnalysis.stats.min)) columnAnalysis.stats.min = value;
if (isNaN(columnAnalysis.stats.max)) columnAnalysis.stats.max = value;
columnAnalysis.stats.sum += value;
columnAnalysis.stats.min = Math.min(columnAnalysis.stats.min, value);
columnAnalysis.stats.max = Math.max(columnAnalysis.stats.max, value);
}
} else if (typeof value === 'boolean') {
if (!columnAnalysis.type || columnAnalysis.type === 'boolean') {
columnAnalysis.type = 'boolean';
// 布尔值统计
if (isNaN(columnAnalysis.stats.trueCount)) columnAnalysis.stats.trueCount = 0;
if (isNaN(columnAnalysis.stats.falseCount)) columnAnalysis.stats.falseCount = 0;
value ? columnAnalysis.stats.trueCount++ : columnAnalysis.stats.falseCount++;
}
} else if (value instanceof Date) {
if (!columnAnalysis.type || columnAnalysis.type === 'date') {
columnAnalysis.type = 'date';
// 日期统计
if (!columnAnalysis.stats.earliest || value < columnAnalysis.stats.earliest) {
columnAnalysis.stats.earliest = value;
}
if (!columnAnalysis.stats.latest || value > columnAnalysis.stats.latest) {
columnAnalysis.stats.latest = value;
}
}
} else {
// 默认视为字符串
if (!columnAnalysis.type) {
columnAnalysis.type = 'string';
}
}
});
// 计算数值列的平均值
if (columnAnalysis.type === 'number' && columnAnalysis.values.total > 0) {
columnAnalysis.stats.mean = columnAnalysis.stats.sum / columnAnalysis.values.total;
// 计算方差和标准差
const squaredDiffs = columnData.map(value => {
if (value === null || value === undefined || value === '') return 0;
const diff = value - columnAnalysis.stats.mean;
return diff * diff;
}).filter(v => !isNaN(v));
columnAnalysis.stats.variance = squaredDiffs.length > 0
? squaredDiffs.reduce((sum, diff) => sum + diff, 0) / squaredDiffs.length
: 0;
columnAnalysis.stats.standardDeviation = Math.sqrt(columnAnalysis.stats.variance);
}
// 更新全局统计
if (columnAnalysis.type === 'number') analysis.stats.numericColumns++;
else if (columnAnalysis.type === 'string') analysis.stats.stringColumns++;
else if (columnAnalysis.type === 'date') analysis.stats.dateColumns++;
else if (columnAnalysis.type === 'boolean') analysis.stats.booleanColumns++;
// 完成列分析
columnAnalysis.values.uniqueCount = columnAnalysis.values.unique.size;
columnAnalysis.values.missingPercentage = (columnAnalysis.values.missing / columnAnalysis.values.total) * 100;
columnAnalysis.values.uniquePercentage = (columnAnalysis.values.uniqueCount / columnAnalysis.values.total) * 100;
analysis.columns[header] = columnAnalysis;
});
// 计算空值百分比
analysis.stats.emptyValuePercentage = (analysis.stats.emptyValues / analysis.stats.totalValues) * 100;
return analysis;
}
// 导出数据为不同格式
async exportData(parsedResult, format = 'json') {
const { headers, rows } = parsedResult;
let blob, fileName = `export-${new Date().toISOString().slice(0,10)}`;
switch (format.toLowerCase()) {
case 'json':
// 导出为JSON
const jsonData = JSON.stringify(rows, null, 2);
blob = this.compat.createBlob([jsonData], { type: 'application/json' });
fileName += '.json';
break;
case 'csv':
// 导出为CSV
const csvRows = [headers.join(',')];
// 添加数据行
rows.forEach(row => {
const csvRow = headers.map(header => {
const value = row[header];
if (value === null || value === undefined) return '';
// 处理CSV特殊字符
let strValue = typeof value === 'object' && value instanceof Date
? value.toISOString()
: String(value);
if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) {
strValue = `"${strValue.replace(/"/g, '""')}"`;
}
return strValue;
});
csvRows.push(csvRow.join(','));
});
blob = this.compat.createBlob([csvRows.join('\n')], { type: 'text/csv' });
fileName += '.csv';
break;
case 'tsv':
// 导出为TSV
const tsvRows = [headers.join('\t')];
rows.forEach(row => {
const tsvRow = headers.map(header => {
const value = row[header];
if (value === null || value === undefined) return '';
let strValue = typeof value === 'object' && value instanceof Date
? value.toISOString()
: String(value);
// 处理TSV特殊字符
if (strValue.includes('\t') || strValue.includes('\n')) {
strValue = strValue.replace(/\t/g, '\\t').replace(/\n/g, '\\n');
}
return strValue;
});
tsvRows.push(tsvRow.join('\t'));
});
blob = this.compat.createBlob([tsvRows.join('\n')], { type: 'text/tab-separated-values' });
fileName += '.tsv';
break;
case 'html':
// 导出为HTML表格
const htmlRows = rows.map(row => `
<tr>
${headers.map(header => `<td>${row[header] !== null && row[header] !== undefined ? row[header] : ''}</td>`).join('')}
</tr>
`).join('\n');
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>导出数据表格</title>
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
tr:nth-child(even) { background-color: #f9f9f9; }
</style>
</head>
<body>
<h1>导出数据表格</h1>
<p>导出时间: ${new Date().toLocaleString()}</p>
<table>
<thead>
<tr>${headers.map(header => `<th>${header}</th>`).join('')}</tr>
</thead>
<tbody>
${htmlRows}
</tbody>
</table>
</body>
</html>
`;
blob = this.compat.createBlob([htmlContent], { type: 'text/html' });
fileName += '.html';
break;
default:
throw new Error(`不支持的导出格式: ${format}`);
}
// 保存文件
return this.compat.saveBlobToFile(blob, fileName);
}
// 格式化文件大小显示
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
8.2 客户端图像编辑与处理应用
结合File/Blob API与Canvas实现的客户端图像编辑工具,支持基本编辑功能而无需服务器:
// 客户端图像编辑器
class ClientImageEditor {
constructor(canvasId, options = {}) {
this.canvas = document.getElementById(canvasId);
if (!this.canvas) {
throw new Error(`找不到ID为 ${canvasId} 的Canvas元素`);
}
this.ctx = this.canvas.getContext('2d');
if (!this.ctx) {
throw new Error('无法获取Canvas 2D上下文');
}
// 初始化管理器
this.memoryManager = new LargeFileMemoryManager();
this.compat = new FileApiCompat();
this.securityManager = new FileSecurityManager();
// 配置
this.options = {
maxImageSize: 8000, // 最大图像尺寸(像素)
defaultQuality: 0.92, // 默认图像质量
supportedFormats: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
...options
};
// 状态管理
this.state = {
originalImage: null,
currentImage: null,
history: [],
historyIndex: -1,
transformations: [],
imageInfo: {}
};
// 初始化画布大小
this.resizeCanvas(this.options.initialWidth || 800, this.options.initialHeight || 600);
}
// 调整画布大小
resizeCanvas(width, height) {
// 保存当前内容
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
tempCtx.drawImage(this.canvas, 0, 0);
// 调整大小
this.canvas.width = width;
this.canvas.height = height;
// 恢复内容(如果需要)
if (this.state.currentImage && this.options.preserveOnResize) {
this.ctx.drawImage(tempCanvas, 0, 0, width, height);
}
return this;
}
// 加载图像文件
async loadImageFile(file, progressCallback) {
// 安全验证
if (!this.securityManager.validateFileType(file, this.options.supportedFormats)) {
throw new Error(`不支持的图像格式: ${file.type}`);
}
if (!this.securityManager.validateFileSize(file, this.options.maxFileSize)) {
throw new Error(`文件过大,最大支持 ${this.formatFileSize(this.options.maxFileSize)}`);
}
progressCallback?.({ stage: 'loading', percent: 20, message: '正在加载图像' });
// 跟踪文件
const fileId = `image-file-${Date.now()}`;
const fileHandle = this.memoryManager.trackObject(fileId, file, {
type: 'image-file',
timestamp: Date.now()
});
try {
// 创建图像对象
const img = new Image();
const url = this.compat.createObjectURL(fileHandle.get());
// 等待图像加载
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = url;
});
progressCallback?.({ stage: 'processing', percent: 50, message: '正在处理图像' });
// 检查图像尺寸
let { width, height } = img;
// 如果图像过大,调整大小
const maxSize = this.options.maxImageSize;
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width *= scale;
height *= scale;
}
// 调整画布大小并绘制图像
this.resizeCanvas(width, height);
this.ctx.clearRect(0, 0, width, height);
this.ctx.drawImage(img, 0, 0, width, height);
// 保存原始图像数据
this.state.originalImage = img;
this.state.currentImage = this.canvas.toDataURL(file.type, this.options.defaultQuality);
this.state.imageInfo = {
originalWidth: img.naturalWidth,
originalHeight: img.naturalHeight,
displayWidth: width,
displayHeight: height,
format: file.type,
size: file.size,
name: file.name,
lastModified: file.lastModified
};
// 重置历史记录
this.resetHistory();
this.addToHistory();
progressCallback?.({
stage: 'complete',
percent: 100,
message: '图像加载完成',
imageInfo: this.state.imageInfo
});
return this.state.imageInfo;
} finally {
// 清理URL对象,但保留文件引用
this.compat.revokeObjectURL(img.src);
fileHandle.release();
}
}
// 应用滤镜效果
applyFilter(filterName, options = {}) {
if (!this.state.originalImage) {
throw new Error('没有加载图像');
}
// 保存当前状态
this.addToHistory();
// 获取滤镜函数
const filterFunction = this.getFilterFunction(filterName);
if (!filterFunction) {
throw new Error(`不支持的滤镜: ${filterName}`);
}
// 获取图像数据
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
// 应用滤镜
filterFunction(imageData.data, options);
// 将处理后的数据放回画布
this.ctx.putImageData(imageData, 0, 0);
// 更新当前图像状态
this.state.currentImage = this.canvas.toDataURL(this.state.imageInfo.format, this.options.defaultQuality);
this.state.transformations.push({
type: 'filter',
name: filterName,
options
});
return this;
}
// 获取滤镜函数
getFilterFunction(filterName) {
const filters = {
// 灰度滤镜
grayscale: (data) => {
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = avg; // R
data[i + 1] = avg; // G
data[i + 2] = avg; // B
}
},
// 亮度调整
brightness: (data, { level = 0 }) => {
const adjustment = level * 255 / 100;
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.max(0, Math.min(255, data[i] + adjustment)); // R
data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + adjustment)); // G
data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + adjustment)); // B
}
},
// 对比度调整
contrast: (data, { level = 0 }) => {
const factor = (259 * (level + 255)) / (255 * (259 - level));
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.max(0, Math.min(255, factor * (data[i] - 128) + 128)); // R
data[i + 1] = Math.max(0, Math.min(255, factor * (data[i + 1] - 128) + 128)); // G
data[i + 2] = Math.max(0, Math.min(255, factor * (data[i + 2] - 128) + 128)); // B
}
},
// 饱和度调整
saturation: (data, { level = 0 }) => {
const saturationFactor = level / 100;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 计算灰度值
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
// 应用饱和度调整
data[i] = Math.max(0, Math.min(255, gray + saturationFactor * (r - gray)));
data[i + 1] = Math.max(0, Math.min(255, gray + saturationFactor * (g - gray)));
data[i + 2] = Math.max(0, Math.min(255, gray + saturationFactor * (b - gray)));
}
},
// 反转颜色
invert: (data) => {
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i]; // R
data[i + 1] = 255 - data[i + 1]; // G
data[i + 2] = 255 - data[i + 2]; // B
}
},
// sepia滤镜
sepia: (data) => {
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
data[i] = Math.min(255, 0.393 * r + 0.769 * g + 0.189 * b);
data[i + 1] = Math.min(255, 0.349 * r + 0.686 * g + 0.168 * b);
data[i + 2] = Math.min(255, 0.272 * r + 0.534 * g + 0.131 * b);
}
},
// 锐化滤镜
sharpen: (data, { strength = 1 }) => {
// 简单锐化实现(3x3卷积核)
const width = this.canvas.width;
const height = this.canvas.height;
const pixels = new Uint8ClampedArray(data);
const kernel = [
0, -strength, 0,
-strength, 4*strength + 1, -strength,
0, -strength, 0
];
// 创建临时数组存储结果
const result = new Uint8ClampedArray(data.length);
// 应用卷积
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
for (let c = 0; c < 3; c++) { // 对RGB通道应用滤镜
const index = (y * width + x) * 4 + c;
let sum = 0;
// 应用卷积核
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const kernelIndex = (ky + 1) * 3 + (kx + 1);
const neighborIndex = ((y + ky) * width + (x + kx)) * 4 + c;
sum += pixels[neighborIndex] * kernel[kernelIndex];
}
}
// 确保值在有效范围内
result[index] = Math.max(0, Math.min(255, sum));
}
// 保留alpha通道
result[(y * width + x) * 4 + 3] = pixels[(y * width + x) * 4 + 3];
}
}
// 将结果复制回原始数据
for (let i = 0; i < data.length; i++) {
data[i] = result[i];
}
}
};
return filters[filterName];
}
// 添加到历史记录
addToHistory() {
// 如果在历史记录中间添加新状态,删除后面的历史
if (this.state.historyIndex < this.state.history.length - 1) {
this.state.history = this.state.history.slice(0, this.state.historyIndex + 1);
}
// 保存当前画布状态
const stateUrl = this.canvas.toDataURL(this.state.imageInfo.format);
this.state.history.push(stateUrl);
this.state.historyIndex = this.state.history.length - 1;
// 限制历史记录数量
if (this.state.history.length > this.options.maxHistoryStates || 20) {
this.state.history.shift();
this.state.historyIndex--;
}
return this;
}
// 重置历史记录
resetHistory() {
this.state.history = [];
this.state.historyIndex = -1;
this.state.transformations = [];
return this;
}
// 撤销操作
undo() {
if (this.state.historyIndex <= 0) return this;
this.state.historyIndex--;
this.restoreHistoryState(this.state.historyIndex);
return this;
}
// 重做操作
redo() {
if (this.state.historyIndex >= this.state.history.length - 1) return this;
this.state.historyIndex++;
this.restoreHistoryState(this.state.historyIndex);
return this;
}
// 恢复历史状态
restoreHistoryState(index) {
if (index < 0 || index >= this.state.history.length) return;
const img = new Image();
img.onload = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(img, 0, 0);
this.state.currentImage = this.state.history[index];
};
img.src = this.state.history[index];
return this;
}
// 旋转图像
rotate(degrees) {
this.addToHistory();
// 创建临时画布
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
// 计算旋转后的尺寸
const radians = degrees * Math.PI / 180;
const rotatedWidth = Math.abs(this.canvas.width * Math.cos(radians)) + Math.abs(this.canvas.height * Math.sin(radians));
const rotatedHeight = Math.abs(this.canvas.width * Math.sin(radians)) + Math.abs(this.canvas.height * Math.cos(radians));
// 设置临时画布尺寸
tempCanvas.width = rotatedWidth;
tempCanvas.height = rotatedHeight;
// 旋转并绘制图像
tempCtx.translate(rotatedWidth / 2, rotatedHeight / 2);
tempCtx.rotate(radians);
tempCtx.drawImage(this.canvas, -this.canvas.width / 2, -this.canvas.height / 2);
// 更新主画布
this.resizeCanvas(rotatedWidth, rotatedHeight);
this.ctx.clearRect(0, 0, rotatedWidth, rotatedHeight);
this.ctx.drawImage(tempCanvas, 0, 0);
// 更新状态
this.state.currentImage = this.canvas.toDataURL(this.state.imageInfo.format);
this.state.transformations.push({
type: 'rotate',
degrees
});
return this;
}
// 裁剪图像
crop(x, y, width, height) {
this.addToHistory();
// 创建临时画布
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
// 设置临时画布尺寸
tempCanvas.width = width;
tempCanvas.height = height;
// 绘制裁剪区域
tempCtx.drawImage(
this.canvas,
x, y, width, height, // 源区域
0, 0, width, height // 目标区域
);
// 更新主画布
this.resizeCanvas(width, height);
this.ctx.clearRect(0, 0, width, height);
this.ctx.drawImage(tempCanvas, 0, 0);
// 更新状态
this.state.currentImage = this.canvas.toDataURL(this.state.imageInfo.format);
this.state.transformations.push({
type: 'crop',
x, y, width, height
});
return this;
}
// 调整图像大小
resize(newWidth, newHeight) {
this.addToHistory();
// 创建临时画布
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
// 设置临时画布尺寸
tempCanvas.width = newWidth;
tempCanvas.height = newHeight;
// 绘制调整后的图像
tempCtx.drawImage(this.canvas, 0, 0, newWidth, newHeight);
// 更新主画布
this.resizeCanvas(newWidth, newHeight);
this.ctx.clearRect(0, 0, newWidth, newHeight);
this.ctx.drawImage(tempCanvas, 0, 0);
// 更新状态
this.state.currentImage = this.canvas.toDataURL(this.state.imageInfo.format);
this.state.transformations.push({
type: 'resize',
width: newWidth,
height: newHeight
});
return this;
}
// 保存图像到文件
async saveImage(options = {}) {
if (!this.state.currentImage) {
throw new Error('没有可保存的图像');
}
const {
format,
quality = this.options.defaultQuality,
fileName = `edited-image-${new Date().toISOString().slice(0,10)}`,
progressCallback
} = options;
progressCallback?.({ stage: 'processing', percent: 30, message: '正在处理图像' });
// 确定图像格式
const imageFormat = format || this.state.imageInfo.format || 'image/jpeg';
const extension = imageFormat.split('/')[1] || 'jpg';
// 从画布获取图像数据
const dataUrl = this.canvas.toDataURL(imageFormat, quality);
const byteString = atob(dataUrl.split(',')[1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
// 创建Blob
const blob = new Blob([ab], { type: imageFormat });
progressCallback?.({ stage: 'saving', percent: 70, message: '正在准备保存' });
// 保存到文件
const result = await this.compat.saveBlobToFile(blob, `${fileName}.${extension}`);
progressCallback?.({ stage: 'complete', percent: 100, message: '图像保存完成' });
return {
...result,
blob,
format: imageFormat,
size: blob.size,
quality
};
}
// 获取当前图像Blob
async getImageBlob(format, quality = this.options.defaultQuality) {
const dataUrl = this.canvas.toDataURL(format || this.state.imageInfo.format, quality);
const response = await fetch(dataUrl);
return response.blob();
}
// 格式化文件大小
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 销毁编辑器实例
destroy() {
this.resetHistory();
this.memoryManager.destroy();
this.state = {
originalImage: null,
currentImage: null,
history: [],
historyIndex: -1,
transformations: []
};
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}
掌握File与Blob黑科技:从浏览器操控本地文件夹到现代应用实战指南(上篇)