背景
在现代Web应用中,文件上传是一个常见的功能需求,特别是在处理大型文件时,如视频、大型文档等。传统的文件上传方式在遇到网络不稳定或服务器问题时,容易导致上传失败,且用户需要重新上传整个文件,这不仅浪费了时间和带宽,也极大地影响了用户体验。为了解决这一问题,断点续传技术应运而生。
断点续传的优点
-
提高上传成功率:即使在网络不稳定的情况下,用户也不需要重新上传整个文件,只需从断点处继续上传。
-
节省时间和带宽:避免了重复上传已成功传输的部分,减少了用户的等待时间和网络资源的浪费。
-
提升用户体验:用户可以随时暂停和恢复上传,更加灵活方便。
-
适用于大文件:对于大文件的上传,断点续传尤为重要,因为它可以显著减少因网络问题导致的上传失败。
断点续传的基本原理
断点续传的核心思想是将大文件分割成多个小块(分片),每次只上传一个分片。如果上传过程中断,下次上传时可以从上次中断的分片开始,而不是重新上传整个文件。具体实现步骤如下:
前端
1. 文件选择与初始化
-
文件选择:用户通过文件输入框选择要上传的文件。
-
初始化:当用户选择文件后,触发
uploadFile
方法。该方法获取文件对象,并计算文件的总分片数totalChunks
。每个分片的大小默认为1MB。
2. 文件分片
-
分片计算:根据文件大小和分片大小(1MB),计算出总分片数
totalChunks
。 -
分片遍历:使用一个
for
循环遍历每个分片,从currentChunk
开始,直到totalChunks
。
3. 检查已上传分片
- 检查已上传分片:在上传每个分片之前,检查
uploadedChunks
数组,如果当前分片已经上传过,则跳过该分片。
4. 上传分片
-
创建分片:计算当前分片的起始位置
start
和结束位置end
,使用file.slice
方法创建当前分片的 Blob 对象。 -
创建表单数据:使用
FormData
对象封装分片数据,包括分片文件、文件名、分片索引和总分片数。 -
发送请求:使用
axios
发送 POST 请求,将分片数据上传到服务器。 -
更新进度:每次成功上传一个分片后,更新
uploadedChunks
数组和上传进度percentage
。
5. 暂停与继续上传
-
暂停上传:用户点击“暂停”按钮时,调用
pauseUpload
方法,将isPaused
设置为false
,停止上传循环。 -
记录当前分片:在暂停上传时,调用
record
方法,记录当前分片序号currentChunk
,并等待用户恢复上传。 -
继续上传:用户点击“继续”按钮时,调用
continueUpload
方法,从服务器获取已上传的分片列表,更新uploadedChunks
数组,重新启动上传循环。
服务端
1. 文件切片上传:
-
客户端将大文件分割成多个小切片(
chunks
),每个切片通过单独的 HTTP 请求上传。 -
每个切片上传时,客户端会发送文件名 (
filename
)、当前切片索引 (chunkIndex
) 和总切片数 (totalChunks
)。
2. 处理文件上传:
-
服务器接收到切片上传请求后,首先检查该切片是否已经存在。如果存在,直接返回成功响应,避免重复上传。
-
如果切片不存在,服务器会创建一个临时目录来存储该文件的所有切片。
-
将上传的切片文件移动到临时目录中,并记录切片索引。
-
如果当前切片是最后一个切片(即
chunkIndex === totalChunks - 1
),则调用mergeChunks
函数进行文件合并。
3. 获取已上传的切片信息:
-
客户端可以通过
/checkUploadedchunks
接口查询某个文件已上传的切片信息。 -
服务器会检查临时目录是否存在,并读取其中的文件列表,返回已上传的切片索引。
4. 文件合并:
-
mergeChunks
函数负责将所有切片按顺序合并成一个完整的文件。 -
读取每个切片文件的内容,并将其追加到一个 Buffer 中。
-
将合并后的 Buffer 写入最终的输出文件。
-
合并完成后,删除所有临时切片文件和临时目录。
代码示例:
前端代码示例:
<template>
<div>
<div class="upload_con">
<div class="upload">
<label for="fileInput">上传文件</label>
<input id="fileInput" type="file" @change="uploadFile" name="上传文件"></input>
</div>
<div class="progress" v-show="isUploading">
<div class="btn_group">
<el-button type="primary" plain v-show="isPaused" @click="pauseUpload">暂停</el-button>
<el-button type="primary" plain v-show="!isPaused" @click="continueUpload">继续</el-button>
</div>
<div>
<el-progress :text-inside="true" :stroke-width="20" :percentage="percentage"></el-progress>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import {
uploadFile, checkUploadedChunks } from "@/api/file";
export default {
data() {
return {
percentage: 0,
isUploading: false,
isPaused: true,
uploadedChunks: [],
}
},
watch: {
percentage(val, oldVal) {
if (val > 0) {
this.isUploading = true;
}
if (val == 100) {
this.isUploading = false;
this.$message.success('上传成功!');
}
}
},
methods: {
// 上传文件按钮
async uploadFile(event) {
this.isPaused = true;
// 获取文件对象
const files = event.target.files || event.dataTransfer.files;
this.file = files[0];
this.fileName = this.file.name;
this.totalChunks = Math.ceil(this.file.size / (1 * 1024 * 1024));
this.currentChunk = 0;
this.uploadChunks();
},
// 上传文件接口
async uploadChunks() {
// 定义每个分片的大小为 1MB
const chunkSize = 1 * 1024 * 1024;
let uploadedChunks = this.uploadedChunks.length;
// 遍历所有分片
for (let i = this.currentChunk; i < this.totalChunks; i++) {
console.log('当前片段序号---::: ', i);
if (this.uploadedChunks.includes(i)) {
// console.log('this.uploadedChunks.includes(i)::: ', this.uploadedChunks.includes(i));
continue; // 跳过已上传的分片
}
if (!this.isPaused) {
await this.record(i);
return; // 暂停时退出循环
}
// 计算当前分片的起始位置
const start = i * chunkSize;
// 计算当前分片的结束位置
const end = Math.min(start + chunkSize, this.file.size);
// 创建当前分片的 Blob 对象
const blob = this.file.slice(start, end);
// 创建表单数据对象
const formData = new FormData();
// 添加当前分片的文件
formData.append('file', blob, `${
this.fileName}_${
i}`);
// 添加文件名
formData.append('filename', this.fileName);
// 添加分片索引
formData.append('chunkIndex', i.toString());
// 添加总分片数
formData.append('totalChunks', this.totalChunks.toString());
try {
// 上传分片
const response = await uploadFile(formData);
console.log(`Chunk ${
i} uploaded successfully.`);
uploadedChunks++;
this.currentChunk = i + 1;
this.percentage = (uploadedChunks / this.totalChunks) * 100;
} catch (error) {
console.error(`Failed to upload chunk ${
i}:`, error);
}
}
},
// 暂停上传
pauseUpload() {
this.isPaused = false;
},
// 暂停
async record(currentChunk) {
console.log('currentChunk::: ', currentChunk);
this.currentChunk = currentChunk;
while (!this.isPaused) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
},
// 继续上传
async continueUpload() {
console.log('this.fileName::: ', this.fileName);
// 获取已上传的分片
const response = await checkUploadedChunks(this.fileName);
console.log('response.data::: ', response.data);
this.uploadedChunks = response.chunks || [];
this.isPaused = true;
this.uploadChunks();
},
}
}
</script>
接口
import request from "@/utils/request";
// 上传文件
export function uploadFile(data) {
return request({
url: "/upload",
method: "post",
data,
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/ 获取已上传的分片
export function checkUploadedChunks(fileName) {
return request({
url: `/checkUploadedchunks?fileName=${
fileName}`,
method: "get",
});
}
实现效果
后端代码示例:
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const util = require('util');
const {
code, message } = require('statuses');
const app = express();
const upload = multer({
dest: 'uploads/' });
// 定义文件夹路径
const mergedDir = path.join(__dirname, 'merged');
// 设置静态文件夹
app.use(express.static('uploads'));
// 将 fs 方法转换为 Promise 版本
const mkdir = util.promisify(fs.mkdir);
const rename = util.promisify(fs.rename);
const unlink = util.promisify(fs.unlink);
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);
const rmdir = util.promisify(fs.rmdir);
// 文件合并函数
async function mergeChunks(filename, totalChunks) {
// 定义存储切片临时文件夹路径
const tempDir = `uploads/${
filename}/`;
// 定义最终合并文件的路径
const outputFilePath = `merged/${
filename}`;
// 创建输出目录
await mkdir(path.dirname(outputFilePath), {
recursive: true });
// 初始化一个空的 Buffer 用于存储合并后的数据
let combinedData = Buffer.alloc(0);
// 遍历所有切片文件并读取内容
for (let i = 0; i < totalChunks; i++) {
// 获取每个切片文件的路径
const chunkPath = `${
tempDir}${
i}`;
// 读取当前切片文件的内容
const chunkData = await readFile(chunkPath);
// 合并切片文件的内容追加到 combinedData 中
combinedData = Buffer.concat([combinedData, chunkData]);
}
// 将合并后的数据写入最终的输出文件
await writeFile(outputFilePath, combinedData);
console.log('File merged successfully.');
// 删除临时切片文件
for (let i = 0; i < totalChunks; i++) {
const chunkPath = `${
tempDir}${
i}`;
try {
await unlink(chunkPath);
} catch (err) {
console.error(`Error deleting chunk ${
i}:`, err);
}
}
// 删除临时文件夹
try {
await rmdir(tempDir, {
recursive: true });
console.log('Temporary directory deleted successfully.');
} catch (err) {
console.error('Error deleting temporary directory:', err);
}
}
// 处理文件上传
app.post('/upload', upload.single('file'), async (req, res) => {
const {
filename, chunkIndex, totalChunks } = req.body;
const chunkPath = `uploads/${
filename}/${
chunkIndex}`;
try {
// 检查切片是否已经存在
const fileExists = await new Promise((resolve, reject) => {
fs.access(chunkPath, fs.constants.F_OK, (err) => {
resolve(!err);
});
});
if (fileExists) {
console.log(`Chunk ${
chunkIndex} already exists`);
res.status(200).send('Chunk already exists');
return;
}
// 创建文件切片目录
await mkdir(path.dirname(chunkPath), {
recursive: true });
// 移动上传的文件到切片目录
await rename(req.file.path, chunkPath);
console.log(`Chunk ${
chunkIndex} saved successfully`);
// 如果这是最后一个切片,则合并所有切片
if (parseInt(chunkIndex) === parseInt(totalChunks) - 1) {
await mergeChunks(filename, totalChunks);
}
res.status(200).send('Chunk received');
} catch (err) {
console.error(`Error handling chunk ${
chunkIndex}:`, err);
res.status(500).send('Internal Server Error');
}
});
// 获取已上传的切片信息
app.get('/checkUploadedchunks', (req, res) => {
const {
fileName } = req.query;
// 获取上传的临时文件夹路径
const tempDir = `uploads/${
fileName}/`;
console.log('tempDir::: ', tempDir);
// 检查临时文件夹是否存在
fs.access(tempDir, fs.constants.F_OK, (err) => {
if (err) {
return res.status(404).json({
chunks: [], message: '临时文件夹不存在' });
}
// 读取临时文件夹中的文件列表
fs.readdir(tempDir, (err, files) => {
if (err) {
return res.status(500).json({
error: '无法读取临时文件夹' });
}
// 将文件名转换为整数,并按升序排序
const uploadedChunks = files.map(file => parseInt(file)).sort((a, b) => a - b);
res.status(200).json({
chunks: uploadedChunks, message: '获取已上传切片成功' });
});
});
});
// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${
PORT}`);
});
文件结构如下
后端
总结
断点续传技术通过将大文件分割成多个小分片,逐个上传,并在上传过程中记录已上传的分片信息。当上传中断时,可以从最后一个已上传的分片继续上传,避免了重新上传整个文件的问题。前端通过文件选择、分片处理、暂停与继续上传等逻辑实现断点续传功能,而后端则负责接收分片、保存分片和提供已上传分片的查询接口。这种技术在实际应用中可以显著提高文件上传的可靠性和用户体验。