文件上传使用分片,文件下载使用流式方案,同时还要实现进度条功能
好的,这是一个非常经典和完整的文件传输方案。我们将采用业界公认的最佳实践:
- 文件上传:前端分片 + 后端记录与合并,实现进度条和断点续传。
- 文件下载:后端流式响应 + 前端读取流,实现平滑进度条。
这套组合拳能完美处理从小文件到几十GB大文件的各种场景。
一、 文件上传:分片上传 + 进度条
核心思路:将大文件在浏览器端切成多个小块,并发上传,后端接收这些小块并在全部到齐后合并成一个完整文件。
1. 前端实现(JavaScript)
前端是进度计算和任务调度的核心。
<!-- 简单的HTML结构 -->
<input type="file" id="fileInput" />
<button onclick="uploadFile()">上传</button>
<progress id="uploadProgress" value="0" max="100"></progress>
<span id="progressText">0%</span>
async function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) return;
const CHUNK_SIZE = 5 * 1024 * 1024; // 每片5MB
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
let uploadedChunks = 0;
// 1. 生成文件唯一标识(用于后端区分不同文件)
const fileHash = await calculateFileHash(file); // 你需要实现一个hash计算函数,如spark-md5
// 2. 并发上传所有分片
const uploadPromises = [];
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(file.size, start + CHUNK_SIZE);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileHash', fileHash);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('fileName', file.name);
const uploadPromise = axios.post('/api/upload/chunk', formData).then(() => {
uploadedChunks++;
// 3. 每完成一个分片,更新进度
const progress = (uploadedChunks / totalChunks) * 100;
document.getElementById('uploadProgress').value = progress;
document.getElementById('progressText').innerText = `${progress.toFixed(2)}%`;
});
uploadPromises.push(uploadPromise);
}
try {
// 4. 等待所有分片上传完成
await Promise.all(uploadPromises);
// 5. 通知后端合并文件
await axios.post('/api/upload/merge', {
fileHash: fileHash,
fileName: file.name,
totalChunks: totalChunks
});
alert('文件上传成功!');
} catch (error) {
console.error('上传失败:', error);
alert('上传失败,请重试!');
}
}
// 辅助函数:使用spark-md5库计算文件hash
async function calculateFileHash(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const chunkSize = 2 * 1024 * 1024; // 2MB
let currentChunk = 0;
const chunks = Math.ceil(file.size / chunkSize);
reader.onload = e => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
reader.onerror = reject;
function loadNext() {
const start = currentChunk * chunkSize;
const end = Math.min(file.size, start + chunkSize);
reader.readAsArrayBuffer(file.slice(start, end));
}
loadNext();
});
}
2. 后端实现(Spring Boot)
后端负责接收、存储和合并。
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.*;
import java.util.Map;
@RestController
public class FileUploadController {
// 临时分片存储目录
private final Path TEMP_DIR = Paths.get("temp-uploads");
// 最终文件存储目录
private final Path FINAL_DIR = Paths.get("uploads");
public FileUploadController() throws IOException {
Files.createDirectories(TEMP_DIR);
Files.createDirectories(FINAL_DIR);
}
/**
* 接收文件分片
*/
@PostMapping("/api/upload/chunk")
public ResponseEntity<?> uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileHash") String fileHash,
@RequestParam("chunkIndex") int chunkIndex) {
try {
// 使用 fileHash-chunkIndex 作为临时文件名,确保唯一性和顺序
Path chunkPath = TEMP_DIR.resolve(fileHash + "-" + chunkIndex);
file.transferTo(chunkPath);
return ResponseEntity.ok().build();
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("分片上传失败");
}
}
/**
* 合并所有分片
*/
@PostMapping("/api/upload/merge")
public ResponseEntity<?> mergeFile(@RequestBody Map<String, String> request) {
String fileHash = request.get("fileHash");
String fileName = request.get("fileName");
int totalChunks = Integer.parseInt(request.get("totalChunks"));
Path targetFile = FINAL_DIR.resolve(fileName);
try (FileOutputStream fos = new FileOutputStream(targetFile.toFile())) {
// 按顺序读取并写入所有分片
for (int i = 0; i < totalChunks; i++) {
Path chunkFile = TEMP_DIR.resolve(fileHash + "-" + i);
Files.copy(chunkFile, fos);
Files.delete(chunkFile); // 合并后删除临时分片
}
return ResponseEntity.ok("文件合并成功");
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("文件合并失败");
}
}
}
二、 文件下载:流式响应 + 进度条
核心思路:后端不将文件读入内存,而是以流的形式写入HTTP响应。前端通过fetch的ReadableStream实时接收数据并计算进度。
1. 后端实现(Spring Boot)
使用StreamingResponseBody可以实现完美的流式下载,内存占用极低。
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@RestController
public class FileDownloadController {
private final Path FILE_DIR = Paths.get("uploads");
@GetMapping("/api/download")
public ResponseEntity<StreamingResponseBody> downloadFile(@RequestParam String fileName) {
Path filePath = FILE_DIR.resolve(fileName).normalize();
if (!Files.exists(filePath) || !Files.isReadable(filePath)) {
return ResponseEntity.notFound().build();
}
StreamingResponseBody stream = outputStream -> {
try (InputStream inputStream = Files.newInputStream(filePath)) {
byte[] buffer = new byte[4096]; // 4KB buffer
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
// 可以在这里记录日志
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.body(stream);
}
}
2. 前端实现(JavaScript)
前端通过fetch获取响应流,并实时计算下载进度。
<!-- 简单的HTML结构 -->
<button onclick="downloadFile('your-large-file.zip')">下载文件</button>
<progress id="downloadProgress" value="0" max="100"></progress>
<span id="downloadProgressText">0%</span>
async function downloadFile(fileName) {
try {
const response = await fetch(`/api/download?fileName=${encodeURIComponent(fileName)}`);
if (!response.ok) {
throw new Error(`下载失败: ${response.statusText}`);
}
// 1. 从响应头获取文件总大小
const contentLength = response.headers.get('Content-Length');
const totalBytes = parseInt(contentLength, 10);
let receivedBytes = 0;
// 2. 获取响应体流
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value); // value 是一个 Uint8Array
receivedBytes += value.length;
// 3. 计算并更新进度
if (totalBytes) {
const progress = (receivedBytes / totalBytes) * 100;
document.getElementById('downloadProgress').value = progress;
document.getElementById('downloadProgressText').innerText = `${progress.toFixed(2)}%`;
}
}
// 4. 所有数据接收完毕,合并并触发浏览器下载
const blob = new Blob(chunks);
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl); // 释放内存
} catch (error) {
console.error(error);
alert('下载出错!');
}
}
总结
这套方案的优势非常明显:
- 高性能:无论是上传还是下载,都避免了在内存中处理整个大文件,内存占用极低。
- 用户体验好:实时、平滑的进度条,让用户清楚地知道传输状态。
- 高可靠性:上传支持断点续传(前端记录已上传分片即可),下载也易于扩展支持断点续传(后端需支持
Range请求,StreamingResponseBody方案已天然兼容)。 - 可扩展性强:这是处理大文件传输的工业级标准方案,可以在此基础上轻松添加更多功能,如秒传(通过文件hash判断文件是否存在)、并发控制等。
所有数据接收完毕,合并后,才会触发浏览器下载吗
这是一个非常好的问题,也是理解这个方案的关键点。
是的,对于上面提供的下载代码,确实是“所有数据接收完毕,合并后,才会触发浏览器下载”。
让我详细解释一下这个过程,以及为什么会这样,以及如何改变它。
1. 为什么会这样?(技术原理)
- 数据在内存中:前端代码中,所有从服务器流式接收到的数据块(
Uint8Array)都被暂时存储在一个名为chunks的数组里。 Blob对象的创建:const blob = new Blob(chunks);这行代码会遍历chunks数组,将所有数据块在内存中拼接成一个完整的Blob对象。只有当所有数据都到达并准备好后,这个Blob才能被创建。- 下载链接的生成:
URL.createObjectURL(blob)会为这个内存中的Blob对象生成一个临时的、以blob:开头的URL。 - 下载的触发:最后,
<a>标签的click()事件会告诉浏览器:“请从这个blob:URL 下载一个文件”。浏览器此时才开始从这个内存地址读取数据并保存到磁盘。
简单来说,这个流程是:网络流 -> 前端内存 -> Blob -> 浏览器下载。 因为中间经过了“前端内存”这一站,所以必须等数据全部到齐才能进行下一步。
2. 这种方式的优缺点
- 优点:
- 兼容性好:这种方式在几乎所有现代浏览器中都能稳定工作。
- 逻辑简单:代码逻辑清晰,先收集,再处理。
- 缺点:
- 内存占用高:这是最大的问题! 如果下载一个20G的文件,那么这20G的数据会全部加载到浏览器的内存中,然后再写入磁盘。这极有可能导致浏览器崩溃,甚至整个系统卡死。
- 延迟感:用户必须等待整个文件下载完成后,才能看到浏览器的“另存为”对话框。对于大文件,这个等待时间会非常长,体验很差。
3. 更好的方案:流式写入磁盘(Stream Saver)
为了解决内存问题,我们需要绕过浏览器的内存,让数据从网络直接流向磁盘。这需要借助一个强大的库:StreamSaver.js。
它的原理是利用了浏览器的 Service Worker 来拦截网络流,并直接将数据写入一个可写的文件流中,从而完全绕过了页面主线程的内存限制。
如何使用 StreamSaver.js 实现真正的流式下载
1. 引入库
<script src="https://cdn.jsdelivr.net/npm/stream-saver@2.0.6/StreamSaver.min.js"></script>
2. 修改前端下载代码
async function downloadFileStream(fileName) {
const fileStream = streamSaver.createWriteStream(fileName);
const writer = fileStream.getWriter();
try {
const response = await fetch(`/api/download?fileName=${encodeURIComponent(fileName)}`);
if (!response.ok) {
throw new Error(`下载失败: ${response.statusText}`);
}
const contentLength = response.headers.get('Content-Length');
const totalBytes = parseInt(contentLength, 10);
let receivedBytes = 0;
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 将数据块直接写入文件流,而不是存入数组
writer.write(value);
receivedBytes += value.length;
// 进度条逻辑保持不变
if (totalBytes) {
const progress = (receivedBytes / totalBytes) * 100;
document.getElementById('downloadProgress').value = progress;
document.getElementById('downloadProgressText').innerText = `${progress.toFixed(2)}%`;
}
}
// 关闭写入流
writer.close();
console.log('文件下载完成!');
} catch (error) {
// 如果出错,确保关闭流并取消下载
writer.abort();
console.error('下载出错:', error);
alert('下载出错!');
}
}
使用 StreamSaver.js 的变化
- 内存占用极低:数据不再存储在
chunks数组中,而是通过writer.write()直接被写入磁盘。无论文件多大,内存占用都只会有一个小小的缓冲区大小。 - 即时响应:一旦开始下载,浏览器会立即弹出“另存为”对话框,用户可以马上选择保存位置。下载过程是实时写入的。
- 后端代码无需改动:后端的流式响应代码完全兼容,不需要任何修改。
总结与对比
| 特性 | 原始方案(内存合并) | StreamSaver.js 方案(流式写入) |
|---|---|---|
| 触发下载时机 | 文件全部下载到内存后 | 下载开始时立即触发 |
| 内存占用 | 极高(等于文件大小) | 极低(恒定小缓冲区) |
| 用户体验 | 差(需长时间等待) | 优(即时响应,实时写入) |
| 稳定性 | 差(大文件易崩溃) | 高(适合任意大小文件) |
| 实现复杂度 | 简单 | 稍复杂(需引入第三方库) |
| 最终建议: |
- 如果你的应用只处理小文件(比如几十MB以内),原始的内存合并方案简单可用。
- 但只要你的应用有可能处理大文件(比如几百MB或更大),强烈建议使用 StreamSaver.js 方案。这才是处理大文件下载的正确、健壮的方式,能提供最佳的用户体验和稳定性。
以上内容由AI生成,仅供参考和借鉴

浙公网安备 33010602011771号