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); 
            }
        }
    }
}
相关推荐
开心工作室_kaic3 分钟前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿22 分钟前
webWorker基本用法
前端·javascript·vue.js
cy玩具42 分钟前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
萧鼎1 小时前
Python并发编程库:Asyncio的异步编程实战
开发语言·数据库·python·异步
学地理的小胖砸1 小时前
【一些关于Python的信息和帮助】
开发语言·python
疯一样的码农1 小时前
Python 继承、多态、封装、抽象
开发语言·python
^velpro^1 小时前
数据库连接池的创建
java·开发语言·数据库
秋の花1 小时前
【JAVA基础】Java集合基础
java·开发语言·windows
小松学前端1 小时前
第六章 7.0 LinkList
java·开发语言·网络
可峰科技1 小时前
斗破QT编程入门系列之二:认识Qt:编写一个HelloWorld程序(四星斗师)
开发语言·qt