文件上传使用分片,文件下载使用流式方案,同时还要实现进度条功能

好的,这是一个非常经典和完整的文件传输方案。我们将采用业界公认的最佳实践:

  • 文件上传前端分片 + 后端记录与合并,实现进度条和断点续传。
  • 文件下载后端流式响应 + 前端读取流,实现平滑进度条。
    这套组合拳能完美处理从小文件到几十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响应。前端通过fetchReadableStream实时接收数据并计算进度。

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. 为什么会这样?(技术原理)

  1. 数据在内存中:前端代码中,所有从服务器流式接收到的数据块(Uint8Array)都被暂时存储在一个名为 chunks 的数组里。
  2. Blob 对象的创建const blob = new Blob(chunks); 这行代码会遍历 chunks 数组,将所有数据块在内存中拼接成一个完整的 Blob 对象。只有当所有数据都到达并准备好后,这个 Blob 才能被创建。
  3. 下载链接的生成URL.createObjectURL(blob) 会为这个内存中的 Blob 对象生成一个临时的、以 blob: 开头的URL。
  4. 下载的触发:最后,<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生成,仅供参考和借鉴

posted @ 2025-11-11 04:39  juyss  阅读(19)  评论(0)    收藏  举报