本文目录导读:

这是一个复杂但非常实用的需求,实现断点续传的核心在于客户端记录上传进度,并服务端支持偏移量写入。
下面我将分步骤讲解原理,并提供一个基于 HTTP + Node.js(后端) + 浏览器(前端) 的完整可运行案例。
核心原理
- 切分文件:前端将大文件(如视频)切成固定大小的块(Slice,1MB)。
- 标记块:为每个块分配一个唯一的序号(
chunkIndex)和文件唯一标识(fileHash)。 - 记录进度:客户端(浏览器)记录已成功上传的块序号(例如使用
localStorage或内存 Map)。 - 断点恢复:上传中断后,重新开始前先发送一个“查询进度”请求给服务端。
- 断点写入:服务端收到“查询进度”的响应后,告知客户端哪些块已上传,客户端只上传缺失的块。
- 合并文件:所有块上传完毕后,客户端通知服务端合并这些块。
环境准备
- Node.js 环境
- 需要安装
express(Web框架)、multer(文件上传处理)、spark-md5(浏览器端计算文件 Hash)。
后端依赖安装命令:
npm init -y npm install express multer
前端依赖(直接在 HTML 中使用 CDN):
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
第一步:后端服务 (Node.js + Express)
创建一个 server.js 文件。
// server.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = 3000;
// 临时存储块的目录
const CHUNK_DIR = path.join(__dirname, 'uploads', 'chunks');
// 最终合并文件的目录
const MERGED_DIR = path.join(__dirname, 'uploads', 'files');
// 确保目录存在
if (!fs.existsSync(CHUNK_DIR)) fs.mkdirSync(CHUNK_DIR, { recursive: true });
if (!fs.existsSync(MERGED_DIR)) fs.mkdirSync(MERGED_DIR, { recursive: true });
// 配置 multer 存储(将块存为临时文件)
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// 根据文件id创建子文件夹
const fileId = req.body.fileId; // 前端发送的文件唯一标识
const chunkDir = path.join(CHUNK_DIR, fileId);
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir, { recursive: true });
}
cb(null, chunkDir);
},
filename: (req, file, cb) => {
// 使用块索引命名文件
const chunkIndex = req.body.chunkIndex;
cb(null, `chunk_${chunkIndex}`);
}
});
const upload = multer({ storage: storage });
// 解析 JSON 请求体
app.use(express.json());
// 1. 上传单个块
app.post('/upload', upload.single('file'), (req, res) => {
console.log(`块 ${req.body.chunkIndex} 上传成功`);
res.json({ code: 0, message: '块接收成功' });
});
// 2. 查询已上传的块列表(实现断点续传的核心)
app.get('/check', (req, res) => {
const { fileId } = req.query;
const chunkDir = path.join(CHUNK_DIR, fileId);
const uploadedChunks = [];
if (fs.existsSync(chunkDir)) {
const files = fs.readdirSync(chunkDir);
files.forEach(file => {
// 文件名是 chunk_0, chunk_1...
const index = parseInt(file.split('_')[1]);
if (!isNaN(index)) {
uploadedChunks.push(index);
}
});
}
// 升序排列
uploadedChunks.sort((a, b) => a - b);
console.log(`文件 ${fileId} 已上传块: ${uploadedChunks.join(', ')}`);
res.json({ code: 0, data: uploadedChunks });
});
// 3. 合并所有块
app.post('/merge', (req, res) => {
const { fileId, fileName } = req.body;
const chunkDir = path.join(CHUNK_DIR, fileId);
const mergedFilePath = path.join(MERGED_DIR, fileName);
// 检查是否所有块都存在(这里简单处理,直接合并所有存在的块)
const chunkFiles = fs.readdirSync(chunkDir).sort((a, b) => {
const indexA = parseInt(a.split('_')[1]);
const indexB = parseInt(b.split('_')[1]);
return indexA - indexB;
});
// 创建可写流写入最终文件
const writeStream = fs.createWriteStream(mergedFilePath);
let chunksProcessed = 0;
function appendChunk() {
if (chunksProcessed >= chunkFiles.length) {
writeStream.end();
// 合并完成后删除块目录
fs.rmSync(chunkDir, { recursive: true, force: true });
console.log(`文件 ${fileName} 合并完成`);
res.json({ code: 0, message: '合并成功' });
return;
}
const chunkPath = path.join(chunkDir, chunkFiles[chunksProcessed]);
const readStream = fs.createReadStream(chunkPath);
readStream.pipe(writeStream, { end: false });
readStream.on('end', () => {
chunksProcessed++;
appendChunk(); // 处理下一个块
});
readStream.on('error', (err) => {
console.error(err);
res.status(500).json({ code: -1, message: '合并失败' });
});
}
appendChunk();
});
// 启动服务
app.listen(PORT, () => {
console.log(`服务已启动,请访问 http://localhost:${PORT}/public/index.html`);
});
// 提供静态文件服务(让前端页面可以访问)
app.use('/public', express.static(path.join(__dirname, 'public')));
第二步:前端页面 (HTML + JavaScript)
在项目根目录下创建 public/index.html 文件。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">断点续传 Demo</title>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
<style>
.progress-bar { width: 100%; background: #ddd; margin: 10px 0; }
.progress-bar .progress { width: 0%; height: 30px; background: green; text-align: center; line-height: 30px; color: white; }
</style>
</head>
<body>
<h1>文件断点续传</h1>
<input type="file" id="fileInput" />
<button id="uploadBtn">上传</button>
<button id="pauseBtn" disabled>暂停</button>
<div id="status"></div>
<div class="progress-bar">
<div class="progress" id="progressBar">0%</div>
</div>
<script>
const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB 一个块
let file = null;
let fileId = null;
let uploadedChunks = []; // 已上传的块索引
let chunkTotal = 0;
let currentChunk = 0;
let isPaused = false;
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const pauseBtn = document.getElementById('pauseBtn');
const statusEl = document.getElementById('status');
const progressBar = document.getElementById('progressBar');
// 选择文件
fileInput.addEventListener('change', async (e) => {
file = e.target.files[0];
if (!file) return;
statusEl.textContent = `已选择文件: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`;
// 计算文件唯一标识(用于服务端区分文件)
fileId = await calculateFileHash(file);
console.log('文件 hash:', fileId);
// 检查服务端是否已经有部分上传
await checkUploadedChunks(fileId);
uploadBtn.disabled = false;
});
// 计算文件 Hash (使用 SparkMD5,对文件分块计算)
function calculateFileHash(file) {
return new Promise((resolve, reject) => {
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
const chunkSize = 2 * 1024 * 1024; // 2MB 用于计算hash
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
fileReader.onerror = (e) => reject(e.target.error);
function loadNext() {
const start = currentChunk * chunkSize;
const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
});
}
// 检查已上传块
async function checkUploadedChunks(fileId) {
try {
const res = await fetch(`http://localhost:3000/check?fileId=${fileId}`);
const result = await res.json();
uploadedChunks = result.data || [];
console.log('已上传块索引:', uploadedChunks);
// 更新进度显示
chunkTotal = Math.ceil(file.size / CHUNK_SIZE);
updateProgressBar();
} catch (error) {
console.error('检查进度失败:', error);
}
}
// 上传文件
uploadBtn.addEventListener('click', async () => {
if (!file) return;
uploadBtn.disabled = true;
pauseBtn.disabled = false;
isPaused = false;
chunkTotal = Math.ceil(file.size / CHUNK_SIZE);
// 从已上传的块之后开始(或从0开始)
if (uploadedChunks.length > 0) {
currentChunk = uploadedChunks[uploadedChunks.length - 1] + 1;
} else {
currentChunk = 0;
}
for (let i = currentChunk; i < chunkTotal; i++) {
if (isPaused) {
statusEl.textContent = '上传已暂停';
break;
}
// 如果已经上传过,跳过
if (uploadedChunks.includes(i)) {
continue;
}
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const formData = new FormData();
formData.append('file', blob, `chunk_${i}`);
formData.append('fileId', fileId);
formData.append('chunkIndex', i);
try {
const res = await fetch('http://localhost:3000/upload', {
method: 'POST',
body: formData
});
const result = await res.json();
if (result.code === 0) {
uploadedChunks.push(i);
updateProgressBar();
statusEl.textContent = `正在上传块 ${i + 1} / ${chunkTotal}`;
} else {
statusEl.textContent = `上传块 ${i} 失败`;
break;
}
} catch (error) {
console.error('上传错误:', error);
statusEl.textContent = `上传错误: ${error.message}`;
break;
}
// 模拟网络波动(非必须)
// await new Promise(r => setTimeout(r, 50));
}
// 所有块上传完成,请求合并
if (!isPaused && uploadedChunks.length === chunkTotal) {
statusEl.textContent = '所有块上传完成,正在合并...';
await mergeFile();
}
uploadBtn.disabled = false;
pauseBtn.disabled = true;
});
// 暂停上传
pauseBtn.addEventListener('click', () => {
isPaused = true;
pauseBtn.disabled = true;
});
// 请求合并文件
async function mergeFile() {
try {
const res = await fetch('http://localhost:3000/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileId: fileId,
fileName: file.name
})
});
const result = await res.json();
if (result.code === 0) {
statusEl.textContent = '上传成功!';
progressBar.style.background = 'blue';
progressBar.textContent = '100%';
} else {
statusEl.textContent = '合并失败: ' + result.message;
}
} catch (error) {
statusEl.textContent = '合并请求错误: ' + error.message;
}
}
// 更新进度条
function updateProgressBar() {
const total = chunkTotal || 1;
const progress = Math.round((uploadedChunks.length / total) * 100);
progressBar.style.width = progress + '%';
progressBar.textContent = progress + '%';
}
</script>
</body>
</html>
第三步:运行与测试
-
确保你的目录结构如下:
your-project/ ├── server.js ├── public/ │ └── index.html └── package.json -
启动服务端:
node server.js
-
打开浏览器,访问
http://localhost:3000/public/index.html。 -
测试断点续传:
- 选择一个稍大的文件(10MB 的文本或视频)。
- 点击上传,观察控制台和进度条,上传几秒后,关闭浏览器标签页(模拟异常中断)。
- 再次刷新页面,重新选择同一个文件,点击上传,你会看到进度条不会从 0 开始,而是从之前中断的位置继续上传。
关键点与扩展
-
文件唯一标识
fileId:使用了文件的 MD5 哈希,生产环境可以用文件名 + 文件大小 + 修改时间的组合,避免大文件计算 Hash 耗时太久(因为 Hash 计算本身也要读文件)。 -
安全性:这个示例没有身份验证和权限控制,生产环境中,
fileId需要和用户绑定,防止恶意覆盖他人文件。 -
并发控制:案例中使用了串行上传(逐个上传),简单稳定但速度中等,要加速,可以用
Promise.all同时上传 3~5 个块,但要注意:- 服务端需要处理并发写入同一个块的问题(通常不会,因为块索引是唯一的)。
- 重试机制:如果并发中某个块失败,需要单独重试。
-
服务器端的合并:示例使用
pipe串行合并,对于超大文件,可以使用流式逐块追加,或者用ffmpeg对视频进行无损合并。 -
前端存储:目前记录
uploadedChunks是依赖服务端(通过/check接口),如果不想每次查询服务器,也可以使用localStorage缓存fileId -> chunks的映射,但服务器端校验仍然是最后的保障。
这个案例实现了断点续传的核心流程,可以作为面试或项目原理入门的参考。