8、文件传输实战(下):进度反馈与UI更新、断点续传思路、错误处理与重传机制

好,咱们接着上一章往下聊。上一章我们把文件切成了块,通过 DataChannel 一股脑地发过去了。但说实话,那只是一个“能跑”的版本。真正要拿出去用,你还得解决三个核心问题:用户看得见进度吗?断了能续吗?出错了怎么办?

这一章,我们就来逐一攻克它们。

8.1 进度反馈:别让用户干等

你想想看,一个几百兆的文件,发送端吭哧吭哧传了半天,接收端那边一点动静都没有。用户心里肯定发毛:“这玩意儿是不是卡死了?”

所以,进度反馈是用户体验的底线。我个人习惯,在发送端和接收端都维护一个“已确认块数”的计数器。

8.1.1 发送端:基于ACK的进度计算

发送端每发一个块,不能发了就完事。你得等接收端回一个 ACK(确认包)。只有收到 ACK,你才能把这个块标记为“已发送成功”。

代码逻辑大概是这样的:

// 发送端维护一个 Map,记录每个块的发送状态
const chunkStatus = new Map(); // key: chunkIndex, value: 'pending' | 'sent' | 'acked'

function sendChunk(index) {
    const chunk = fileChunks[index];
    channel.send(chunk);
    chunkStatus.set(index, 'sent');
    // 启动一个超时计时器,防止丢包
    startRetryTimer(index);
}

// 收到接收端的 ACK
function onAckReceived(index) {
    chunkStatus.set(index, 'acked');
    updateProgress();
}

function updateProgress() {
    const ackedCount = Array.from(chunkStatus.values()).filter(v => v === 'acked').length;
    const progress = (ackedCount / totalChunks) * 100;
    // 更新 UI
    progressBar.value = progress;
    progressText.textContent = `${progress.toFixed(1)}%`;
}
我的小技巧: 进度条别只显示百分比。我习惯在下面加一行小字,比如“已确认 45/100 块”,这样用户心里更有底。

8.1.2 接收端:写文件与进度更新

接收端这边,进度更新的时机就两个:

  • 收到一个块并写入文件后:更新“已接收”进度。
  • 发送 ACK 后:这个 ACK 其实也隐含了“我准备好收下一块了”。

接收端的 UI 更新更简单,因为数据是顺序写入的(或者你可以用 Map 暂存乱序的块,但最终写入文件时还是顺序的)。

let receivedCount = 0;

function onChunkReceived(data, index) {
    // 将数据写入文件(使用 FileSystem API 或 IndexedDB)
    writeChunkToFile(data, index);
    receivedCount++;
    
    // 更新进度
    const progress = (receivedCount / totalChunks) * 100;
    updateUI(progress);
    
    // 发送 ACK
    channel.send(JSON.stringify({ type: 'ack', index: index }));
}

8.2 断点续传思路:从零开始的勇气?不,我们需要“记忆”

这是个大话题。说实话,WebRTC 的 DataChannel 本身不提供断点续传能力。它只是一个管道,数据丢了就丢了,断了就断了。续传的逻辑,得我们自己实现。

核心思路就一句话:记录已成功传输的块,下次重连时跳过它们

8.2.1 记录“已确认块”的位图

我建议用位图(Bitmap)来记录。假设文件被切成 1000 块,你就用一个 1000 位的数组,每一位代表一个块是否已确认。

为什么用位图?因为它体积小,传输方便。你可以在连接建立后,先把位图发给对方。

// 发送端维护的位图
const ackedBitmap = new Uint8Array(Math.ceil(totalChunks / 8));

function markAsAcked(index) {
    const byteIndex = Math.floor(index / 8);
    const bitIndex = index % 8;
    ackedBitmap[byteIndex] |= (1 << bitIndex);
}

function isAcked(index) {
    const byteIndex = Math.floor(index / 8);
    const bitIndex = index % 8;
    return (ackedBitmap[byteIndex] & (1 << bitIndex)) !== 0;
}
断点续传的关键流程:
  1. 发送端和接收端建立连接后,先交换各自的位图。
  2. 发送端根据位图,只发送那些“未确认”的块。
  3. 接收端收到块后,更新自己的位图,并写文件。
  4. 如果连接断开,双方都保存位图到本地(比如 localStorage 或 IndexedDB)。
  5. 下次重连时,加载位图,继续传输。

8.2.2 我踩过的坑:文件修改时间

我曾经遇到过一个很头疼的问题:用户传了一半,突然把源文件修改了。结果续传时,后半段数据跟前半段对不上,文件直接损坏。

解决方案其实不复杂:在传输开始前,计算整个文件的哈希值(比如 SHA-256),并把它作为会话 ID 的一部分。续传时,先校验哈希。如果文件变了,就放弃续传,重新开始。

// 发送端
const fileHash = await calculateSHA256(file);
const sessionId = `${file.name}-${file.size}-${fileHash}`;

