JS文件断点续传

如何在 JS 中实现文件的断点续传,这里所说的续传是指文件上传。文件下载直接通过浏览器的原生的断点能力进行实现,除非有业务场景需要支持,比如桌面应用可能需要 JS 进行处理,可以通过Range Header 实现。

所谓续传,是当网络出现抖动,或者文件较大上传时间较长,如果出现失败,可以支持从上一次失败的位置继续上传。怎么实现续传呢,比较的简单的想法,就是客户端记住失败那一时刻已经成功上传文件内容的位置。但是,这个位置其实不大好确定,由于客户端和服务器是通过网络进行连接的,具体到了什么位置需要服务器同步给客户端,这样的实现比较复杂。采用文件分片是无状态的实现方案,大文件切成一片一片上传,出现问题时就重新上传失败分片,这样就不需要上传位置信息的同步。代码实现如下,最关键的就是JS 的文件切分 API。

创建resumable.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Resumable Upload</title>
</head>
<body>
    <input type="file" id="fileInput">
    <button id="uploadButton">上传</button>

    <script src="resumableUpload.js"></script>
</body>
</html>

创建 resumableUpload.js

let chunkIndex = 0;
let position = 0;
document.getElementById('fileInput').addEventListener("change", function(){
    chunkIndex = 0;
    position = 0;
    
})

document.getElementById('uploadButton').addEventListener('click', function() {
    const file = document.getElementById('fileInput').files[0];
    const chunkSize = 1024 * 1024; // 1MB chunk size
    let totalChunks = Math.ceil(file.size/chunkSize);

    function uploadChunk() {
        if (position >= file.size) {
            console.log('Upload complete');
            chunkIndex = 0;
            position = 0;
            return;
        }

        const chunk = file.slice(position, position + chunkSize); //关键 API
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('chunk', chunkIndex);
        formData.append('filename', file.name);
        formData.append('totalChunks', totalChunks);

        const xhr = new XMLHttpRequest();
        xhr.open('POST', 'http://localhost:8090/test/resumeUpload', true);
        xhr.onload = function () {
            if (xhr.status === 200) {
                console.log('Chunk ' + chunkIndex + ' uploaded');
                position += chunkSize;
                chunkIndex++;
                uploadChunk(); // Upload next chunk
            } else {
                console.error('Upload failed for chunk ' + chunkIndex);
            }
        };
        xhr.send(formData);
    }

    uploadChunk(); // Start the upload
});

如果想要提高性能,可以通过异步进行代码改造,通过 await 加 fetch 进行实现,代码如下,设置了 4 个并发,可根据实际情况进行调整,如果服务器是 http1.1 协议,那么最多只能 6-8 个:

resumableUploadAsync.js

async function uploadFile() {
    const file = document.getElementById('fileInput').files[0];
    const chunkSize = 1 * 1024 * 1024; // 1MB
    let start = 0;
    const totalChunks = Math.ceil(file.size / chunkSize);
    let activeUploads = 0;
    const maxConcurrentUploads = 4;
    let nextChunk = 0;

    async function uploadChunk(chunkIndex) {
        const end = Math.min(start + chunkSize, file.size);
        const chunk = file.slice(start, end);
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('chunk', chunkIndex);
        formData.append('totalChunks', totalChunks);
        formData.append('filename', file.name); 

        try {
            const response = await fetch('http://localhost:8090/test/resumeUpload', {
                method: 'POST',
                body: formData
            });
            if (!response.ok) {
                throw new Error('Failed to upload chunk ' + chunkIndex);
            }
            console.log(`Chunk ${chunkIndex} uploaded successfully`);
        } catch (error) {
            console.error('Error:', error.message);
        } finally {
            activeUploads--;
            uploadNextChunks();
        }
    }

    function uploadNextChunks() {
        while (activeUploads < maxConcurrentUploads && nextChunk < totalChunks) {
            uploadChunk(nextChunk);
            start += chunkSize;
            nextChunk++;
            activeUploads++;
        }
    }

    uploadNextChunks(); // Start the first batch of uploads
}

document.getElementById('uploadButton').addEventListener('click', uploadFile);

后台代码实现,Java版本:

Controller:

@PostMapping("/resumeUpload")
    @CrossOrigin(origins = "*", maxAge = 3600)
    public String resumeUpload(@RequestParam("file") MultipartFile file,
                               @RequestParam("filename") String filename,
                               @RequestParam("chunk") int chunk,
                               @RequestParam("totalChunks") int totalChunks) {

        String filePath = fileStorageService.storeChunk(file, filename, chunk, totalChunks);

        if (fileStorageService.isUploadComplete(filename, totalChunks)) {
            fileStorageService.mergeFile(filename, totalChunks);
            return "File uploaded successfully: " + filePath;
        }

        return "Chunk uploaded successfully";


    }

Service:

import com.lundbeck.carbon.service.FileStorageService;
import lombok.SneakyThrows;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

/**
 * @author: wansj
 * @since: 2024-04-18
 **/
@Service
public class FileStorageServiceImpl implements FileStorageService {
    private final Path rootLocation = Paths.get("/tmp/uploads");

    @Override
    public String storeChunk(MultipartFile file, String filename, int chunk, int totalChunks) {
        try {
            Files.createDirectories(rootLocation);
            Path targetLocation = this.rootLocation.resolve(filename + "_" + chunk);
            file.transferTo(targetLocation.toFile());
            return targetLocation.toString();
        } catch (Exception e) {
            throw new RuntimeException("Could not store file " + filename, e);
        }
    }

    @Override
    @SneakyThrows
    public boolean isUploadComplete(String filename, int totalChunks) {
        return Files.list(rootLocation)
                .filter(path -> path.getFileName().toString().startsWith(filename))
                .count() == totalChunks;
    }

    @SneakyThrows
    @Override
    public void mergeFile(String filename, int totalChunks) {
        Path outputFile = rootLocation.resolve(filename);
        try (OutputStream out = Files.newOutputStream(outputFile, StandardOpenOption.CREATE,
                StandardOpenOption.APPEND)) {
            for (int i = 0; i < totalChunks; i++) {
                Path chunkFile = rootLocation.resolve(filename + "_" + i);
                Files.copy(chunkFile, out);
                Files.delete(chunkFile); 
            }
        }
    }
}
相关推荐
JUNAI_Strive_ving15 分钟前
番茄小说逆向爬取
javascript·python
落落落sss19 分钟前
MybatisPlus
android·java·开发语言·spring·tomcat·rabbitmq·mybatis
看到请催我学习24 分钟前
如何实现两个标签页之间的通信
javascript·css·typescript·node.js·html5
简单.is.good37 分钟前
【测试】接口测试与接口自动化
开发语言·python
twins352043 分钟前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
Yvemil71 小时前
MQ 架构设计原理与消息中间件详解(二)
开发语言·后端·ruby
程序员是干活的1 小时前
私家车开车回家过节会发生什么事情
java·开发语言·软件构建·1024程序员节
qiyi.sky1 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~1 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
哪 吒1 小时前
华为OD机试 - 几何平均值最大子数(Python/JS/C/C++ 2024 E卷 200分)
javascript·python·华为od