一、为什么需要大文件上传和断点续传?
传统的单文件上传方式,当文件体积较大时,会面临以下挑战:
- 请求超时:HTTP请求通常有超时限制,大文件上传时间过长容易导致请求超时。
- 网络不稳定:网络波动、中断可能导致上传失败,用户体验差。
- 服务器压力:单个大文件上传会长时间占用服务器资源。
- 用户体验:上传失败后需要重新上传整个文件,耗时且 frustrates 用户。
**分片上传(Chunked Upload)和断点续传(Resumable Upload)**是解决这些问题的核心方案。
- 分片上传:将大文件分割成若干个小文件片(chunk),然后逐个上传这些文件片。即使某个文件片上传失败,也只需要重新上传该文件片,而不是整个文件。所有文件片上传完成后,再由服务器将它们合并成完整的文件。
- 断点续传:在分片上传的基础上,记录每个文件片的上传状态。当上传中断后,下次上传时可以查询服务器已上传的文件片列表,然后只上传未上传的文件片,从而实现从中断处继续上传。
二、核心概念
-
文件分片 (File Chunking) :
- 利用
File
对象的slice
方法将文件切割成固定大小的块。这是实现分片上传的基础。 - 例如:
file.slice(start, end)
。
- 利用
-
文件唯一标识 (File Hashing/Fingerprinting) :
-
断点续传原理:
-
并发上传 (Concurrent Uploads) :
三、前端实现步骤(详细代码讲解)
我们将通过一个简单的 HTML 页面和 JavaScript 代码来演示大文件上传和断点续传的实现。
HTML 结构 (index.html
) :
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大文件上传与断点续传</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.container { max-width: 800px; margin: 0 auto; padding: 20px; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
input[type="file"] { margin-bottom: 10px; }
button { padding: 10px 15px; background-color: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; margin-right: 10px; }
button:hover { background-color: #0056b3; }
button:disabled { background-color: #cccccc; cursor: not-allowed; }
.progress-bar-container { width: 100%; background-color: #f3f3f3; border-radius: 5px; margin-top: 10px; }
.progress-bar { width: 0%; height: 20px; background-color: #4CAF50; border-radius: 5px; text-align: center; line-height: 20px; color: white; }
.file-info { margin-top: 20px; border-top: 1px solid #eee; padding-top: 15px; }
.chunk-status { margin-top: 10px; font-size: 0.9em; color: #555; }
.chunk-item { display: inline-block; width: 10px; height: 10px; border: 1px solid #ccc; margin: 1px; }
.chunk-item.uploaded { background-color: #4CAF50; }
.chunk-item.pending { background-color: #f0ad4e; }
.chunk-item.error { background-color: #d9534f; }
</style>
</head>
<body>
<div class="container">
<h1>大文件上传与断点续传</h1>
<input type="file" id="fileInput">
<button id="uploadBtn" disabled>上传</button>
<button id="pauseBtn" disabled>暂停</button>
<button id="resumeBtn" disabled>继续</button>
<div class="file-info" style="display: none;">
<p>文件名: <span id="fileName"></span></p>
<p>文件大小: <span id="fileSize"></span></p>
<p>文件哈希: <span id="fileHash"></span></p>
<p>上传进度: <span id="uploadProgressText">0%</span></p>
<div class="progress-bar-container">
<div class="progress-bar" id="uploadProgressBar"></div>
</div>
<p>分片状态:</p>
<div id="chunkStatusContainer" class="chunk-status"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/spark-md5.min.js"></script>
<script src="main.js"></script>
</body>
</html>
JavaScript 逻辑 (main.js
) :
js
// main.js
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resumeBtn = document.getElementById('resumeBtn');
const fileNameSpan = document.getElementById('fileName');
const fileSizeSpan = document.getElementById('fileSize');
const fileHashSpan = document.getElementById('fileHash');
const uploadProgressText = document.getElementById('uploadProgressText');
const uploadProgressBar = document.getElementById('uploadProgressBar');
const fileInfoDiv = document.querySelector('.file-info');
const chunkStatusContainer = document.getElementById('chunkStatusContainer');
// 配置参数
const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB 每个分片的大小
const CONCURRENCY_LIMIT = 3; // 并发上传数
const API_BASE_URL = 'http://localhost:3000'; // 后端API地址,请替换为你的后端地址
let selectedFile = null;
let fileHash = '';
let chunks = [];
let uploadedChunks = new Set(); // 记录已上传的分片索引
let isUploading = false;
let isPaused = false;
let controller = null; // AbortController 用于取消 Fetch 请求
fileInput.addEventListener('change', handleFileChange);
uploadBtn.addEventListener('click', startUpload);
pauseBtn.addEventListener('click', pauseUpload);
resumeBtn.addEventListener('click', resumeUpload);
// 1. 文件选择与信息展示
function handleFileChange(event) {
selectedFile = event.target.files[0];
if (!selectedFile) {
resetUI();
return;
}
fileNameSpan.textContent = selectedFile.name;
fileSizeSpan.textContent = formatBytes(selectedFile.size);
fileInfoDiv.style.display = 'block';
uploadBtn.disabled = true; // 先禁用上传,等待哈希计算
pauseBtn.disabled = true;
resumeBtn.disabled = true;
uploadProgressText.textContent = '0%';
uploadProgressBar.style.width = '0%';
chunkStatusContainer.innerHTML = '';
// 计算文件哈希
calculateFileHash(selectedFile).then(hash => {
fileHash = hash;
fileHashSpan.textContent = hash;
uploadBtn.disabled = false; // 哈希计算完成后启用上传按钮
console.log('文件哈希:', fileHash);
}).catch(error => {
console.error('文件哈希计算失败:', error);
alert('文件哈希计算失败!');
resetUI();
});
}
// 辅助函数:格式化文件大小
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// 2. 计算文件哈希 (使用 spark-md5 和 Web Worker 模拟异步)
function calculateFileHash(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
const chunkSize = 2 * 1024 * 1024; // 每次读取2MB进行哈希计算
let offset = 0;
fileReader.onload = function (e) {
spark.append(e.target.result); // Append array buffer
offset += e.target.result.byteLength;
if (offset < file.size) {
readNextChunk();
} else {
resolve(spark.end()); // Compute hash
}
};
fileReader.onerror = function () {
reject('文件读取失败');
};
function readNextChunk() {
const slice = file.slice(offset, offset + chunkSize);
fileReader.readAsArrayBuffer(slice);
}
readNextChunk();
});
}
// 3. 文件分片
function createFileChunks(file, hash) {
const fileChunks = [];
let current = 0;
let index = 0;
while (current < file.size) {
const chunk = file.slice(current, current + CHUNK_SIZE);
fileChunks.push({
fileHash: hash,
chunk: chunk,
index: index,
size: chunk.size,
fileName: file.name,
totalChunks: Math.ceil(file.size / CHUNK_SIZE),
status: 'pending' // pending, uploading, uploaded, error
});
current += CHUNK_SIZE;
index++;
}
chunks = fileChunks; // 保存到全局变量
renderChunkStatus();
return fileChunks;
}
// 渲染分片状态小方块
function renderChunkStatus() {
chunkStatusContainer.innerHTML = '';
chunks.forEach(chunk => {
const div = document.createElement('div');
div.classList.add('chunk-item');
div.classList.add(chunk.status);
div.title = `分片 ${chunk.index + 1}: ${chunk.status}`;
chunkStatusContainer.appendChild(div);
});
}
function updateChunkStatusUI(index, status) {
if (chunks[index]) {
chunks[index].status = status;
const chunkItem = chunkStatusContainer.children[index];
if (chunkItem) {
chunkItem.className = 'chunk-item ' + status;
}
}
}
// 4. 开始上传
async function startUpload() {
if (!selectedFile || !fileHash) {
alert('请先选择文件并等待哈希计算完成!');
return;
}
uploadBtn.disabled = true;
pauseBtn.disabled = false;
resumeBtn.disabled = true;
isUploading = true;
isPaused = false;
controller = new AbortController(); // 初始化 AbortController
chunks = createFileChunks(selectedFile, fileHash);
uploadedChunks.clear(); // 清空已上传列表
// 检查文件是否已存在或已部分上传
try {
const response = await fetch(`${API_BASE_URL}/upload/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileHash: fileHash,
fileName: selectedFile.name,
fileSize: selectedFile.size,
totalChunks: chunks.length
})
});
const data = await response.json();
if (data.uploaded) {
alert('文件已秒传成功!');
updateProgressBar(100);
chunks.forEach((_, i) => updateChunkStatusUI(i, 'uploaded'));
resetUI();
return;
}
if (data.uploadedChunks && data.uploadedChunks.length > 0) {
data.uploadedChunks.forEach(index => {
uploadedChunks.add(index);
updateChunkStatusUI(index, 'uploaded');
});
console.log('已上传分片:', uploadedChunks);
updateProgressBar(uploadedChunks.size / chunks.length * 100);
}
// 开始上传未完成的分片
await uploadChunksSequentially();
} catch (error) {
console.error('文件验证或上传失败:', error);
alert('上传失败,请重试!');
resetUI();
}
}
// 5. 上传分片(使用Promise池控制并发)
async function uploadChunksSequentially() {
const pendingChunks = chunks.filter(chunk => !uploadedChunks.has(chunk.index));
let currentConcurrency = 0;
let uploadQueue = [];
const uploadNext = async () => {
if (!isUploading || isPaused) {
console.log('上传暂停或停止。');
return;
}
if (pendingChunks.length === 0 && uploadQueue.length === 0) {
// 所有分片都已处理
if (uploadedChunks.size === chunks.length) {
console.log('所有分片上传完成,通知服务器合并。');
await mergeChunks();
}
return;
}
while (currentConcurrency < CONCURRENCY_LIMIT && pendingChunks.length > 0) {
const chunkToUpload = pendingChunks.shift();
if (!chunkToUpload) continue; // 队列可能为空
currentConcurrency++;
uploadQueue.push(
uploadChunk(chunkToUpload)
.finally(() => {
currentConcurrency--;
uploadNext(); // 递归调用,尝试上传下一个
})
);
}
};
// 启动上传
uploadNext();
await Promise.allSettled(uploadQueue); // 等待所有当前并发的请求完成
}
async function uploadChunk(chunkInfo) {
const formData = new FormData();
formData.append('fileHash', chunkInfo.fileHash);
formData.append('chunk', chunkInfo.chunk);
formData.append('index', chunkInfo.index);
formData.append('totalChunks', chunkInfo.totalChunks);
formData.append('fileName', chunkInfo.fileName);
updateChunkStatusUI(chunkInfo.index, 'uploading');
try {
const response = await fetch(`${API_BASE_URL}/upload/chunk`, {
method: 'POST',
body: formData,
signal: controller.signal // 关联 AbortController
});
if (response.ok) {
uploadedChunks.add(chunkInfo.index);
updateChunkStatusUI(chunkInfo.index, 'uploaded');
updateProgressBar(uploadedChunks.size / chunks.length * 100);
console.log(`分片 ${chunkInfo.index} 上传成功`);
} else {
throw new Error(`分片 ${chunkInfo.index} 上传失败: ${response.statusText}`);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log(`分片 ${chunkInfo.index} 上传已取消`);
updateChunkStatusUI(chunkInfo.index, 'pending'); // 状态回到待上传
} else {
console.error(`分片 ${chunkInfo.index} 上传失败:`, error);
updateChunkStatusUI(chunkInfo.index, 'error'); // 标记为错误
// 可以在这里实现重试逻辑
}
throw error; // 抛出错误以便 Promise.allSettled 捕获
}
}
// 6. 合并文件
async function mergeChunks() {
try {
const response = await fetch(`${API_BASE_URL}/upload/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileHash: fileHash,
fileName: selectedFile.name,
totalChunks: chunks.length
})
});
if (response.ok) {
alert('文件上传并合并成功!');
resetUI();
} else {
throw new Error('文件合并失败');
}
} catch (error) {
console.error('文件合并请求失败:', error);
alert('文件合并失败,请联系管理员!');
resetUI();
}
}
// 7. 进度显示
function updateProgressBar(percentage) {
uploadProgressBar.style.width = percentage.toFixed(2) + '%';
uploadProgressText.textContent = percentage.toFixed(2) + '%';
}
// 8. 暂停/继续上传
function pauseUpload() {
isPaused = true;
isUploading = false; // 停止上传循环
controller.abort(); // 取消所有进行中的 fetch 请求
pauseBtn.disabled = true;
resumeBtn.disabled = false;
uploadBtn.disabled = true; // 暂停后上传按钮仍然禁用
console.log('上传已暂停。');
}
async function resumeUpload() {
if (!selectedFile || !fileHash) {
alert('没有文件或哈希值,无法继续上传。');
return;
}
if (uploadedChunks.size === chunks.length) {
alert('所有分片已上传,无需继续。');
resetUI();
return;
}
isPaused = false;
isUploading = true;
controller = new AbortController(); // 重新初始化 AbortController
pauseBtn.disabled = false;
resumeBtn.disabled = true;
uploadBtn.disabled = true;
console.log('上传已继续。');
await uploadChunksSequentially();
}
// 重置UI状态
function resetUI() {
selectedFile = null;
fileHash = '';
chunks = [];
uploadedChunks.clear();
isUploading = false;
isPaused = false;
if (controller) {
controller.abort(); // 确保所有请求都被取消
controller = null;
}
fileInput.value = '';
uploadBtn.disabled = true;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
fileInfoDiv.style.display = 'none';
fileNameSpan.textContent = '';
fileSizeSpan.textContent = '';
fileHashSpan.textContent = '';
uploadProgressText.textContent = '0%';
uploadProgressBar.style.width = '0%';
chunkStatusContainer.innerHTML = '';
}
// 初始禁用上传按钮
uploadBtn.disabled = true;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
代码讲解:
-
HTML 结构:
-
全局变量和配置:
CHUNK_SIZE
: 定义每个文件分片的大小,这里设置为 1MB。实际应用中可以根据网络情况和服务器能力调整。CONCURRENCY_LIMIT
: 定义同时上传的分片数量,控制并发。API_BASE_URL
: 后端 API 的基础 URL。selectedFile
,fileHash
,chunks
,uploadedChunks
,isUploading
,isPaused
,controller
: 用于存储文件信息、上传状态和控制上传流程。
-
文件选择 (
handleFileChange
) : -
文件分片 (
createFileChunks
) : -
开始上传 (
startUpload
) : -
上传分片 (
uploadChunksSequentially
,uploadChunk
) :-
uploadChunksSequentially
实现了并发控制的逻辑。它维护一个pendingChunks
队列和currentConcurrency
计数器。 -
uploadNext
函数会检查当前并发数是否小于CONCURRENCY_LIMIT
且pendingChunks
队列中还有待上传的分片,如果是,则取出分片并调用uploadChunk
。 -
uploadChunk
函数负责发送单个分片的fetch
请求。- 使用
FormData
封装分片数据和其他元信息。 fetch
请求中传入signal: controller.signal
,用于实现暂停时取消请求。- 成功上传后,将分片索引添加到
uploadedChunks
Set,并更新 UI 进度条和分片状态。 - 失败时,标记分片状态为
error
,并可以根据需要实现重试逻辑。
- 使用
-
-
合并文件 (
mergeChunks
) : -
进度显示 (
updateProgressBar
) :- 根据已上传分片的数量占总分片数的比例,更新进度条的宽度和文本。
-
暂停/继续 (
pauseUpload
,resumeUpload
) :-
pauseUpload
:- 设置
isPaused = true
和isUploading = false
,阻止uploadChunksSequentially
继续上传。 - 最关键的是调用
controller.abort()
,这会取消所有当前正在进行的fetch
请求,使它们抛出AbortError
。 - 更新按钮状态。
- 设置
-
resumeUpload
:- 重新初始化
AbortController
。 - 设置
isPaused = false
和isUploading = true
。 - 再次调用
uploadChunksSequentially
,它会从uploadedChunks
中已记录的进度开始,继续上传剩余的分片。
- 重新初始化
-
-
UI 重置 (
resetUI
) :- 在上传完成、失败或用户取消后,重置所有状态变量和 UI 元素到初始状态。
四、后端考虑(简要说明)
虽然本示例主要关注前端,但大文件上传和断点续传离不开后端的支持。后端需要实现以下功能:
-
文件验证接口 (
/upload/verify
) :- 接收前端发送的文件哈希、文件名、大小等信息。
- 查询数据库或存储系统,判断该文件是否已存在(秒传)。
- 如果文件已存在,直接返回成功。
- 如果文件未完全上传,返回已接收到的分片列表(例如,已上传分片的索引数组)。
- 为新文件或未完成的文件创建一个临时目录来存放分片。
-
分片上传接口 (
/upload/chunk
) :- 接收前端发送的分片数据(通常是
multipart/form-data
格式)、文件哈希、分片索引等。 - 将接收到的分片保存到对应的临时目录中,并以分片索引命名(例如
fileHash/chunk_0
,fileHash/chunk_1
)。 - 记录该分片已成功接收。
- 接收前端发送的分片数据(通常是
-
文件合并接口 (
/upload/merge
) :- 接收前端发送的文件哈希、文件名、总分片数等信息。
- 根据文件哈希找到所有已上传的分片。
- 按照分片索引的顺序读取所有分片,并将其内容写入一个最终的文件。
- 合并完成后,清理临时分片文件和目录。
后端技术选型 :
后端可以使用 Node.js (如 Express, Koa)、Python (如 Flask, Django)、Java (如 Spring Boot)、Go 等任何支持文件上传和处理的语言和框架来实现。关键在于正确处理文件流、存储分片、以及合并逻辑。
五、潜在优化和高级话题
- Web Workers 计算哈希 : 对于非常大的文件,哈希计算可能耗时。将哈希计算放在 Web Worker 中,可以完全不阻塞主线程,提升用户体验。 [2][7]
- 客户端状态持久化 : 在某些场景下,为了在浏览器关闭后也能恢复上传,可以将
uploadedChunks
等状态信息存储到localStorage
或IndexedDB
中。 - 分片重试机制 : 在
uploadChunk
失败时,可以实现指数退避等重试策略,增加上传的健壮性。 - 文件完整性校验: 在文件合并后,后端可以再次计算合并后文件的哈希值,与前端提供的哈希值进行比对,确保文件在传输过程中没有损坏。
- 文件切片策略: 除了固定大小切片,还可以根据文件内容(如视频关键帧)进行智能切片,但这会增加复杂性。
- 上传队列管理 : 对于大量分片,可以使用更复杂的队列管理系统,例如限制并发的
Promise.allSettled
结合递归调用的方式,或者使用专门的库。 [4][6] - CDN/OSS 直传 : 对于生产环境,通常会结合 CDN 或对象存储服务(如阿里云 OSS、AWS S3)的直传功能,将文件直接上传到存储服务,减轻后端服务器压力。这些服务通常也内置了分片上传和断点续传的能力。 [10]
通过上述详细的讲解和代码示例,你应该对前端如何实现大文件上传和断点续传有了全面的理解。记住,前端和后端紧密协作是实现这一功能的关键。
推荐好文:
- 一文吃透 大文件分片上传、断点续传、秒传 - 稀土掘金
- 大文件处理(上传,下载)思考 - 阿里云开发者社区
- 面试官:大文件上传如何做断点续传? | web前端面试 - Vue3
- 如何实现大文件上传、断点续传、切片上传- Xproer-松鼠- 博客园
- 大文件上传以及断点续传(项目) - DragonPeng的博客
- 前端大文件切片上传,断点续传,并发控制实现原创 - CSDN博客
- 前端如何获取图片的hash值 - PingCode 智库
- 在线计算文件Hash值- 拉米工具
- 大文件上传优化,断点续传,分片上传- javascript - SegmentFault 思否
- 使用分片上传的方式上传大文件_对象存储(OSS) - 阿里云文档