数据通道在文件传输中的应用:分片策略、校验与重传、进度回调实现

文件传输,可以说是数据通道最经典的应用场景之一。我最早接触 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 渲染压力大,反而卡顿。

整体流程梳理

我把整个文件传输的流程画了张图,方便你理解各环节的关系:

文件传输完整流程 发送端 接收端 1. 选择文件 2. 分片 + CRC 计算 3. 发送分片 4. 等待 ACK / 超时重传 1. 接收分片 2. CRC 校验 3. 回复 ACK 4. 重组文件 数据通道(分片数据) ACK 确认消息 进度回调实时更新 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 上。用户看到速度正常,心里就不慌。实现也不复杂,维护一个时间窗口内的传输量就行。

文件传输看似简单,但要做好,分片大小、校验、重传、进度反馈,一个都不能少。你把这些机制都搭好了,后面再加断点续传、多文件并发,都是水到渠成的事。

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