// 将 sessionId 作为元数据,先发给接收端
channel.send(JSON.stringify({ type: 'metadata', sessionId, totalChunks }));

8.3 错误处理与重传机制:别让一个坏块毁了整个文件

DataChannel 虽然基于 SCTP,有可靠模式,但网络环境复杂时,丢包、乱序、甚至连接断开都是家常便饭。我们不能指望底层协议解决一切。

8.3.1 超时重传:最简单的保底策略

发送端每发一个块,就启动一个计时器。如果在规定时间内没收到 ACK,就认为这个块丢了,重新发送。

超时时间怎么设?我一般用动态超时:根据最近几次的 RTT(往返时间)来估算。

let rttHistory = [];
const TIMEOUT_MULTIPLIER = 2; // 超时倍率

function calculateTimeout() {
    if (rttHistory.length === 0) return 3000; // 初始 3 秒
    const avgRTT = rttHistory.reduce((a, b) => a + b, 0) / rttHistory.length;
    return avgRTT * TIMEOUT_MULTIPLIER;
}

function startRetryTimer(index) {
    const timeout = calculateTimeout();
    setTimeout(() => {
        if (chunkStatus.get(index) !== 'acked') {
            console.warn(`块 ${index} 超时,准备重传`);
            sendChunk(index); // 重传
        }
    }, timeout);
}

// 收到 ACK 时,记录 RTT
function onAckReceived(index, sendTime) {
    const rtt = Date.now() - sendTime;
    rttHistory.push(rtt);
    // 只保留最近 10 次记录
    if (rttHistory.length > 10) rttHistory.shift();
}
注意: 超时重传要配合去重机制。接收端可能会收到重复的块,你需要根据块索引去重,避免重复写入文件。

8.3.2 选择性重传:别一股脑全重发

如果网络抖动,可能只是中间几个块丢了。这时候,发送端如果能精准地只重传丢失的块,效率会高很多。

接收端可以维护一个“缺失块列表”,定期(比如每收到 10 个块)把这个列表发给发送端。

// 接收端
const missingChunks = new Set();

function onChunkReceived(index) {
    missingChunks.delete(index);
    // 每收到 10 个块,或者每隔 1 秒,发送一次缺失列表
    if (missingChunks.size > 0 && receivedCount % 10 === 0) {
        channel.send(JSON.stringify({ 
            type: 'nack', 
            missing: Array.from(missingChunks) 
        }));
    }
}

// 发送端
function onNackReceived(missingList) {
    missingList.forEach(index => {
        if (chunkStatus.get(index) !== 'acked') {
            sendChunk(index);
        }
    });
}

8.3.3 连接断开与重连

这是最极端的情况。DataChannel 的 onclose 事件会告诉你连接断了。

我的处理策略是:

  • 保存现场:把位图、已接收的块数据(如果存在内存中)都持久化到本地。
  • 尝试重连:使用指数退避策略(1秒、2秒、4秒...最多30秒)重新建立 PeerConnection。
  • 恢复传输:重连成功后,交换位图,继续传输未完成的块。
let retryDelay = 1000;
const MAX_RETRY_DELAY = 30000;

function reconnect() {
    console.log(`尝试重连,等待 ${retryDelay}ms`);
    setTimeout(async () => {
        try {
            await createPeerConnection();
            // 重连成功,恢复传输
            resumeTransfer();
            retryDelay = 1000; // 重置延迟
        } catch (e) {
            console.error('重连失败', e);
            retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
            reconnect();
        }
    }, retryDelay);
}

8.4 本章核心逻辑图

下面这张图,把整个文件传输的闭环流程串起来了。你可以看到进度反馈、断点续传和错误重传是如何协同工作的。

文件传输闭环流程图 发送端 接收端 1. 读取文件,切块 计算哈希,生成会话ID 2. 发送块 + 启动超时计时器 维护位图,标记为“已发送” 3. 处理 ACK / NACK 更新位图,计算RTT 4. 超时重传 / 选择性重传 指数退避,避免网络风暴 A. 接收块,去重 写入文件,更新位图 B. 发送 ACK / NACK 报告缺失块索引 C. 更新进度 UI 显示百分比和已确认块数 D. 断线时保存位图 持久化到 localStorage 数据块 ACK/NACK 重传循环(超时或收到NACK)

总结一下本章的核心要点:

  • 进度反馈:基于 ACK 的计数,让用户看到真实进度。别用“已发送”来算,要用“已确认”。
  • 断点续传:位图 + 文件哈希,缺一不可。位图记录进度,哈希防止文件被篡改。
  • 错误重传:超时重传是保底,选择性重传是优化。连接断开时,保存现场,指数退避重连。

嗯,这一章的内容就到这里。代码量不大,但逻辑环环相扣。你把这些机制都加进去之后,你的文件传输工具才算真正有了“工业级”的底气。


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