模式切换
RuoYi-Vue-Plus 项目中实现文件并发分片上传、断点续传、秒传及分片下载功能
实现文件并发上传、断点续传、秒传及分片下载功能是一个比较基础但又复杂的任务,需要前后端技术共同配合实现。
不同工程师的实现方式可能会有所不同,本文主要介绍在 RuoYi-Vue-Plus 项目中实现文件并发分片上传、断点续传、秒传及分片下载功能。
分片上传演示:
分片下载演示:
分片上传流程
核心流程
- 前端计算分片 MD5 实现秒传
- 预签名 URL 直传 OSS 减轻服务器负担
- Redis 记录上传状态实现断点续传
- 后端仅处理元数据,数据直接传到OSS
分片上传时序图:
后端业务逻辑分析
关键设计点:
- 秒传功能:通过 MD5 校验实现,避免重复上传相同文件
- 断点续传:通过 Redis 记录上传进度,支持中断后继续上传
- 直接上传:使用预签名 URL 让客户端直接上传到 OSS,减轻服务器负担
- 安全性:预签名 URL 有时效性,防止未授权访问
在数据库设计中,添加原有表 sys_oss
中列 md5_digest
和 file_size
:
sql
alter table sys_oss
add column md5_digest varchar(32) not null comment '文件md5';
alter table sys_oss
add column file_size bigint(20) default null comment '文件大小(单位:字节)';
相应地,实体类 SysOss
、SysOssBo
和 SysOssVo
也要加上这两个字段:
java
/**
* 文件MD5
*/
private String md5Digest;
/**
* 文件大小(单位:字节)
*/
private Long fileSize;
后端分片上传时序图:
检查文件是否已存在(实现秒传功能)
- 客户端计算文件 MD5 并发送到服务端
- 服务端检查数据库中是否存在相同 MD5 的文件记录(笔者规定相同 MD5 值的文件为同一个文件)
- 如果存在则直接返回文件信息,实现秒传
java
/**
* 检查文件是否已上传,实现秒传功能
* 根据文件md5Digest查询数据库中是否有记录,如果有直接返回,并且判断originalFileName是否一致,如果不一致则更新
*
* @param md5Digest 文件的 MD5 摘要
* @param originalFileName 文件的原始名称
* @return SysOssVo 对象,包含文件信息
*/
public SysOssVo checkFileExists(String md5Digest, String originalFileName) {
SysOssVo sysOssVo = baseMapper.selectVoOne(Wrappers.<SysOss>lambdaQuery().eq(SysOss::getMd5Digest, md5Digest));
if (ObjectUtil.isNotNull(sysOssVo)) {
if (!sysOssVo.getOriginalName().equals(originalFileName)) {
sysOssVo.setOriginalName(originalFileName);
SysOss sysOss = new SysOss();
sysOss.setOssId(sysOssVo.getOssId());
sysOss.setOriginalName(originalFileName);
baseMapper.updateById(sysOss);
}
return sysOssVo;
}
return null;
}
初始化分片上传
- 客户端发送文件基本信息(文件名、MD5、大小等)
- 服务端创建分片上传任务,生成 Upload ID
- 将任务信息存入 Redis(包括 Upload ID、文件名等)
java
/**
* 初始化分片上传任务
*
* @param multipartBo 初始化分片的参数对象
* @return 分片上传对象信息
*/
@Override
public MultipartVo initiateMultipart(MultipartBo multipartBo) {
OssClient storage = OssFactory.instance();
String md5Digest = multipartBo.getMd5Digest();
String ossKey = GlobalConstants.OSS_CONTINUATION + LoginHelper.getUserId() + md5Digest;
MultipartVo multipartVo = RedisUtils.getCacheObject(ossKey);
if (ObjectUtil.isNotNull(multipartVo)) {
// 获取上传分段进度
List<PartUploadResult> listParts = storage.listParts(multipartVo.getFilename(), multipartVo.getUploadId(), null, null);
multipartVo.setPartUploadList(listParts.stream()
.map(x -> new MultipartVo.PartUploadResult(x.getPartNumber(), x.getETag()))
.collect(Collectors.toList()));
} else {
multipartVo = new MultipartVo();
String originalName = multipartBo.getOriginalName();
String suffix = StringUtils.substring(originalName, originalName.lastIndexOf("."), originalName.length());
UploadResult uploadResult = storage.initiateMultipart(suffix);
multipartVo.setFilename(uploadResult.getFilename());
multipartVo.setUploadId(uploadResult.getUploadId());
multipartVo.setMd5Digest(md5Digest);
multipartVo.setOriginalName(originalName);
multipartVo.setSuffix(suffix);
RedisUtils.setCacheObject(ossKey, multipartVo, Duration.ofHours(1));
RedisUtils.setCacheObject(GlobalConstants.OSS_MULTIPART + multipartVo.getUploadId(), multipartVo, Duration.ofHours(1));
}
return multipartVo;
}
java
/**
* 创建分片上传任务
*
* @param key 在 Amazon S3 中的对象键
* @return 包含上传后的文件信息
*/
public UploadResult createMultipartUpload(String key) {
try {
String uploadId = client.createMultipartUpload(
x -> x.bucket(properties.getBucketName())
.key(key)
.build()
).join().uploadId();
return UploadResult.builder().filename(key).uploadId(uploadId).build();
} catch (Exception e) {
// 捕获异常并抛出自定义异常
throw new OssException("创建分片上传任务失败,请检查配置信息:[" + e.getMessage() + "]");
}
}
上传分片
- 客户端将文件分成多个分片(默认 5MB)
- 对每个分片请求服务端获取预签名 URL
- 使用预签名 URL 直接上传分片到 OSS
- 服务端记录已上传分片信息,可实现断点续传功能
java
/**
* 上传文件分片
*
* @param multipartBo 分段上传的参数对象
* @return 分片上传成功后的对象信息
*/
@Override
public MultipartVo uploadPart(MultipartBo multipartBo) {
String uploadId = multipartBo.getUploadId();
Integer partNumber = multipartBo.getPartNumber();
MultipartVo multipartVo = RedisUtils.getCacheObject(GlobalConstants.OSS_MULTIPART + uploadId);
if (ObjectUtil.isNull(multipartVo)) {
throw new ServiceException("该分片任务不存在!");
}
OssClient storage = OssFactory.instance();
String privateUrl = storage.uploadPartFutures(multipartVo.getFilename(), uploadId, partNumber, 60 * 60 * 72);
multipartVo.setPreSignUrl(privateUrl);
multipartVo.setPartNumber(partNumber);
return multipartVo;
}
java
/**
* 生成预签名的分片上传 URL
*
* @param key 在 Amazon S3 中的对象键
* @param uploadId 分片上传任务的 Upload ID
* @param partNumber 分片编号(从1开始递增)
* @param second 签名持续时间(秒)
* @return 预签名 URL 字符串
*/
public String uploadPartFutures(String key, String uploadId, Integer partNumber, Integer second) {
URL url = presigner.presignUploadPart(
x -> x.signatureDuration(Duration.ofSeconds(second))
.uploadPartRequest(
y -> y.bucket(properties.getBucketName())
.key(key)
.uploadId(uploadId)
.partNumber(partNumber)
.build()
).build()
).url();
return url.toString();
}
查询上传进度
- 客户端可以查询已上传的分片列表
- 用于断点续传或进度显示
java
/**
* 获取上传分片进度
*
* @param multipartBo 分片上传对象信息
* @return 分片上传对象信息
*/
@Override
public MultipartVo uploadPartList(MultipartBo multipartBo) {
String uploadId = multipartBo.getUploadId();
MultipartVo multipartVo = RedisUtils.getCacheObject(GlobalConstants.OSS_MULTIPART + uploadId);
if (ObjectUtil.isNull(multipartVo)) {
throw new ServiceException("该分片任务不存在!");
}
OssClient storage = OssFactory.instance();
List<PartUploadResult> listParts = storage.listParts(multipartVo.getFilename(), uploadId, multipartBo.getMaxParts(), multipartBo.getPartNumberMarker());
multipartVo.setPartUploadList(listParts.stream()
.map(x -> new MultipartVo.PartUploadResult(x.getPartNumber(), x.getETag()))
.collect(Collectors.toList()));
return multipartVo;
}
java
/**
* 获取指定对象分片上传的分片列表
*
* @param key 在 Amazon S3 中的对象键
* @param uploadId 分片上传任务的 Upload ID
* @param maxParts 最大返回的分片数(默认为1000)
* @param partNumberMarker 分片编号的标记,用于分页查询(默认为0,表示从第一个分片开始查询)
* @return 包含分片上传结果信息的 PartUploadResult 对象列表
*/
public List<PartUploadResult> listParts(String key, String uploadId, Integer maxParts, Integer partNumberMarker) {
try {
List<Part> parts = client.listParts(
x -> x.bucket(properties.getBucketName())
.key(key)
.uploadId(uploadId)
.maxParts(maxParts != null ? maxParts : 1000)
.partNumberMarker(partNumberMarker != null ? partNumberMarker : 0)
.build()).join().parts();
return parts.stream()
.map(x -> PartUploadResult.builder()
.partNumber(x.partNumber())
.eTag(x.eTag())
.build())
.collect(Collectors.toList());
} catch (Exception e) {
// 捕获异常并抛出自定义异常
throw new OssException("获取分片列表失败,请检查配置信息:[" + e.getMessage() + "]");
}
}
完成上传
- 客户端发送所有分片信息(分片编号和 eTag)
- 服务端通知 OSS 合并所有分片
- 将文件信息存入数据库
- 清理 Redis 中的临时信息
java
/**
* 合并分片
*
* @param multipartBo 分片上传对象信息
* @return OSS对象存储视图对象
*/
@Override
public SysOssVo completeMultipartUpload(MultipartBo multipartBo) {
String uploadId = multipartBo.getUploadId();
String uploadIdKey = GlobalConstants.OSS_MULTIPART + uploadId;
MultipartVo multipartVo = RedisUtils.getCacheObject(uploadIdKey);
if (ObjectUtil.isNull(multipartVo)) {
throw new ServiceException("该分片任务不存在!");
}
List<PartUploadResult> listParts = multipartBo.getPartUploadList().stream()
.map(x -> PartUploadResult.builder()
.partNumber(x.getPartNumber())
.eTag(x.getETag())
.build())
.collect(Collectors.toList());
OssClient storage = OssFactory.instance();
UploadResult uploadResult = storage.completeMultipartUpload(multipartVo.getFilename(), uploadId, listParts);
// 保存文件信息
SysOssVo sysOssVo = buildResultEntity(multipartVo.getMd5Digest(), multipartBo.getFileSize(), multipartVo.getOriginalName(), multipartVo.getSuffix(), storage.getConfigKey(), uploadResult);
RedisUtils.deleteObject(uploadIdKey);
RedisUtils.deleteObject(GlobalConstants.OSS_CONTINUATION + LoginHelper.getUserId() + multipartVo.getMd5Digest());
return sysOssVo;
}
java
/**
* 完成分片上传任务
*
* @param key 在 Amazon S3 中的对象键
* @param uploadId 分片上传任务的 Upload ID
* @param partUploadResults 已完成的分片列表(必须是唯一且按照递增顺序排列,严格检查是否漏传)
* @return 包含上传后的文件信息
*/
public UploadResult completeMultipartUpload(String key, String uploadId, List<PartUploadResult> partUploadResults) {
if (CollUtil.isEmpty(partUploadResults)) {
throw new OssException("分片列表不能为空");
}
List<CompletedPart> completedParts = partUploadResults.stream()
.map(x -> CompletedPart.builder()
.partNumber(x.getPartNumber())
.eTag(x.getETag())
.build())
.collect(Collectors.toList());
try {
String eTag = client.completeMultipartUpload(
x -> x.bucket(properties.getBucketName())
.key(key)
.uploadId(uploadId)
.multipartUpload(y -> y.parts(completedParts)
.build())
.build()
).join().eTag();
// 提取上传结果中的 ETag,并构建一个自定义的 UploadResult 对象
return UploadResult.builder().url(getUrl() + StringUtils.SLASH + key).filename(key).eTag(eTag).build();
} catch (Exception e) {
// 捕获异常并抛出自定义异常
throw new OssException("合并文件失败,请检查配置信息:[" + e.getMessage() + "]");
}
}
取消上传
- 客户端可以取消正在进行的上传任务
- 服务端通知 OSS 中止上传并清理 Redis 信息
java
/**
* 取消正在上传的文件分片
*
* @param multipartBo 分片上传对象信息
* @return 是否取消成功
*/
@Override
public Boolean cancelUploading(MultipartBo multipartBo) {
String uploadId = multipartBo.getUploadId();
String uploadIdKey = GlobalConstants.OSS_MULTIPART + uploadId;
MultipartVo multipartVo = RedisUtils.getCacheObject(uploadIdKey);
if (ObjectUtil.isNull(multipartVo)) {
throw new ServiceException("该分片任务不存在!");
}
OssClient storage = OssFactory.instance();
storage.abortMultipartUpload(multipartVo.getFilename(), uploadId);
RedisUtils.deleteObject(uploadIdKey);
RedisUtils.deleteObject(GlobalConstants.OSS_CONTINUATION + LoginHelper.getUserId() + multipartVo.getMd5Digest());
return true;
}
java
/**
* 取消分片上传任务
*
* @param key 在 Amazon S3 中的对象键
* @param uploadId 分片上传任务的 Upload ID
*/
public void abortMultipartUpload(String key, String uploadId) {
try {
client.abortMultipartUpload(
x -> x.bucket(properties.getBucketName())
.key(key)
.uploadId(uploadId)
.build()
).join();
} catch (Exception e) {
// 捕获异常并抛出自定义异常
throw new OssException("取消分片上传任务失败,请检查配置信息:[" + e.getMessage() + "]");
}
}
客户端业务逻辑分析
客户端分片上传时序图:
文件选择和准备
- 用户选择文件后,前端创建文件对象,包含文件信息(名称、大小等)和上传状态typescript
/** * 处理文件选择 * @param e 事件对象 */ const handleFileChange = (e: Event) => { if (props.disabled) return; // 禁用状态下不处理 const input = e.target as HTMLInputElement; if (!input.files || input.files.length === 0) return; const newFiles = Array.from(input.files).map((file) => ({ id: Date.now() + '-' + Math.random().toString(36).substr(2, 9), file, name: file.name, size: file.size, percent: 0, status: 'waiting' as const, chunks: [], currentChunkIndex: 0, partUploadList: [] })); files.value = [...files.value, ...newFiles]; input.value = ''; // 重置input,允许重复选择相同文件 };
- 根据文件大小决定是否使用分片上传(默认 5MB 以下直接上传)typescript
// 小于最小分片大小 MIN_CHUNK_SIZE 的文件使用直接上传,否则使用分片上传 if (fileObj.file.size < MIN_CHUNK_SIZE * 1024 * 1024) { handleDirectUpload(fileObj, index); } else { handleMultipartUpload(fileObj, index); }
秒传检查
- 计算第一个分片的 MD5 值javascript
// 准备分片 fileObj.chunks = []; for (let i = 0; i < fileObj.file.size; i += chunkSizeInBytes.value) { const chunk = fileObj.file.slice(i, i + chunkSizeInBytes.value); fileObj.chunks.push(chunk); } // 计算第一个分片的MD5 const chunk0 = await fileObj.chunks[0].arrayBuffer(); const uint8array0 = new Uint8Array(chunk0); const firstChunkMd5 = await md5(uint8array0);
- 发送检查请求到服务端,验证文件是否已存在javascript
// 检查文件是否已存在 const checkRes = await multipartUpload({ ossStatus: 'check', originalName: fileObj.name, md5Digest: firstChunkMd5 });
- 如果存在则直接完成上传,实现秒传javascript
if (checkRes.data) { fileObj.percent = 100; fileObj.status = 'success'; fileObj.ossId = checkRes.data.ossId; // 保存已有文件的ossId updateModelValue(); return; // 已存在的文件直接返回,不继续上传 } // 如果不存在,则继续分片上传
初始化分片上传
- 发送初始化请求获取 Upload ID
- 服务端返回已上传的分片信息(用于断点续传)typescript
// 初始化上传 const resp = await multipartUpload({ ossStatus: 'initiate', originalName: fileObj.name, md5Digest: firstChunkMd5 }); fileObj.uploadId = resp.data.uploadId; fileObj.partUploadList = resp.data.partUploadList || []; const startPartNumber = fileObj.partUploadList.length > 0 ? fileObj.partUploadList.length + 1 : 1; if (startPartNumber > fileObj.chunks.length) { fileObj.percent = 100; fileObj.status = 'success'; updateModelValue(); return; } fileObj.currentChunkIndex = startPartNumber - 1;
分片上传
- 将文件分割为多个分片(默认 5MB)
- 为每个分片请求预签名 URLjavascript
const uploadResp = await multipartUpload({ ossStatus: 'upload', uploadId: fileObj.uploadId, partNumber: i + 1 }); const preSignUrl = uploadResp.data.preSignUrl;
- 使用预签名 URL 直接上传分片到 OSSjavascript
const minioResp = await axios.put(preSignUrl, chunk, { headers: { 'Content-Type': 'application/octet-stream' }, signal: fileObj.controller?.signal });
- 记录已上传分片信息javascript
const eTag = minioResp.headers['etag']; if (!fileObj.partUploadList) fileObj.partUploadList = []; fileObj.partUploadList.push({ partNumber: i + 1, eTag: eTag });
完成上传
- 所有分片上传完成后,发送完成请求javascript
const completeRes = await multipartUpload({ ossStatus: 'complete', uploadId: fileObj.uploadId, partUploadList: fileObj.partUploadList, fileSize: fileObj.size });
- 服务端合并分片并返回文件信息
- 更新前端状态为上传成功javascript
// 只更新当前文件的状态 fileObj.status = 'success'; fileObj.ossId = completeRes.data.ossId; fileObj.percent = 100;
控制功能
- 暂停/继续:可暂停正在上传的分片,之后继续上传typescript
/** * 暂停上传 * @param index 文件索引 */ const pauseUpload = (index: number) => { const fileObj = files.value[index]; if (fileObj && fileObj.status === 'uploading') { fileObj.controller?.abort(); fileObj.status = 'paused'; } }; /** * 继续上传 * @param index 文件索引 */ const resumeUpload = async (index: number) => { const fileObj = files.value[index]; if (fileObj && fileObj.status === 'paused') { fileObj.status = 'uploading'; fileObj.controller = new AbortController(); await uploadChunks(index); } };
- 取消:中止上传并清理资源typescript
/** * 移除已上传的文件 * @param index 文件索引 */ const removeUploadedFile = async (index: number) => { console.log('removeUploadedFile', index); console.log('files', files.value); const fileObj = files.value[index]; if (!fileObj) return; try { if (fileObj.ossId) { // 调用删除接口 const { code } = await delOss(fileObj.ossId); if (code === 200) { // 删除成功后从数组中移除 files.value.splice(index, 1); updateModelValue(); // 更新modelValue } else { ElMessage.error('文件删除失败'); } } else { // 如果没有ossId,直接移除 files.value.splice(index, 1); } } catch (error) { console.error('RemoveUploadedFile Error:', error); if (error instanceof Error) { ElMessage.error(`文件删除失败: ${error.message}`); } } }; /** * 取消正在上传的文件 * @param index 文件索引 */ const cancelUploading = async (index: number) => { const fileObj = files.value[index]; if (!fileObj) return; fileObj.status = 'cancelling'; fileObj.controller?.abort(); try { if (fileObj.uploadId) { // 调用取消上传接口 const { data } = await multipartUpload({ ossStatus: 'cancel', uploadId: fileObj.uploadId }); if (!data) { ElMessage.error('取消上传失败'); } } // 无论是否有uploadId都移除文件项 files.value.splice(index, 1); updateModelValue(); // 更新modelValue } catch (error) { console.error('CancelUploading Error:', error); if (error instanceof Error) { ElMessage.error(`取消上传失败: ${error.message}`); } } };
- 重试:上传失败后可重新尝试typescript
/** * 重试上传 * @param index 文件索引 */ const retryUpload = async (index: number) => { const fileObj = files.value[index]; if (fileObj && fileObj.status === 'error') { fileObj.percent = 0; fileObj.status = 'waiting'; await startUpload(index); } };
分片下载流程
核心流程
- 范围请求(Range Header)实现分片获取
- 多分片并发下载提高速度
- 前端完成分片校验和合并
- 进度实时更新显示
分片下载时序图:
后端业务逻辑分析
后端分片下载时序图:
初始化下载
- 客户端请求文件下载初始化信息
- 服务端返回文件大小和推荐的分片大小(默认 5MB)
java
/**
* 初始化分片下载
* @param ossId 文件ID
*/
@Override
public DownloadInitVo initChunkDownload(Long ossId) {
SysOssVo sysOss = getById(ossId);
if (ObjectUtil.isNull(sysOss)) {
throw new ServiceException("文件不存在");
}
DownloadInitVo vo = new DownloadInitVo();
vo.setFileSize(sysOss.getFileSize());
vo.setFileName(sysOss.getOriginalName());
vo.setChunkSize(OssConstant.DEFAULT_CHUNK_SIZE);
return vo;
}
分片下载
- 客户端根据文件大小计算分片范围
- 请求指定范围的分片数据
- 服务端从 OSS 获取指定范围的数据流
- 客户端合并所有分片数据
java
/**
* 下载文件分片
* @param ossId 文件ID
* @param chunkIndex 分片索引
* @param chunkSize 分片大小
*/
@Override
public void downloadChunk(Long ossId, int chunkIndex, long chunkSize, HttpServletResponse response) throws IOException {
SysOssVo sysOss = getById(ossId);
OssClient storage = OssFactory.instance(sysOss.getService());
long start = chunkIndex * chunkSize;
long end = Math.min(start + chunkSize - 1, sysOss.getFileSize() - 1);
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + sysOss.getFileSize());
response.setContentType("application/octet-stream");
storage.downloadRange(sysOss.getFileName(), response.getOutputStream(), start, end);
}
java
/**
* 范围下载
* @param key 对象Key
* @param out 输出流
* @param start 起始位置
* @param end 结束位置
*/
public void downloadRange(String key, OutputStream out, long start, long end) {
try {
Download<ResponseInputStream<GetObjectResponse>> download = transferManager.download(DownloadRequest.builder()
.getObjectRequest(GetObjectRequest.builder()
.bucket(properties.getBucketName())
.key(key)
.range("bytes=" + start + "-" + end)
.build())
.responseTransformer(AsyncResponseTransformer.toBlockingInputStream())
.build());
try (ResponseInputStream<GetObjectResponse> res = download.completionFuture().join().result()) {
res.transferTo(out);
}
} catch (Exception e) {
throw new OssException("分片下载失败: " + e.getMessage());
}
}
客户端业务逻辑分析
客户端分片下载时序图:
初始化下载
- 请求文件信息(大小、名称等)typescript
// 1. 初始化下载 const initRes = await axios.get(initUrl, { headers: globalHeaders() }); const { fileSize, fileName, chunkSize } = initRes.data.data;
- 计算分片数量和大小typescript
const start = chunkIndex * chunkSize; const end = Math.min(start + chunkSize - 1, fileSize - 1); const currentChunkSize = end - start + 1;
并发下载
- 使用多个并发请求下载不同分片
- 跟踪每个分片的下载进度
typescript
// 5. 下载任务
const downloadTask = async (chunkIndex: number) => {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize - 1, fileSize - 1);
const currentChunkSize = end - start + 1;
progressMap.set(chunkIndex, 0);
try {
const response = await axios.get(chunkDownloadUrl, {
params: {
chunkIndex,
chunkSize: currentChunkSize
},
responseType: 'blob',
headers: {
...globalHeaders(),
'Range': `bytes=${start}-${end}`, // 明确指定范围
'Cache-Control': 'no-cache'
},
onDownloadProgress: (progressEvent) => {
// 实时更新该分片的下载量
progressMap.set(chunkIndex, progressEvent.loaded);
updateProgress();
}
});
return {
index: chunkIndex,
data: response.data
};
} catch (error) {
console.error(`分片 ${chunkIndex} 下载失败:`, error);
throw error;
}
};
// 6. 控制并发下载
const CONCURRENCY = 3;
const chunkCount = Math.ceil(fileSize / chunkSize);
const chunks: Array<{ index: number; data: Blob }> = [];
for (let i = 0; i < chunkCount; i += CONCURRENCY) {
const currentChunks = Array.from({ length: Math.min(CONCURRENCY, chunkCount - i) }, (_, j) => downloadTask(i + j));
const results = await Promise.all(currentChunks);
chunks.push(...results);
}
合并文件
- 所有分片下载完成后按顺序合并
- 使用 FileSaver 保存完整文件
typescript
// 7. 合并文件
downloadLoadingInstance.setText('文件合并中...');
chunks.sort((a, b) => a.index - b.index);
const fullBlob = new Blob(
chunks.map((chunk) => chunk.data),
{ type: 'application/octet-stream' }
);
// 8. 保存文件
FileSaver.saveAs(fullBlob, fileName);
downloadLoadingInstance.setText('下载完成!');
进度显示
- 实时显示整体下载进度
- 提供取消功能
typescript
// 4. 进度更新方法
const updateProgress = () => {
const totalDownloaded = Array.from(progressMap.values()).reduce((a, b) => a + b, 0);
const percent = Math.round((totalDownloaded / fileSize) * 100);
// ${formatFileSize(totalDownloaded)}/${formatFileSize(fileSize)} 可显示下载大小信息,格式如:830MB/1.2GB
downloadLoadingInstance.setText(`下载中... ${percent}%`);
};