7、文件传输实战(上):文件读取与分片、通过DataChannel发送文件块、接收端重组文件

好,咱们今天聊点实在的。前面几章我们把 DataChannel 的基础打牢了,什么可靠模式、无序传输、SCTP 协议栈……这些概念你都懂了。但说实话,光会发字符串和 JSON,那离真正的「实战」还差得远。

真正的实战是什么?是传文件。你想啊,一个视频文件动辄几百 MB,甚至几个 GB,你总不能一股脑塞进一个 DataChannel 消息里吧?那肯定不行。DataChannel 的单条消息大小是有限制的,浏览器里一般也就 16KB 到 64KB 左右。所以,我们必须把大文件切成小块,一块一块地发,到了接收端再拼回去。

这个过程,说白了就是「分而治之」。我当年第一次做文件传输时,心想这还不简单?结果一跑起来,文件损坏、顺序错乱、内存溢出……各种坑踩了个遍。今天我就把这些经验揉碎了讲给你听。

核心要点:文件传输的三大步骤——读取并分片、发送块、重组文件。每一步都有坑,咱们一个一个来填。

7.1 文件读取与分片:把大象装进冰箱的第一步

在浏览器里读取文件,用的是 File API。你通过一个 <input type="file"> 或者拖拽事件拿到 File 对象,然后调用 File.prototype.slice() 方法把它切成若干块。

嗯,这里要注意:slice() 方法返回的是一个 Blob 对象,不是 ArrayBuffer。但我们在 DataChannel 里发送数据,最方便的是用 ArrayBuffer。所以你需要把 Blob 转成 ArrayBuffer,或者直接用 FileReader 来读取。

我个人习惯用 FileReaderreadAsArrayBuffer 方法,因为它能直接拿到二进制数据,省去一次转换。你想想看,如果每个分片都要先转成 Blob 再转成 ArrayBuffer,那性能开销就大了。

分片大小怎么定?我建议用 16KB。为什么?因为浏览器底层对 DataChannel 的消息大小有限制,16KB 是一个比较安全的阈值。你设得太大,消息可能发不出去;设得太小,分片数量太多,控制消息的开销反而大了。

// 读取文件并分片
function sliceFile(file, chunkSize = 16 * 1024) {
  const chunks = [];
  let offset = 0;
  while (offset < file.size) {
    const end = Math.min(offset + chunkSize, file.size);
    const blob = file.slice(offset, end);
    chunks.push(blob);
    offset = end;
  }
  return chunks;
}

// 将 Blob 分片转为 ArrayBuffer
async function blobToArrayBuffer(blob) {
  const reader = new FileReader();
  return new Promise((resolve, reject) => {
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsArrayBuffer(blob);
  });
}

小技巧:分片时最好给每个分片编个号,比如从 0 开始递增。这样接收端收到后可以按序号排序,就算乱序到达也不怕。我一般会在分片数据前面加一个 4 字节的序号头,这样接收端解析起来非常快。

7.2 通过 DataChannel 发送文件块:别让数据堵在路上

分片准备好了,接下来就是发送。你可能会想:直接一个 for 循环,把每个分片 send() 出去不就行了?

千万别这么干!

我曾经犯过这个错。那时候我传一个 100MB 的文件,分成了 6000 多块,然后一股脑全塞进 send 方法里。结果浏览器直接卡死,DataChannel 的缓冲队列爆了,消息丢失了一大半。

为什么会这样?因为 send() 是异步的,但它不会等你把数据发完再返回。你连续调用 send,数据会堆积在底层缓冲区里。一旦缓冲区满了,浏览器就会报错,甚至断开连接。

正确的做法是:监听 bufferedAmount 属性,当它低于某个阈值时,再继续发送下一批数据。这就像水管里的水,你不能一直灌,得等水流出去一些再灌。

// 发送文件块的正确姿势
async function sendFileChunks(dataChannel, chunks) {
  const MAX_BUFFER = 64 * 1024; // 缓冲区上限 64KB
  let index = 0;

  function sendNext() {
    while (index < chunks.length && dataChannel.bufferedAmount < MAX_BUFFER) {
      const chunk = chunks[index];
      // 构造消息:4字节序号 + 分片数据
      const header = new Uint32Array([index]);
      const message = new Uint8Array(4 + chunk.byteLength);
      message.set(new Uint8Array(header.buffer), 0);
      message.set(new Uint8Array(chunk), 4);
      dataChannel.send(message.buffer);
      index++;
    }

    if (index < chunks.length) {
      // 缓冲区满了,等它清空再继续
      dataChannel.onbufferedamountlow = () => {
        dataChannel.onbufferedamountlow = null;
        sendNext();
      };
    } else {
      console.log('所有分片发送完毕');
    }
  }

  sendNext();
}

