上传一个2G的视频,网络断了,从头再来。用户心态崩了,你也崩了。今天我们用前端+Node.js实现一套完整的大文件上传方案:分片上传、断点续传、秒传、进度条。代码可直接复制到项目中使用。
一、痛点:为什么大文件上传要"分片"?
传统<input type="file"> + FormData上传,是一次性把整个文件发到服务器。缺点:
- 网络中断就得重头传
- 占用大量内存和带宽
- 无法实时看到进度
- 服务器接收超时风险高
分片上传:把大文件切成小块(比如每片1MB),一片一片传,失败只重传失败的片。所有片传完,服务器再合并。
二、前端实现:分片 + 断点续传 + 秒传
1. 文件分片 & 计算hash
js
// 分片大小:1MB
const CHUNK_SIZE = 1024 * 1024;
// 计算文件hash(用于秒传和断点续传)
async function calculateHash(file) {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// 生成分片
function createChunks(file) {
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push(file.slice(start, end));
start = end;
}
return chunks;
}
2. 上传前检查(秒传 & 断点续传)
js
async function uploadFile(file) {
const hash = await calculateHash(file);
// 1. 检查文件是否已存在(秒传)
const checkRes = await fetch('/api/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash })
});
const { uploaded } = await checkRes.json();
if (uploaded) {
alert('秒传成功!');
return;
}
// 2. 获取已上传的分片索引(断点续传)
const progressRes = await fetch('/api/progress', {
method: 'POST',
body: JSON.stringify({ hash })
});
const { uploadedChunks = [] } = await progressRes.json();
const chunks = createChunks(file);
const total = chunks.length;
let completed = 0;
// 3. 并发上传未完成的分片(限制并发数)
const concurrency = 5;
const queue = [...chunks.entries()];
const active = new Set();
while (queue.length || active.size) {
while (active.size < concurrency && queue.length) {
const [index, chunk] = queue.shift();
if (uploadedChunks.includes(index)) {
completed++;
updateProgress(completed, total);
continue;
}
const promise = uploadChunk(hash, index, chunk, total);
active.add(promise);
promise.finally(() => {
active.delete(promise);
completed++;
updateProgress(completed, total);
});
}
await Promise.race(active);
}
// 4. 通知服务器合并
await fetch('/api/merge', {
method: 'POST',
body: JSON.stringify({ hash, fileName: file.name, total })
});
}
3. 上传单个分片
js
async function uploadChunk(hash, index, chunk, total) {
const formData = new FormData();
formData.append('hash', hash);
formData.append('index', index);
formData.append('total', total);
formData.append('chunk', chunk);
await fetch('/api/upload', { method: 'POST', body: formData });
}
4. 进度条
js
function updateProgress(completed, total) {
const percent = (completed / total * 100).toFixed(2);
document.getElementById('progress').style.width = `${percent}%`;
document.getElementById('progress-text').innerText = `${percent}%`;
}
三、Node.js后端实现
js
const express = require('express');
const multer = require('multer');
const fs = require('fs-extra');
const path = require('path');
const app = express();
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');
const TEMP_DIR = path.resolve(__dirname, 'temp');
app.use(express.json());
// 检查文件是否已存在(秒传)
app.post('/api/check', async (req, res) => {
const { hash } = req.body;
const filePath = path.join(UPLOAD_DIR, hash);
if (await fs.pathExists(filePath)) {
res.json({ uploaded: true });
} else {
res.json({ uploaded: false });
}
});
// 获取已上传的分片(断点续传)
app.post('/api/progress', async (req, res) => {
const { hash } = req.body;
const chunkDir = path.join(TEMP_DIR, hash);
let uploadedChunks = [];
if (await fs.pathExists(chunkDir)) {
const files = await fs.readdir(chunkDir);
uploadedChunks = files.map(f => parseInt(f.split('_')[1]));
}
res.json({ uploadedChunks });
});
// 上传分片
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const { hash } = req.body;
const chunkDir = path.join(TEMP_DIR, hash);
await fs.ensureDir(chunkDir);
cb(null, chunkDir);
},
filename: (req, file, cb) => {
const { index } = req.body;
cb(null, `${file.originalname}_${index}`);
}
});
const upload = multer({ storage });
app.post('/api/upload', upload.single('chunk'), (req, res) => {
res.json({ ok: true });
});
// 合并分片
app.post('/api/merge', async (req, res) => {
const { hash, fileName, total } = req.body;
const chunkDir = path.join(TEMP_DIR, hash);
const filePath = path.join(UPLOAD_DIR, `${hash}_${fileName}`);
const writeStream = fs.createWriteStream(filePath);
for (let i = 0; i < total; i++) {
const chunkPath = path.join(chunkDir, `${fileName}_${i}`);
const data = await fs.readFile(chunkPath);
writeStream.write(data);
}
writeStream.end();
await fs.remove(chunkDir);
res.json({ ok: true });
});
四、扩展:秒传原理 & 断点续传
- 秒传:上传前计算文件hash,服务端检查是否已存在。有则直接返回,不需要重新上传。
- 断点续传:每次上传前询问服务端已上传的分片索引,只传缺失的。
- 并发控制:限制同时上传的分片数量,避免网络拥塞(上面代码限制5个并发)。
- 失败重试 :可在
uploadChunk中加入重试逻辑,比如失败后重试3次。
五、生产环境注意事项
- 分片大小:网络好的环境可以用5MB/片,差的用1MB。可根据文件大小动态调整。
- hash计算 :大文件计算hash较慢,可以用
requestIdleCallback或在Web Worker中进行,避免阻塞UI。 - 服务端合并 :上面的合并方式是逐个读取写入,大文件可能占用内存。可用
createReadStream+pipe流式合并。 - 清理临时文件:定期清理未合并完成的临时分片(比如超过24小时未合并的删除)。
- HTTPS:分片上传涉及文件数据,务必使用HTTPS。
六、总结
| 功能 | 实现方式 |
|---|---|
| 分片上传 | File.slice() + 并发控制 |
| 断点续传 | 服务端记录已上传分片 + 前端跳过 |
| 秒传 | 文件hash + 服务端比对 |
| 进度条 | 已上传分片数 / 总分片数 |
这套方案已在我司多个项目中稳定运行,支持GB级文件上传。你可以根据自己的需求调整分片大小、并发数、重试策略。
你在大文件上传中还遇到过什么坑?评论区交流。点个赞让更多需要的人看到。