数据通道在文件传输中的应用:分片策略、校验与重传、进度回调实现
文件传输,可以说是数据通道最经典的应用场景之一。我最早接触 WebRTC 时,第一个实战项目就是做个点对点传文件的小工具。当时觉得挺简单——数据通道能发二进制数据,那直接把文件扔进去不就完了?结果一跑,大文件直接卡死,小文件偶尔还丢数据。嗯,这里面的坑,我今天帮你一个一个填上。
为什么需要分片?
数据通道底层走的是 SCTP 协议。SCTP 本身有消息长度限制,不同浏览器实现也不一样。Chrome 这边,单条消息超过 256KB 就可能出问题。你想想看,一个几百兆的视频文件,不分片怎么传?
分片还有个好处:可以并行发送。把文件切成小块,一块一块发出去,接收端再拼回来。这样既利用了带宽,又不会把内存撑爆。
核心原则:分片大小建议控制在 16KB ~ 64KB 之间。太小了包头开销大,太大了容易触发 SCTP 流控。
分片策略怎么设计?
我个人习惯用固定大小分片,简单可靠。每片加个头部信息,包含文件 ID、片序号、总片数、数据长度。接收端靠这些信息重组文件。
来看一个分片的数据结构:
// 分片头部结构
struct FileChunkHeader {
uint32_t fileId; // 文件唯一标识
uint32_t chunkIndex; // 当前片序号(从0开始)
uint32_t totalChunks; // 总片数
uint32_t dataLength; // 本片数据长度
uint32_t checksum; // CRC32 校验值
};
// 发送端分片逻辑
function sliceFile(file, chunkSize = 32768) {
const chunks = [];
let offset = 0;
let index = 0;
while (offset < file.size) {
const blob = file.slice(offset, offset + chunkSize);
chunks.push({
index: index++,
data: blob,
offset: offset
});
offset += chunkSize;
}
return chunks;
}
我在项目中遇到过一个问题:分片大小设成 256KB,结果在移动端网络波动时,丢片率特别高。后来改成 32KB,情况好多了。说白了,分片越小,单次传输失败的成本越低。
校验机制:别让数据悄悄出错
数据通道虽然基于 SCTP,SCTP 本身有校验,但端到端的完整性还是得自己保证。为什么?因为中间可能有代理、网关做奇怪的事情。我曾经排查过一个 bug,传图片偶尔花屏,最后发现是某个老旧代理修改了数据包。
校验我推荐用 CRC32,计算快,碰撞率低。每片发之前算一次 CRC,接收端收到后再算一次,对比一致才算通过。
// CRC32 计算(浏览器环境)
function crc32(data) {
// 使用 Web Crypto API 或现成库
// 这里示意逻辑
let crc = 0xFFFFFFFF;
for (let i = 0; i < data.length; i++) {
crc ^= data[i];
for (let j = 0; j < 8; j++) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
}
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
// 校验对比
function verifyChunk(chunk, receivedChecksum) {
const computed = crc32(chunk);
return computed === receivedChecksum;
}
小技巧:除了每片校验,文件传输完成后还可以对整个文件做一次 MD5 或 SHA-256 校验。这样能发现极低概率的 CRC 碰撞问题。
重传机制:丢了怎么办?
数据通道默认是可靠传输,但可靠不代表不会丢。当网络状况差时,SCTP 的重传超时时间可能很长,用户体验很差。我的做法是:应用层自己做超时重传。
基本思路:
- 发送端每发一片,启动一个定时器(比如 3 秒)
- 接收端收到后,回复一个 ACK(确认包)
- 发送端超时未收到 ACK,就重传该片
- 重传超过 3 次,判定传输失败
// 发送端重传逻辑
class FileSender {
constructor(channel) {
this.channel = channel;
this.pendingAcks = new Map(); // chunkIndex -> timer
this.maxRetries = 3;
}
sendChunk(chunk) {
const retryCount = chunk.retryCount || 0;
if (retryCount >= this.maxRetries) {
console.error('重传次数超限,放弃传输');
return;
}
this.channel.send(chunk.data);
// 启动超时定时器
const timer = setTimeout(() => {
console.log(`片 ${chunk.index} 超时,开始重传`);
chunk.retryCount = retryCount + 1;
this.sendChunk(chunk);
}, 3000);
this.pendingAcks.set(chunk.index, timer);
}
onAck(chunkIndex) {
const timer = this.pendingAcks.get(chunkIndex);
if (timer) {
clearTimeout(timer);
this.pendingAcks.delete(chunkIndex);
}
}
}
注意:ACK 包本身也可能丢。所以接收端收到重复片时,要能去重。每片根据 chunkIndex 判断,已经收到的直接丢弃,但还是要回复 ACK。
进度回调:让用户心里有数
没有进度条的文件传输,用户会焦虑。进度回调的核心是:实时计算已传输字节数占总字节数的比例。
我习惯用事件回调的方式暴露进度:
// 进度回调接口
class FileTransfer {
constructor() {
this.onProgress = null; // (progress: number) => void
this.onComplete = null; // () => void
this.onError = null; // (error: Error) => void
}
// 发送端进度更新
updateSendProgress(sentBytes, totalBytes) {
const progress = sentBytes / totalBytes;
if (this.onProgress) {
this.onProgress({
type: 'send',
loaded: sentBytes,
total: totalBytes,
percent: Math.round(progress * 100)
});
}
}
// 接收端进度更新
updateReceiveProgress(receivedBytes, totalBytes) {
const progress = receivedBytes / totalBytes;
if (this.onProgress) {
this.onProgress({
type: 'receive',
loaded: receivedBytes,
total: totalBytes,
percent: Math.round(progress * 100)
});
}
}
}
// 使用示例
const transfer = new FileTransfer();
transfer.onProgress = (info) => {
console.log(`${info.type} 进度: ${info.percent}%`);
// 更新 UI 进度条
updateProgressBar(info.percent);
};
进度更新不要太频繁。每收到一片更新一次就够了,没必要每字节都触发。否则 UI 渲染压力大,反而卡顿。
整体流程梳理
我把整个文件传输的流程画了张图,方便你理解各环节的关系:
完整代码骨架
最后,我把上面这些整合成一个简单的文件传输类。你拿去改改就能用:
class FileTransferOverDataChannel {
constructor(channel, file) {
this.channel = channel;
this.file = file;
this.chunkSize = 32768; // 32KB
this.chunks = [];
this.sentCount = 0;
this.ackCount = 0;
this.retryMap = new Map();
this.maxRetries = 3;
this.onProgress = null;
this.onComplete = null;
this.onError = null;
}
start() {
this.chunks = this.sliceFile(this.file);
this.sendNext();
}
sliceFile(file) {
const chunks = [];
let offset = 0;
let index = 0;
while (offset < file.size) {
const blob = file.slice(offset, offset + this.chunkSize);
chunks.push({ index, data: blob, offset, retries: 0 });
offset += this.chunkSize;
index++;
}
return chunks;
}
sendNext() {
if (this.sentCount >= this.chunks.length) return;
const chunk = this.chunks[this.sentCount];
this.sendChunk(chunk);
this.sentCount++;
this.updateProgress();
}
sendChunk(chunk) {
const header = this.buildHeader(chunk);
const message = { header, data: chunk.data };
this.channel.send(JSON.stringify(message));
// 启动超时定时器
const timer = setTimeout(() => {
if (chunk.retries < this.maxRetries) {
chunk.retries++;
console.log(`重传片 ${chunk.index},第 ${chunk.retries} 次`);
this.sendChunk(chunk);
} else {
this.onError && this.onError(new Error(`片 ${chunk.index} 重传失败`));
}
}, 3000);
this.retryMap.set(chunk.index, timer);
}
buildHeader(chunk) {
return {
fileId: this.file.name + this.file.size,
chunkIndex: chunk.index,
totalChunks: this.chunks.length,
dataLength: chunk.data.size,
checksum: this.calcChecksum(chunk.data)
};
}
calcChecksum(data) {
// 实际使用 CRC32 库
return 0;
}
onAck(chunkIndex) {
const timer = this.retryMap.get(chunkIndex);
if (timer) {
clearTimeout(timer);
this.retryMap.delete(chunkIndex);
}
this.ackCount++;
this.updateProgress();
if (this.ackCount === this.chunks.length) {
this.onComplete && this.onComplete();
}
}
updateProgress() {
if (this.onProgress) {
const loaded = this.ackCount * this.chunkSize;
const total = this.file.size;
this.onProgress({
loaded: Math.min(loaded, total),
total: total,
percent: Math.round((this.ackCount / this.chunks.length) * 100)
});
}
}
}
经验之谈:实际项目中,我还会加一个「传输速度统计」功能。每秒钟算一次已传输字节数,显示在 UI 上。用户看到速度正常,心里就不慌。实现也不复杂,维护一个时间窗口内的传输量就行。
文件传输看似简单,但要做好,分片大小、校验、重传、进度反馈,一个都不能少。你把这些机制都搭好了,后面再加断点续传、多文件并发,都是水到渠成的事。