录制文件上传:从 Blob 到断点续传
文件上传这件事,说起来简单,做起来坑不少。尤其是 WebRTC 录制的视频文件,动不动就几百 MB,甚至上 GB。你要是直接一个 POST 丢给服务器,大概率会超时、断连、或者直接被浏览器干掉。
我最早做这个功能时,就吃过这个亏。录了一个 10 分钟的会议视频,上传到一半网络闪断,结果全白录了。从那以后,我再也不敢小看上传模块的设计。
今天咱们就把这块彻底讲透。从最基础的 FormData 上传 Blob,到分片上传、进度显示,再到断点续传,一步步来。
1. 基础上传:FormData + Blob
先看最简单的场景。录制完成后,你手里有一个 Blob 对象。怎么把它扔给服务器?
用 FormData 包装一下就行。这是浏览器原生支持的,兼容性很好。
function uploadBlob(blob, fileName) {
const formData = new FormData();
formData.append('file', blob, fileName);
formData.append('type', 'video/webm');
return fetch('/upload', {
method: 'POST',
body: formData
});
}
// 调用示例
const blob = new Blob(recordedChunks, { type: 'video/webm' });
uploadBlob(blob, 'meeting-2024-01-15.webm')
.then(res => res.json())
.then(data => console.log('上传成功', data.url));
这里有个细节要注意:append 的第三个参数是文件名。如果不传,服务器收到的文件名可能是 blob 或者空字符串。我个人习惯用时间戳 + 会话 ID 来命名,方便后续排查。
2. 分片上传:大文件的救星
文件一大,直接上传就不靠谱了。你想想看,一个 500MB 的文件,上传到 80% 突然断了,你甘心吗?
分片上传的思路很简单:把大文件切成小块,一块一块传。每块独立上传,互不影响。
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 一片
function sliceFile(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;
}
async function uploadChunks(file, fileId) {
const chunks = sliceFile(file);
const total = chunks.length;
for (let i = 0; i < total; i++) {
const formData = new FormData();
formData.append('chunk', chunks[i]);
formData.append('fileId', fileId);
formData.append('index', i);
formData.append('total', total);
await fetch('/upload/chunk', {
method: 'POST',
body: formData
});
console.log(`已上传 ${i + 1}/${total}`);
}
// 通知服务端合并
await fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId, total })
});
}
分片大小怎么定?我建议 1MB 到 10MB 之间。太小的片会导致 HTTP 请求过多,太大的片又失去了分片的意义。5MB 是个比较折中的值,我在项目中一直用这个。
3. 显示上传进度
用户最关心什么?进度。没有进度条的上传,就像没有倒计时的考试——心里没底。
用 XMLHttpRequest 的 upload.onprogress 事件可以拿到实时进度。Fetch API 虽然更现代,但进度支持不如 XHR 方便。
function uploadWithProgress(blob, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', blob, 'recording.webm');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error('上传失败'));
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.open('POST', '/upload');
xhr.send(formData);
});
}
// 使用
uploadWithProgress(blob, (percent) => {
document.getElementById('progress-bar').style.width = percent + '%';
document.getElementById('progress-text').textContent = percent + '%';
});
分片上传的进度怎么算?简单:已上传片数 / 总片数。但更精确的做法是:已上传字节数 / 总字节数。每个分片上传时,累加它的 loaded 值。
4. 断点续传:最硬核的部分
断点续传,说白了就是记住上次传到哪里了,下次接着传。这需要前端和后端配合。
核心思路:
- 上传前,先问服务器:这个文件之前传过没?
- 如果传过,返回已收到的分片列表。
- 前端跳过已上传的分片,只传缺失的。
async function resumeUpload(file, fileId) {
// 1. 查询已上传的分片
const res = await fetch(`/upload/status?fileId=${fileId}`);
const { uploadedIndexes } = await res.json();
// 2. 分片
const chunks = sliceFile(file);
const uploadedSet = new Set(uploadedIndexes);
// 3. 只传缺失的
for (let i = 0; i < chunks.length; i++) {
if (uploadedSet.has(i)) {
console.log(`分片 ${i} 已上传,跳过`);
continue;
}
const formData = new FormData();
formData.append('chunk', chunks[i]);
formData.append('fileId', fileId);
formData.append('index', i);
formData.append('total', chunks.length);
await fetch('/upload/chunk', {
method: 'POST',
body: formData
});
// 保存进度到本地
localStorage.setItem(`upload_${fileId}`, JSON.stringify({
lastIndex: i,
total: chunks.length
}));
}
// 4. 通知合并
await fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId, total: chunks.length })
});
// 5. 清理本地记录
localStorage.removeItem(`upload_${fileId}`);
}
这里有个坑:本地存储的进度和服务器实际收到的可能不一致。比如用户清除了浏览器缓存,或者换了设备。所以永远以服务器返回的 uploadedIndexes 为准。
5. 整体流程梳理
把上面这些串起来,一个完整的上传模块长这样:
流程其实不复杂。关键是要把异常情况都考虑到:网络断了怎么办?用户关掉页面怎么办?服务端重启了怎么办?
我曾经在一个项目中,因为没处理服务端合并时的超时,导致用户上传了 90% 的分片,最后合并失败,所有分片都得重传。那次教训让我养成了一个习惯:合并接口一定要做幂等处理,即使重复调用也不会出问题。
6. 避坑指南
最后分享几个我踩过的坑:
- Blob 不可变: 分片时用
file.slice()而不是blob.slice()。Blob 虽然也有 slice 方法,但有些浏览器实现有 bug。 - 并发控制: 分片上传不要一次性发几十个请求。我一般控制并发数在 3-5 个,用
Promise.all配合限流。 - 进度更新频率: onprogress 事件触发很频繁,不要每次更新都操作 DOM。用 requestAnimationFrame 或者节流函数控制一下。
- 文件唯一标识: 不要只用文件名做 fileId。不同用户可能上传同名文件。我习惯用
userId + sessionId + 时间戳生成唯一 ID。
嗯,上传这块就讲到这里。内容不少,但核心就三件事:分片、进度、续传。把这三点做好,用户基本不会抱怨上传体验了。
公众号:蓝海资料掘金营,微信deep3321