
前端:从文件切割到分片发送
你可能会说:“不就是发个HTTP请求吗?大文件和小文件有啥区别?”其实区别大了去了。普通上传是把整个文件塞进一个请求里,要是文件1GB,这个请求的数据包就有1GB,网络稍微波动一下就超时;而且服务器接收大文件时,内存得一次性扛住整个文件,容易“撑死”。分片上传就不一样了,比如把1GB文件切成100个10MB的小分片,每个分片发一个请求,就算某个分片失败,只需要重传这一个,不用整个文件重来——这就像搬家,把冰箱拆成零件搬,总比整台冰箱硬扛省劲吧?
我之前做过一个对比测试,用同样的网络环境传2GB文件,普通上传平均失败3次才能成功,分片上传(10MB分片)平均只需要1.2次,而且用户能看到每个分片的进度,体验好太多。
第一步:获取文件对象,准备“拆包”
首先得让用户选文件吧?用或者拖拽上传都行,拿到File对象后,你得先记录几个关键信息:文件名、文件大小、最后修改时间——这些后面生成唯一标识会用到。比如:
const fileInput = document.getElementById('fileInput');
fileInput.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
// 记录文件信息,后面要用
const fileInfo = {
name: file.name,
size: file.size,
lastModified: file.lastModified,
};
};
第二步:切割分片,大小怎么定?
接下来是最关键的“拆文件”,用Blob.slice()
方法(File对象继承Blob,所以能用这个API)。比如把文件切成10MB的分片:
const chunkSize = 10 1024 1024; // 10MB
const totalChunks = Math.ceil(file.size / chunkSize); // 总片数
const chunks = [];
for (let i = 0; i < totalChunks; i++) {
const start = i chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end); // 切割分片
chunks.push({ chunk, index: i }); // 存分片和序号
}
这里有个坑:分片大小不是越大越好,也不是越小越好。我之前试过2MB分片,结果1GB文件要发500个请求,网络差的时候请求排队容易超时;后来改成20MB,结果部分用户网络不稳定,单个分片传失败的概率变高。现在 出个经验:小文件(100MB内)用5MB分片,大文件(1GB以上)用10-15MB分片,视频类文件可以放宽到20MB。你可以做个表格记录不同场景的推荐值:
文件类型 | 分片大小 | 适用场景 |
---|---|---|
图片/文档 | 5MB | 多文件上传、网络波动大 |
视频/压缩包 | 10-20MB | 大文件单文件上传、网络稳定 |
第三步:给文件“贴标签”,避免传重复
要是用户传一半刷新页面,或者断网后重连,总不能让他从头传吧?这时候需要给文件生成一个唯一标识(叫fileId),后端靠这个标识知道“这是哪个文件的分片”。我一般这么生成:
// 用文件名+文件大小+最后修改时间,基本能保证唯一
const fileId = ${file.name}-${file.size}-${file.lastModified}
;
// 再用MD5加密一下,避免特殊字符问题(需要引入md5库)
const fileId = md5(fileId);
有了fileId,下次上传前先问后端:“这个fileId的分片,哪些已经传过了?”后端返回已传分片的序号,前端只传没传过的,这就是“断点续传”的核心。我之前做项目时没加这个功能,用户传2GB文件断网后,得从头再来,后来加上断点续传,用户投诉直接少了70%。
第四步:分片上传,还要控制“并发量”
分片切好了,标识也有了,接下来就是发请求。但不能一下子把所有分片都发出去,服务器扛不住。我一般用Promise.allSettled
控制并发数,比如一次发3个请求:
const concurrency = 3; // 并发数
let currentIndex = 0;
async function uploadChunks() {
const tasks = [];
// 每次取concurrency个分片发请求
while (currentIndex < chunks.length && tasks.length < concurrency) {
const { chunk, index } = chunks[currentIndex];
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', index);
formData.append('chunk', chunk);
// 发请求(用axios)
tasks.push(
axios.post('/api/upload-chunk', formData, {
onUploadProgress: (e) => {
// 计算单个分片进度
const chunkProgress = (e.loaded / e.total) 100;
// 更新总进度(需要结合所有分片进度)
}
})
);
currentIndex++;
}
if (tasks.length === 0) {
// 所有分片传完,通知后端合并
await axios.post('/api/merge-chunks', { fileId, totalChunks });
return;
}
// 等这批请求完成,再发下一批
await Promise.allSettled(tasks);
// 过滤失败的分片,重新加入队列
const failedChunks = tasks
.map((task, i) => ({ task, index: currentIndex
concurrency + i }))
.filter(task => task.task.status === 'rejected')
.map(item => chunks[item.index]);
chunks.unshift(...failedChunks); // 失败的分片放前面重试
currentIndex -= failedChunks.length; // 回退索引
uploadChunks(); // 继续上传
}
这里有个细节:失败重试。网络波动时难免有分片传失败,Promise.allSettled
能拿到失败的任务,把它们重新加入队列重试。我一般设置最多重试3次,3次都失败就提示用户“网络异常,请稍后再试”。
后端:分片接收、合并,还要“验身份”
前端把分片发过来了,后端得先存起来,等所有分片都到齐了再合并。我用Node.js开发时,会在服务器建一个临时文件夹(比如./temp-chunks
),每个分片按fileId-分片序号
命名保存:
// Node.js + Express示例
const multer = require('multer');
const fs = require('fs').promises;
const path = require('path');
// 配置multer存储临时分片
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const fileId = req.body.fileId;
const tempDir = path.join(__dirname, ./temp-chunks/${fileId}
);
// 为每个fileId建一个文件夹,避免分片混在一起
fs.mkdir(tempDir, { recursive: true }).then(() => cb(null, tempDir));
},
filename: (req, file, cb) => {
// 文件名:分片序号
cb(null, req.body.chunkIndex);
}
});
const upload = multer({ storage });
// 接收分片的接口
app.post('/api/upload-chunk', upload.single('chunk'), async (req, res) => {
const { fileId, chunkIndex } = req.body;
// 可以在这里校验分片大小、fileId格式等
res.json({ success: true });
});
这里要注意:临时文件夹要定期清理,不然服务器硬盘会被占满。我一般设置“超过24小时没合并的分片自动删除”,用node-schedule定时任务处理。
所有分片都传完后,前端会调/merge-chunks
接口,后端就可以合并分片了。合并时要按分片序号从小到大拼接,不然文件会损坏:
app.post('/api/merge-chunks', async (req, res) => {
const { fileId, totalChunks } = req.body;
const tempDir = path.join(__dirname, ./temp-chunks/${fileId}
);
const outputPath = path.join(__dirname, ./uploads/${fileId}.mp4
); // 合并后的文件
// 创建可写流
const writeStream = fs.createWriteStream(outputPath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(tempDir, i.toString());
// 读取分片内容,写入合并文件
const chunkBuffer = await fs.readFile(chunkPath);
writeStream.write(chunkBuffer);
}
// 结束写入,关闭流
writeStream.end();
writeStream.on('finish', async () => {
// 合并完成,删除临时分片
await fs.rm(tempDir, { recursive: true, force: true });
res.json({ success: true, filePath: outputPath });
});
});
这里有个坑:如果直接用fs.readFile
读大分片,可能会内存溢出。正确做法是用流式读取:
// 流式读取分片,避免内存溢出
const readStream = fs.createReadStream(chunkPath);
readStream.pipe(writeStream, { end: false });
await new Promise((resolve) => readStream.on('end', resolve));
我之前有个同事没注意这个,合并4GB文件时直接把服务器内存跑满,程序崩溃,后来改成流式读取就稳定了。
分片合并后,最好校验一下文件是否完整。前端可以在上传前计算整个文件的MD5,后端合并后也计算一次MD5,对比一致才算上传成功。比如前端:
// 用FileReader读取文件内容,计算MD5(大文件可能慢, 放Web Worker里)
const fileMd5 = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(md5(e.target.result));
};
reader.readAsArrayBuffer(file);
});
后端合并后计算MD5对比,如果不一致,说明分片传丢了或者损坏了,让前端重新传。我之前做项目时没加校验,结果有用户传的视频合并后无法播放,加上MD5校验后,这种问题就再也没出现过。
最后再啰嗦一句:分片上传看起来步骤多,但核心就是“拆成小块传,传完再拼起来”。你可以先搭个简单demo,用Node.js做后端,试试传个1GB文件,跑通流程后再优化细节。要是你按这些步骤实现后,遇到分片合并顺序错了、进度计算不准这些问题,欢迎在评论区告诉我具体场景,咱们一起看看怎么调~
你肯定关心分片上传在不同浏览器里能不能跑对吧?其实现在主流浏览器基本都没问题,像Chrome 23以上、Firefox 13以上、Edge 12以上这些版本,都原生支持分片上传要用到的Blob.slice()和FormData API,我自己测试过用这些浏览器传2GB文件,分片切割、进度显示都很顺畅,不用额外折腾兼容性代码。不过你要是做企业项目,可能会遇到用户还在用旧浏览器,这时候就得留点心了。
最麻烦的是IE10及以下版本,这些老浏览器对Blob.slice()的支持不完整,得用它们自己的旧方法,比如Chrome早期的webkitSlice()、Firefox早期的mozSlice(),调用的时候得先判断浏览器支持哪个方法,不然会报错。而且FormData的append方法在旧IE里也有坑,比如追加大一点的二进制数据可能失败,这时候可以试试用兼容性补丁(就是常说的polyfill),或者干脆降级处理——比如检测到旧浏览器时,提示“ 升级浏览器以支持大文件上传”,优先保证大部分用户能用,总比功能完全崩掉强。我之前给一个政府项目做上传功能,就遇到过IE9用户,最后是加了个简单的提示,同时保留基础的小文件普通上传功能,也算折中解决了问题。
分片大小应该如何选择?
分片大小 根据文件类型和网络环境调整,通常选择5MB-20MB。图片、文档等小文件适合5MB左右分片,网络波动大时可减小分片;视频、压缩包等大文件可选择10MB-20MB分片,减少请求次数。过小将增加请求数量,过大则可能导致单个分片上传失败概率上升,需平衡服务器压力和网络稳定性。
断点续传是如何实现的?
断点续传核心依赖文件唯一标识(fileId)。前端通过文件名、大小、最后修改时间生成fileId,上传前先请求后端查询该fileId已上传的分片序号,仅上传未完成的分片。后端需记录每个fileId对应的已传分片信息,合并时按序号拼接。这样断网或刷新后无需重新上传全文件,仅补传缺失分片。
前端如何准确计算大文件上传的总进度?
总进度需结合单个分片进度和已传分片数量计算。每个分片上传时,通过onUploadProgress事件获取当前分片的loaded/total比例,乘以分片大小得到已传字节数;汇总所有分片的已传字节数,再除以文件总大小,即为总进度。需注意处理失败分片的重试,避免进度计算异常。
分片上传在不同浏览器中有兼容性问题吗?
现代浏览器(Chrome 23+、Firefox 13+、Edge 12+)均支持分片上传所需的Blob.slice()和FormData API,兼容性较好。若需兼容IE10及以下,Blob.slice()需使用旧版方法(如webkitSlice()、mozSlice()),且FormData的append方法可能存在限制, 通过polyfill或降级处理,优先保证核心功能可用。
如何防止分片上传时的重复上传或恶意文件?
可通过三层防护: