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); 
            }
        }
    }
}
相关推荐
老妪力虽衰1 分钟前
零基础的小白也能通过AI搭建自己的网页应用
前端
该用户已不存在1 分钟前
Node.js后端开发必不可少的7个核心库
javascript·后端·node.js
褪色的笔记簿4 分钟前
在 Vue 项目里管理弹窗组件:用 ref 还是用 props?
前端·vue.js
Danny_FD5 分钟前
使用Taro实现微信小程序仪表盘:使用canvas实现仪表盘(有仪表盘背景,也可以用于Web等)
前端·taro·canvas
IMPYLH10 分钟前
Lua 的 IO (输入/输出)模块
开发语言·笔记·后端·lua
普通网友11 分钟前
Objective-C 类的方法重载与重写:区别与正确使用场景
开发语言·ios·objective-c
掘金安东尼14 分钟前
VSCode V1.107 发布(2025 年 11 月)
前端·visual studio code
一只小阿乐18 分钟前
前端vue3 web端中实现拖拽功能实现列表排序
前端·vue.js·elementui·vue3·前端拖拽
喵了meme20 分钟前
C语言实战6
c语言·开发语言
AAA阿giao23 分钟前
从“操纵绳子“到“指挥木偶“:Vue3 Composition API 如何彻底改变前端开发范式
开发语言·前端·javascript·vue.js·前端框架·vue3·compositionapi