录制文件上传:从 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 来命名,方便后续排查。

小提示: Blob 的 type 字段要和服务端期望的 MIME 类型一致。WebRTC 录制默认是 video/webm,但有些后端只认 video/mp4。这时候要么转码,要么让后端改配置。

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 是个比较折中的值,我在项目中一直用这个。

注意: 分片上传时,服务端必须支持按顺序合并。如果片序乱了,视频文件会损坏。所以每个分片都要带上 index 和 total 信息。

3. 显示上传进度

用户最关心什么?进度。没有进度条的上传,就像没有倒计时的考试——心里没底。

XMLHttpRequestupload.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. 整体流程梳理

把上面这些串起来,一个完整的上传模块长这样:

录制完成 → Blob 判断文件大小 小于 50MB 大于 50MB FormData 直接上传 分片 + 断点续传 服务端合并/存储 上传完成 ✅

流程其实不复杂。关键是要把异常情况都考虑到:网络断了怎么办?用户关掉页面怎么办?服务端重启了怎么办?

我曾经在一个项目中,因为没处理服务端合并时的超时,导致用户上传了 90% 的分片,最后合并失败,所有分片都得重传。那次教训让我养成了一个习惯:合并接口一定要做幂等处理,即使重复调用也不会出问题。

6. 避坑指南

最后分享几个我踩过的坑:

  • Blob 不可变: 分片时用 file.slice() 而不是 blob.slice()。Blob 虽然也有 slice 方法,但有些浏览器实现有 bug。
  • 并发控制: 分片上传不要一次性发几十个请求。我一般控制并发数在 3-5 个,用 Promise.all 配合限流。
  • 进度更新频率: onprogress 事件触发很频繁,不要每次更新都操作 DOM。用 requestAnimationFrame 或者节流函数控制一下。
  • 文件唯一标识: 不要只用文件名做 fileId。不同用户可能上传同名文件。我习惯用 userId + sessionId + 时间戳 生成唯一 ID。
我的习惯: 上传完成后,把服务端返回的文件 URL 和 fileId 存到 IndexedDB 里。这样即使用户刷新页面,也能看到历史记录。下次再传同类型文件时,还能复用一些元数据。

嗯,上传这块就讲到这里。内容不少,但核心就三件事:分片、进度、续传。把这三点做好,用户基本不会抱怨上传体验了。


公众号:蓝海资料掘金营,微信deep3321