前言
文件下载是前端开发中的常见需求,看似简单,实则涉及多个技术点。本文将深入解析文件下载的实现原理,并提供一个企业级的解决方案。
为什么文件下载值得深入探讨?
-
浏览器兼容性问题:不同浏览器对文件下载的处理方式不同
-
文件名安全处理:特殊字符、编码、长度限制等
-
大文件下载:进度追踪、断点续传、内存优化
-
错误处理:网络异常、文件类型验证、重试机制
-
用户体验:加载状态、进度显示、成功提示
基础实现
1. 最简单的实现方式
TypeScript
// 基础版本:直接使用 a 标签
const downloadFile = (url: string, fileName: string) => {
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
};
问题:
- 只能下载同源文件
- 无法处理跨域文件
- 无法追踪下载进度
- 无法处理错误情况
2. 使用 Blob + URL.createObjectURL
TypeScript
// 改进版本:使用 Blob
const downloadBlob = async (url: string, fileName: string) => {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = fileName;
link.click();
// 清理内存
URL.revokeObjectURL(blobUrl);
};
优势:
- 支持跨域(需要 CORS)
- 可以处理二进制数据
- 可以追踪下载进度
技术难点与解决方案
难点1:文件名安全处理
问题场景
- Windows 不允许的字符:`< > : " / \ | ? *`
- 文件名长度限制(Windows 260字符,Linux 255字符)
- Unicode 字符编码问题
- 空文件名处理
解决方案
TypeScript
/**
* 文件名安全处理
* @param fileName 原始文件名
* @param maxLength 最大长度(默认200)
* @returns 处理后的安全文件名
*/
export const sanitizeFileName = (fileName: string, maxLength: number = 200): string => {
if (!fileName) return 'download';
// 1. 移除非法字符
const illegalChars = /[<>:"/\\|?*\x00-\x1f]/g;
let sanitized = fileName.replace(illegalChars, '_');
// 2. 处理空格和特殊字符
sanitized = sanitized
.replace(/\s+/g, '_') // 空格替换为下划线
.replace(/[^\w\u4e00-\u9fa5\-_.]/g, ''); // 只保留字母数字中文和下划线等
// 3. 限制长度(保留扩展名)
const lastDotIndex = sanitized.lastIndexOf('.');
if (lastDotIndex > 0) {
const name = sanitized.substring(0, lastDotIndex);
const ext = sanitized.substring(lastDotIndex);
const maxNameLength = maxLength - ext.length;
sanitized = name.substring(0, maxNameLength) + ext;
} else {
sanitized = sanitized.substring(0, maxLength);
}
return sanitized || 'download';
};
技术要点:
- 使用正则表达式过滤非法字符
- 考虑扩展名的长度限制
- 处理 Unicode 字符(中文、emoji)
- 提供默认文件名
难点2:动态文件名生成
业务需求
文件名需要包含业务信息:`matchDetail_{xxxName}{xxxxName}{YYYY-MM-DD}.xlsx`
解决方案:模板化文件名生成器
TypeScript
interface FileNameTemplate {
prefix?: string; // 前缀
fields?: Record<string, string | number>; // 业务字段
dateFormat?: 'YYYY-MM-DD' | 'YYYYMMDD' | 'YYYY-MM-DD_HH-mm' | 'timestamp';
suffix?: string; // 后缀
extension?: string; // 扩展名
}
export const generateFileName = (template: FileNameTemplate): string => {
const parts: string[] = [];
// 前缀
if (template.prefix) parts.push(template.prefix);
// 业务字段:key_value 格式
if (template.fields) {
const fieldValues = Object.entries(template.fields)
.map(([key, value]) => {
const val = String(value || '').trim();
return val ? `${key}_${val}` : '';
})
.filter(Boolean);
parts.push(...fieldValues);
}
// 日期格式化
if (template.dateFormat) {
const now = new Date();
let dateStr = '';
switch (template.dateFormat) {
case 'YYYY-MM-DD':
dateStr = now.toISOString().split('T')[0];
break;
case 'YYYY-MM-DD_HH-mm':
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
dateStr = `${year}-${month}-${day}_${hours}-${minutes}`;
break;
// ... 其他格式
}
parts.push(dateStr);
}
// 组合文件名
let fileName = parts.join('_');
// 添加扩展名
if (template.extension) {
const ext = template.extension.startsWith('.')
? template.extension
: `.${template.extension}`;
fileName += ext;
}
return sanitizeFileName(fileName);
};
// 使用示例
const fileName = generateFileName({
prefix: 'matchDetail',
fields: {
vessel: 'xxxxx',
candidate: 'xxxxx'
},
dateFormat: 'YYYY-MM-DD',
extension: 'xlsx'
});
// 结果: matchDetail_vessel_xxxxx_candidate_xxxxxx_2024-01-15.xlsx
优势:
- 模板化配置,易于维护
- 支持多种日期格式
- 自动处理空值
- 类型安全
难点3:下载进度追踪
问题场景
大文件下载时,用户需要看到下载进度。
解决方案:使用 axios 的 onDownloadProgress
TypeScript
interface DownloadProgress {
loaded: number; // 已下载字节数
total: number; // 总字节数
percentage: number; // 百分比
}
type ProgressCallback = (progress: DownloadProgress) => void;
const downloadFile = async (
url: string,
fileName: string,
onProgress?: ProgressCallback
) => {
const response = await axios({
url,
method: 'GET',
responseType: 'blob',
onDownloadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
onProgress({
loaded: progressEvent.loaded,
total: progressEvent.total,
percentage: Math.round(
(progressEvent.loaded / progressEvent.total) * 100
)
});
}
}
});
// ... 处理下载
};
// 使用示例
await downloadFile(url, fileName, (progress) => {
console.log(`下载进度: ${progress.percentage}%`);
// 更新 UI 进度条
updateProgressBar(progress.percentage);
});
难点4:错误处理与重试机制
问题场景
- 网络不稳定导致下载失败
- 服务器返回错误
- 文件类型不匹配
解决方案:重试机制 + 错误分类
TypeScript
interface DownloadOptions {
retryCount?: number; // 重试次数
retryDelay?: number; // 重试延迟(毫秒)
validateFileType?: boolean; // 是否验证文件类型
expectedTypes?: string[]; // 期望的文件类型
}
const downloadFile = async (options: DownloadOptions) => {
const {
url,
retryCount = 3,
retryDelay = 1000,
validateFileType = true,
expectedTypes = []
} = options;
let attempt = 0;
while (attempt < retryCount) {
try {
const response = await axios.get(url, {
responseType: 'blob'
});
// 验证文件类型
if (validateFileType && expectedTypes.length > 0) {
const contentType = response.headers['content-type'];
if (!expectedTypes.includes(contentType)) {
throw new Error(
`文件类型不匹配: ${contentType}`
);
}
}
// 下载成功
return handleDownload(response);
} catch (error) {
attempt++;
if (attempt >= retryCount) {
// 所有重试都失败
throw error;
}
// 指数退避:延迟时间逐渐增加
await new Promise(resolve =>
setTimeout(resolve, retryDelay * attempt)
);
}
}
};
技术要点:
- 指数退避策略(延迟时间递增)
- 错误分类处理
- 文件类型验证
- 用户友好的错误提示
难点5:多场景适配
业务场景
同一个页面可能从不同入口进入,需要使用不同的 API 接口和参数。
解决方案:策略模式
TypeScript
const handleExport = async () => {
// 1. 获取来源标识
let pageSource = store.detailPageSource || 'homePage';
if (!pageSource) {
pageSource = sessionStorage.getItem('detailPageSource') || 'homePage';
}
// 2. 根据来源选择不同的策略
const exportStrategies = {
homePage: {
url: '/Other/ExportEvaluationForm',
params: { uuid: store.detailDataUuid }
},
matchingMode: {
url: '/Other/ExportPersonnelMatching',
params: { id: store.detailDataUuid }
}
};
const strategy = exportStrategies[pageSource] || exportStrategies.homePage;
// 3. 生成文件名
const fileName = generateFileName({
prefix: 'matchDetail',
fields: {
vessel: par.vessel?.vesselName || '',
candidate: par.candidate?.name || ''
},
dateFormat: 'YYYY-MM-DD',
extension: 'xlsx'
});
// 4. 执行下载
await downloadFile({
url: strategy.url,
params: strategy.params,
fileName,
method: 'GET'
});
};
优势:
- 代码清晰,易于扩展
- 符合开闭原则
- 便于单元测试
企业级扩展方案
1. 下载历史记录
TypeScript
interface DownloadHistoryItem {
fileName: string;
url: string;
timestamp: string;
size: number;
}
const recordDownloadHistory = (item: DownloadHistoryItem) => {
const history = JSON.parse(
localStorage.getItem('downloadHistory') || '[]'
);
history.unshift(item);
// 只保留最近 50 条
if (history.length > 50) {
history.splice(50);
}
localStorage.setItem('downloadHistory', JSON.stringify(history));
};
2. 并发下载控制
TypeScript
class DownloadManager {
private maxConcurrent = 3; // 最大并发数
private queue: Array<() => Promise<void>> = [];
private running = 0;
async addDownload(downloadFn: () => Promise<void>) {
// 返回一个Promise,这样调用者可以用await等待任务完成
return new Promise<void>((resolve, reject) => {
// 将任务包装后放入队列
this.queue.push(async () => {
try {
await downloadFn(); // 执行实际的下载
resolve(); // 成功后resolve
} catch (error) {
reject(error); // 失败后reject
} finally {
this.running--; // 无论如何都要减少运行计数
this.processQueue(); // 检查是否可以启动新任务
}
});
// 尝试立即执行
this.processQueue();
});
}
private processQueue() {
// 当有"空位"且队列中有任务时
while (this.running < this.maxConcurrent && this.queue.length > 0) {
this.running++; // 占用一个并发位置
const task = this.queue.shift(); // 从队列取出任务
task?.(); // 立即执行(不等待)
}
}
}
3. 断点续传(大文件)
TypeScript
const downloadWithResume = async (
url: string,
fileName: string,
onProgress?: ProgressCallback
) => {
// 检查是否有未完成的下载
const resumeInfo = localStorage.getItem(`download_${fileName}`);
let startByte = 0;
if (resumeInfo) {
const info = JSON.parse(resumeInfo);
startByte = info.loaded;
}
const response = await axios.get(url, {
responseType: 'blob',
headers: {
'Range': `bytes=${startByte}-`
},
onDownloadProgress: (progressEvent) => {
// 保存下载进度
localStorage.setItem(`download_${fileName}`, JSON.stringify({
loaded: progressEvent.loaded + startByte,
total: progressEvent.total
}));
if (onProgress) {
onProgress({
loaded: progressEvent.loaded + startByte,
total: progressEvent.total,
percentage: Math.round(
((progressEvent.loaded + startByte) / progressEvent.total) * 100
)
});
}
}
});
// 下载完成后清除记录
localStorage.removeItem(`download_${fileName}`);
};
最佳实践
1. 用户体验优化
TypeScript
// 好的实践
const handleExport = async () => {
// 1. 显示加载状态
const loading = ElLoading.service({
lock: true,
text: '正在导出,请稍候...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
try {
await downloadFile(options);
// 2. 成功提示
ElNotification({
title: '导出成功',
message: `文件已开始下载`,
type: 'success',
duration: 3000
});
} catch (error) {
// 3. 错误提示
ElMessage.error('导出失败,请稍后重试');
} finally {
loading.close();
}
};
2. 性能优化
- 内存管理:及时释放 Blob URL
- 并发控制:限制同时下载的文件数量
- 缓存策略:对相同文件使用缓存
3. 错误处理
TypeScript
const handleDownloadError = (error: any) => {
if (error.response) {
// 服务器返回错误
switch (error.response.status) {
case 404:
return '文件不存在';
case 403:
return '没有下载权限';
case 500:
return '服务器错误,请稍后重试';
default:
return '下载失败,请稍后重试';
}
} else if (error.request) {
// 网络错误
return '网络连接失败,请检查网络';
} else {
// 其他错误
return error.message || '未知错误';
}
};
总结
技术要点回顾
- 文件名安全处理:过滤非法字符、长度限制、编码处理
- 动态文件名生成:模板化配置,支持业务字段和日期
- 下载进度追踪:使用 axios 的 onDownloadProgress
- 错误处理与重试:指数退避策略、错误分类
- 多场景适配:策略模式,易于扩展
适用场景
- 小文件下载(< 10MB):直接使用 Blob 方案
- 大文件下载(> 10MB):需要进度追踪和断点续传
- -批量下载:需要并发控制和队列管理
- 企业级应用:需要完整的错误处理和日志记录