注意:bufferedamountlow 事件需要你先设置 dataChannel.bufferedAmountLowThreshold 属性,否则它不会触发。我一般设成 16KB,这样缓冲区一降到 16KB 以下,就立刻开始发下一批。

另外,发送时别忘了带上元数据。比如文件名、文件总大小、分片总数这些信息。我习惯在发送第一个分片之前,先发一个 JSON 格式的「文件头」消息,告诉接收端:我要传一个叫 xxx 的文件,总共 1000 个分片,每个分片 16KB。这样接收端就能提前分配好内存,做好重组准备。

7.3 接收端重组文件:把碎片拼回原样

接收端的工作其实更考验细节。你收到一堆乱序的分片,怎么把它们拼回完整的文件?

我的做法是:维护一个数组,数组长度等于分片总数。每收到一个分片,就根据序号把它放到数组的对应位置。等所有分片都到齐了,再把它们拼接成一个完整的 Uint8Array,最后转成 Blob 下载。

这里有个坑:分片可能重复到达。DataChannel 虽然是可靠的,但在某些极端情况下(比如网络闪断重连),接收端可能会收到重复的分片。所以你要做去重处理——如果某个位置已经有数据了,就忽略新来的。

// 接收端重组文件
class FileReceiver {
  constructor(totalChunks, fileName) {
    this.totalChunks = totalChunks;
    this.fileName = fileName;
    this.chunks = new Array(totalChunks).fill(null);
    this.receivedCount = 0;
  }

  receiveChunk(data) {
    const view = new Uint8Array(data);
    const index = new Uint32Array(data.slice(0, 4))[0];
    const chunkData = view.slice(4);

    // 去重:如果这个位置已经有数据了,跳过
    if (this.chunks[index] !== null) {
      console.warn(`重复分片: ${index}`);
      return;
    }

    this.chunks[index] = chunkData;
    this.receivedCount++;

    // 检查是否全部收齐
    if (this.receivedCount === this.totalChunks) {
      this.assembleFile();
    }
  }

  assembleFile() {
    // 计算总大小
    const totalSize = this.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
    const merged = new Uint8Array(totalSize);
    let offset = 0;
    for (const chunk of this.chunks) {
      merged.set(chunk, offset);
      offset += chunk.byteLength;
    }

    // 创建 Blob 并触发下载
    const blob = new Blob([merged]);
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = this.fileName;
    a.click();
    URL.revokeObjectURL(url);

    console.log('文件重组完成!');
  }
}

经验之谈:如果文件很大(比如超过 1GB),你最好用 IndexedDB 来暂存分片,而不是全放在内存里。否则浏览器可能会因为内存不足而崩溃。我做过一个项目,传 4GB 的视频文件,就是用 IndexedDB 做临时存储,每收到一个分片就写入数据库,最后再统一读出拼接。

7.4 整体流程梳理

说了这么多,咱们用一张图把整个流程串起来。你一看就明白了。

发送端 1. 用户选择文件 2. slice() 分片 (16KB/块) 3. 添加序号头 (4字节) 4. 控制流速发送 (bufferedAmount) DataChannel 传输 网络 接收端 5. 解析序号头 6. 按序号放入数组 (去重) 7. 检查是否收齐所有分片 8. 拼接 Uint8Array → Blob 触发下载 / 保存

你看,整个流程其实不复杂。发送端做四件事:选文件、切分片、加序号、控流速。接收端也做四件事:解析序号、按序存放、检查完整性、拼接下载。两边配合好,文件传输就稳了。

最后提醒一句:别忘了处理错误和中断。比如用户中途取消、网络断开、分片丢失……这些情况都要有对应的恢复机制。我一般会在接收端加一个超时检测,如果超过 30 秒没收到新分片,就主动询问发送端重传丢失的块。

好了,这一章的内容就到这里。代码你拿去就能用,但建议你根据自己的场景调整分片大小和缓冲区阈值。下一章我们继续深入,聊聊大文件传输中的断点续传和错误恢复。


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