1. 核心思路
-
分片上传:将大文件分割成多个小文件(chunk),逐个上传。
-
记录上传进度:通过本地存储(如
localStorage
)或服务端记录已上传的分片。 -
断点续传:上传中断后,重新上传时只上传未完成的分片。
-
合并文件:所有分片上传完成后,通知服务端合并文件。
2. 实现步骤
1) 前端分片上传
-
使用
File
对象的slice
方法将文件分割成多个分片。 -
通过
FormData
将分片上传到服务端。
2) 记录上传进度
-
每个分片上传成功后,记录已上传的分片信息(如分片索引、文件唯一标识等)。
-
可以使用
localStorage
或服务端存储记录上传进度。
3) 断点续传
-
重新上传时,先检查已上传的分片,跳过已上传的部分。
-
只上传未完成的分片。
4) 合并文件
-
所有分片上传完成后,通知服务端合并文件。
3. 代码实现
前端代码(原生JS)
class FileUploader {
constructor(file, chunkSize = 5 * 1024 * 1024) { // 默认分片大小为5MB
this.file = file;
this.chunkSize = chunkSize;
this.totalChunks = Math.ceil(file.size / chunkSize); // 总分片数
this.chunkIndex = 0; // 当前分片索引
this.fileId = null; // 文件唯一标识
this.uploadedChunks = new Set(); // 已上传的分片索引
}
// 生成文件唯一标识(可以用文件名+文件大小+最后修改时间)
generateFileId() {
return `${this.file.name}-${this.file.size}-${this.file.lastModified}`;
}
// 获取未上传的分片
getUnuploadedChunks() {
const unuploadedChunks = [];
for (let i = 0; i < this.totalChunks; i++) {
if (!this.uploadedChunks.has(i)) {
unuploadedChunks.push(i);
}
}
return unuploadedChunks;
}
// 上传分片
async uploadChunk(chunkIndex) {
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunk = this.file.slice(start, end);
const formData = new FormData();
formData.append("file", chunk);
formData.append("chunkIndex", chunkIndex);
formData.append("totalChunks", this.totalChunks);
formData.append("fileId", this.fileId);
try {
await fetch("/upload", {
method: "POST",
body: formData,
});
this.uploadedChunks.add(chunkIndex); // 记录已上传的分片
this.saveProgress(); // 保存上传进度
} catch (error) {
console.error("上传失败:", error);
throw error;
}
}
// 保存上传进度到 localStorage
saveProgress() {
const progress = {
fileId: this.fileId,
uploadedChunks: Array.from(this.uploadedChunks),
};
localStorage.setItem(this.fileId, JSON.stringify(progress));
}
// 从 localStorage 加载上传进度
loadProgress() {
const progress = JSON.parse(localStorage.getItem(this.fileId));
if (progress) {
this.uploadedChunks = new Set(progress.uploadedChunks);
}
}
// 开始上传
async startUpload() {
this.fileId = this.generateFileId();
this.loadProgress(); // 加载上传进度
const unuploadedChunks = this.getUnuploadedChunks();
for (const chunkIndex of unuploadedChunks) {
await this.uploadChunk(chunkIndex);
console.log(`分片 ${chunkIndex} 上传完成`);
}
console.log("所有分片上传完成,通知服务端合并文件");
await this.mergeFile();
}
// 通知服务端合并文件
async mergeFile() {
await fetch("/merge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
fileId: this.fileId,
fileName: this.file.name,
totalChunks: this.totalChunks,
}),
});
console.log("文件合并完成");
localStorage.removeItem(this.fileId); // 清除上传进度
}
}
// 使用示例
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (file) {
const uploader = new FileUploader(file);
await uploader.startUpload();
}
});
前端代码(VUE)
<template>
<div>
<input type="file" @change="handleFileChange" />
<button @click="startUpload" :disabled="!file || isUploading">
{
{ isUploading ? '上传中...' : '开始上传' }}
</button>
<div v-if="progress > 0">
上传进度: {
{ progress }}%
</div>
</div>
</template>
<script>
import { ref } from 'vue';
export default {
name: 'FileUploader',
setup() {
const file = ref(null); // 选择的文件
const isUploading = ref(false); // 是否正在上传
const progress = ref(0); // 上传进度
const chunkSize = 5 * 1024 * 1024; // 分片大小(5MB)
const fileId = ref(''); // 文件唯一标识
const uploadedChunks = ref(new Set()); // 已上传的分片索引
// 生成文件唯一标识
const generateFileId = (file) => {
return `${file.name}-${file.size}-${file.lastModified}`;
};
// 获取未上传的分片
const getUnuploadedChunks = (totalChunks) => {
const unuploadedChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (!uploadedChunks.value.has(i)) {
unuploadedChunks.push(i);
}
}
return unuploadedChunks;
};
// 上传分片
const uploadChunk = async (chunkIndex, totalChunks) => {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.value.size);
const chunk = file.value.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
formData.append('fileId', fileId.value);
try {
await fetch('/upload', {
method: 'POST',
body: formData,
});
uploadedChunks.value.add(chunkIndex); // 记录已上传的分片
saveProgress(); // 保存上传进度
} catch (error) {
console.error('上传失败:', error);
throw error;
}
};
// 保存上传进度到 localStorage
const saveProgress = () => {
const progressData = {
fileId: fileId.value,
uploadedChunks: Array.from(uploadedChunks.value),
};
localStorage.setItem(fileId.value, JSON.stringify(progressData));
};
// 从 localStorage 加载上传进度
const loadProgress = () => {
const progressData = JSON.parse(localStorage.getItem(fileId.value));
if (progressData) {
uploadedChunks.value = new Set(progressData.uploadedChunks);
}
};
// 通知服务端合并文件
const mergeFile = async () => {
await fetch('/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileId: fileId.value,
fileName: file.value.name,
totalChunks: Math.ceil(file.value.size / chunkSize),
}),
});
console.log('文件合并完成');
localStorage.removeItem(fileId.value); // 清除上传进度
};
// 开始上传
const startUpload = async () => {
if (!file.value) return;
isUploading.value = true;
fileId.value = generateFileId(file.value);
loadProgress(); // 加载上传进度
const totalChunks = Math.ceil(file.value.size / chunkSize);
const unuploadedChunks = getUnuploadedChunks(totalChunks);
for (const chunkIndex of unuploadedChunks) {
await uploadChunk(chunkIndex, totalChunks);
progress.value = Math.round((uploadedChunks.value.size / totalChunks) * 100);
console.log(`分片 ${chunkIndex} 上传完成`);
}
console.log('所有分片上传完成,通知服务端合并文件');
await mergeFile();
isUploading.value = false;
progress.value = 100;
};
// 选择文件
const handleFileChange = (event) => {
const selectedFile = event.target.files[0];
if (selectedFile) {
file.value = selectedFile;
progress.value = 0;
uploadedChunks.value = new Set();
}
};
return {
file,
isUploading,
progress,
startUpload,
handleFileChange,
};
},
};
</script>
<style scoped>
/* 样式可以根据需要自定义 */
div {
margin: 20px;
}
button {
margin-top: 10px;
}
</style>
服务器端代码
const express = require('express');
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const app = express();
const upload = multer({ dest: 'uploads/' }); // 分片存储目录
// 上传分片
app.post('/upload', upload.single('file'), (req, res) => {
const { chunkIndex, fileId } = req.body;
const chunkPath = path.join('uploads', `${fileId}-${chunkIndex}`);
// 将分片移动到指定位置
fs.renameSync(req.file.path, chunkPath);
res.send('分片上传成功');
});
// 合并文件
app.post('/merge', express.json(), (req, res) => {
const { fileId, fileName, totalChunks } = req.body;
const filePath = path.join('uploads', fileName);
// 创建可写流
const writeStream = fs.createWriteStream(filePath);
// 依次读取分片并写入文件
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join('uploads', `${fileId}-${i}`);
const chunk = fs.readFileSync(chunkPath);
writeStream.write(chunk);
fs.unlinkSync(chunkPath); // 删除分片
}
writeStream.end();
res.send('文件合并完成');
});
app.listen(3000, () => {
console.log('服务端运行在 http://localhost:3000');
});