如何在 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);
}
}
}
}