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 来读取。
我个人习惯用 FileReader 的 readAsArrayBuffer 方法,因为它能直接拿到二进制数据,省去一次转换。你想想看,如果每个分片都要先转成 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 整体流程梳理
说了这么多,咱们用一张图把整个流程串起来。你一看就明白了。
你看,整个流程其实不复杂。发送端做四件事:选文件、切分片、加序号、控流速。接收端也做四件事:解析序号、按序存放、检查完整性、拼接下载。两边配合好,文件传输就稳了。
最后提醒一句:别忘了处理错误和中断。比如用户中途取消、网络断开、分片丢失……这些情况都要有对应的恢复机制。我一般会在接收端加一个超时检测,如果超过 30 秒没收到新分片,就主动询问发送端重传丢失的块。
好了,这一章的内容就到这里。代码你拿去就能用,但建议你根据自己的场景调整分片大小和缓冲区阈值。下一章我们继续深入,聊聊大文件传输中的断点续传和错误恢复。