提示:这个纯原生js写的,您可以手动转成自己项目中的上传组件(vue或者react),也很简单的
前言
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中断,今天就带大家从头到尾盘清楚其中的技术细节。
一、先看效果:我们要实现什么?
- 文件分片: 将大文件分割成小块,便于上传和管理
- 分片上传: 并行或串行上传各个分片
- 进度监控: 实时显示上传进度
- 断点续传: 记录已上传分片,支持从中断处继续上传
- 分片合并: 服务器端将所有分片合并为完整文件
二、全流程拆解:从选文件到合并
我们先从宏观视角梳理整个流程,再拆分成前端和后端的具体实现。整个过程可总结为「5 步走」,每一步都有明确的目标和技术要点:
用户选择文件 → 前端分片+算哈希 → 校验文件状态(秒传/断点续传) → 并发上传分片 → 后端合并分片
下面是完整的代码和注释:
<!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>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f7fa;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
padding: 30px;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
}
.upload-area {
border: 2px dashed #3498db;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
margin-bottom: 30px;
transition: all 0.3s;
background-color: #f8fafc;
}
.upload-area:hover {
border-color: #2980b9;
background-color: #f0f7ff;
}
.upload-area.dragover {
border-color: #27ae60;
background-color: #e8f6ef;
}
.upload-area p {
margin-bottom: 15px;
color: #7f8c8d;
}
.file-input {
display: none;
}
.btn {
display: inline-block;
background: #3498db;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background 0.3s;
}
.btn:hover {
background: #2980b9;
}
.btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.btn-success {
background: #27ae60;
}
.btn-success:hover {
background: #219653;
}
.progress-container {
margin: 20px 0;
}
.progress-bar {
height: 20px;
background: #ecf0f1;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
}
.progress {
height: 100%;
background: linear-gradient(90deg, #3498db, #2ecc71);
width: 0%;
transition: width 0.3s;
}
.progress-text {
text-align: center;
font-size: 14px;
color: #7f8c8d;
}
.file-info {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.file-info p {
margin-bottom: 5px;
}
.chunk-info {
margin-top: 20px;
max-height: 200px;
overflow-y: auto;
}
.chunk-item {
display: flex;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid #eee;
}
.chunk-item:last-child {
border-bottom: none;
}
.status-pending {
color: #f39c12;
}
.status-uploading {
color: #3498db;
}
.status-completed {
color: #27ae60;
}
.status-error {
color: #e74c3c;
}
.controls {
display: flex;
gap: 10px;
margin-top: 20px;
}
.message {
padding: 10px;
border-radius: 5px;
margin-top: 15px;
display: none;
}
.message.success {
background: #d4edda;
color: #155724;
display: block;
}
.message.error {
background: #f8d7da;
color: #721c24;
display: block;
}
</style>
</head>
<body>
<div class="container">
<h1>大文件上传示例</h1>
<div class="upload-area" id="uploadArea">
<p>拖放文件到此处或</p>
<input type="file" id="fileInput" class="file-input">
<label for="fileInput" class="btn">选择文件</label>
</div>
<div class="file-info" id="fileInfo" style="display: none;">
<p><strong>文件名:</strong> <span id="fileName"></span></p>
<p><strong>文件大小:</strong> <span id="fileSize"></span></p>
<p><strong>分片数量:</strong> <span id="chunkCount"></span></p>
</div>
<div class="progress-container">
<div class="progress-bar">
<div class="progress" id="progressBar"></div>
</div>
<div class="progress-text" id="progressText">0%</div>
</div>
<div class="chunk-info" id="chunkInfo" style="display: none;">
<h3>分片上传状态</h3>
<div id="chunkList"></div>
</div>
<div class="controls">
<button class="btn" id="uploadBtn" disabled>开始上传</button>
<button class="btn" id="pauseBtn" disabled>暂停上传</button>
<button class="btn" id="resumeBtn" disabled>继续上传</button>
</div>
<div class="message" id="message"></div>
</div>
<script>
// 配置参数
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB 每个分片大小
const MAX_CONCURRENT_UPLOADS = 3; // 最大并发上传数
// 全局变量
let file = null;
let fileHash = '';
let chunkList = [];
let uploadedChunks = new Set();
let isUploading = false;
let isPaused = false;
let uploadedSize = 0;
// DOM 元素
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const chunkCount = document.getElementById('chunkCount');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const chunkInfo = document.getElementById('chunkInfo');
const chunkListElem = document.getElementById('chunkList');
const uploadBtn = document.getElementById('uploadBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resumeBtn = document.getElementById('resumeBtn');
const message = document.getElementById('message');
// 事件监听
fileInput.addEventListener('change', handleFileSelect);
uploadArea.addEventListener('dragover', handleDragOver);
uploadArea.addEventListener('dragleave', handleDragLeave);
uploadArea.addEventListener('drop', handleDrop);
uploadBtn.addEventListener('click', startUpload);
pauseBtn.addEventListener('click', pauseUpload);
resumeBtn.addEventListener('click', resumeUpload);
// 处理文件选择
function handleFileSelect(e) {
const selectedFile = e.target.files[0];
if (selectedFile) {
prepareFile(selectedFile);
}
}
// 处理拖放
function handleDragOver(e) {
e.preventDefault();
uploadArea.classList.add('dragover');
}
function handleDragLeave(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
}
function handleDrop(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
prepareFile(droppedFile);
}
}
// 准备文件
async function prepareFile(selectedFile) {
file = selectedFile;
// 显示文件信息
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
// 计算分片
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
chunkCount.textContent = totalChunks;
// 生成文件哈希(简化版,实际应用中应使用更安全的哈希算法)
fileHash = await generateFileHash(file);
// 初始化分片列表
chunkList = [];
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
chunkList.push({
index: i,
start,
end,
status: 'pending' // pending, uploading, completed, error
});
}
// 显示文件信息和分片列表
fileInfo.style.display = 'block';
chunkInfo.style.display = 'block';
renderChunkList();
// 启用上传按钮
uploadBtn.disabled = false;
// 检查服务器是否已有部分分片
await checkUploadedChunks();
}
// 生成文件哈希(简化版)
async function generateFileHash(file) {
// 在实际应用中,应使用更安全的哈希算法如SHA-256
// 这里使用文件名+大小作为简化哈希
return btoa(`${file.name}-${file.size}`).replace(/[^a-zA-Z0-9]/g, '');
}
// 渲染分片列表
function renderChunkList() {
chunkListElem.innerHTML = '';
chunkList.forEach(chunk => {
const chunkItem = document.createElement('div');
chunkItem.className = 'chunk-item';
chunkItem.innerHTML = `
<span>分片 ${chunk.index + 1}</span>
<span class="status-${chunk.status}">${getStatusText(chunk.status)}</span>
`;
chunkListElem.appendChild(chunkItem);
});
}
// 获取状态文本
function getStatusText(status) {
switch(status) {
case 'pending': return '等待上传';
case 'uploading': return '上传中';
case 'completed': return '已完成';
case 'error': return '上传失败';
default: return '未知';
}
}
// 检查已上传的分片
async function checkUploadedChunks() {
try {
// 在实际应用中,这里应该向服务器发送请求
// 获取已上传的分片列表
const response = await fetch('/api/check-chunks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash,
totalChunks: chunkList.length
})
});
if (response.ok) {
const data = await response.json();
uploadedChunks = new Set(data.uploadedChunks || []);
// 更新已上传的分片状态
uploadedChunks.forEach(index => {
if (chunkList[index]) {
chunkList[index].status = 'completed';
}
});
// 更新进度
updateProgress();
renderChunkList();
showMessage('已找到之前上传的记录,可以继续上传', 'success');
}
} catch (error) {
console.error('检查已上传分片失败:', error);
// 如果检查失败,继续正常上传流程
}
}
// 开始上传
async function startUpload() {
if (!file || isUploading) return;
isUploading = true;
isPaused = false;
uploadBtn.disabled = true;
pauseBtn.disabled = false;
resumeBtn.disabled = true;
// 获取待上传的分片
const chunksToUpload = chunkList.filter(chunk =>
chunk.status !== 'completed' && !uploadedChunks.has(chunk.index)
);
// 分批上传
await uploadChunksInBatches(chunksToUpload);
// 所有分片上传完成后,请求合并
if (uploadedChunks.size === chunkList.length) {
await mergeChunks();
}
isUploading = false;
pauseBtn.disabled = true;
resumeBtn.disabled = true;
if (!isPaused) {
uploadBtn.disabled = false;
showMessage('文件上传完成!', 'success');
}
}
// 分批上传分片
async function uploadChunksInBatches(chunks) {
for (let i = 0; i < chunks.length; i += MAX_CONCURRENT_UPLOADS) {
if (isPaused) break;
const batch = chunks.slice(i, i + MAX_CONCURRENT_UPLOADS);
await Promise.all(batch.map(chunk => uploadChunk(chunk)));
}
}
// 上传单个分片
async function uploadChunk(chunk) {
if (uploadedChunks.has(chunk.index)) return;
chunk.status = 'uploading';
renderChunkList();
try {
const chunkData = file.slice(chunk.start, chunk.end);
const formData = new FormData();
formData.append('file', chunkData);
formData.append('chunkIndex', chunk.index);
formData.append('totalChunks', chunkList.length);
formData.append('fileHash', fileHash);
formData.append('fileName', file.name);
const response = await fetch('/api/upload-chunk', {
method: 'POST',
body: formData
});
if (response.ok) {
chunk.status = 'completed';
uploadedChunks.add(chunk.index);
uploadedSize += chunk.end - chunk.start;
updateProgress();
} else {
throw new Error('上传失败');
}
} catch (error) {
console.error(`分片 ${chunk.index} 上传失败:`, error);
chunk.status = 'error';
}
renderChunkList();
}
// 暂停上传
function pauseUpload() {
isPaused = true;
pauseBtn.disabled = true;
resumeBtn.disabled = false;
}
// 继续上传
function resumeUpload() {
isPaused = false;
pauseBtn.disabled = false;
resumeBtn.disabled = true;
startUpload();
}
// 更新进度
function updateProgress() {
const progress = (uploadedSize / file.size) * 100;
progressBar.style.width = `${progress}%`;
progressText.textContent = `${progress.toFixed(2)}%`;
}
// 合并分片
async function mergeChunks() {
try {
const response = await fetch('/api/merge-chunks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash,
fileName: file.name,
totalChunks: chunkList.length
})
});
if (response.ok) {
showMessage('文件合并完成!', 'success');
} else {
throw new Error('合并失败');
}
} catch (error) {
console.error('合并分片失败:', error);
showMessage('文件合并失败,请重试', 'error');
}
}
// 显示消息
function showMessage(text, type) {
message.textContent = text;
message.className = 'message';
message.classList.add(type);
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
</script>
</body>
</html>
核心功能说明
1. 文件分片
- 将大文件按固定大小(如2MB)分割成多个分片
- 每个分片包含索引、起始位置和结束位置信息
2. 分片上传
-
使用FormData对象封装分片数据
-
支持并发上传(通过MAX_CONCURRENT_UPLOADS控制并发数)
-
每个分片上传时携带文件哈希、分片索引等信息
3. 进度监控
-
实时计算已上传数据量占总文件大小的比例
-
通过进度条和百分比文字展示上传进度
4. 断点续传
-
使用文件哈希标识唯一文件
-
上传前检查服务器已存在的分片
-
暂停后可从断点处继续上传
5. 分片合并
-
所有分片上传完成后,向服务器发送合并请求
-
服务器根据分片索引顺序合并文件
服务器端实现要点
虽然本文主要关注前端实现,但服务器端也需要相应支持:
- 分片上传接口:接收并存储分片文件
- 分片检查接口:返回已上传的分片列表
- 分片合并接口:将所有分片合并为完整文件
使用说明
- 选择或拖放一个大文件到上传区域
- 查看文件信息和分片数量
- 点击"开始上传"按钮开始上传
- 可随时暂停和继续上传
- 上传完成后会显示成功消息
这个实现提供了完整的大文件上传功能,包括分片、进度显示、暂停/继续等关键特性,可以直接用于实际项目或作为学习参考。
736

被折叠的 条评论
为什么被折叠?



