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)}%`;
}
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;
}
- 发送端和接收端建立连接后,先交换各自的位图。
- 发送端根据位图,只发送那些“未确认”的块。
- 接收端收到块后,更新自己的位图,并写文件。
- 如果连接断开,双方都保存位图到本地(比如 localStorage 或 IndexedDB)。
- 下次重连时,加载位图,继续传输。
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 本章核心逻辑图
下面这张图,把整个文件传输的闭环流程串起来了。你可以看到进度反馈、断点续传和错误重传是如何协同工作的。
总结一下本章的核心要点:
- 进度反馈:基于 ACK 的计数,让用户看到真实进度。别用“已发送”来算,要用“已确认”。
- 断点续传:位图 + 文件哈希,缺一不可。位图记录进度,哈希防止文件被篡改。
- 错误重传:超时重传是保底,选择性重传是优化。连接断开时,保存现场,指数退避重连。
嗯,这一章的内容就到这里。代码量不大,但逻辑环环相扣。你把这些机制都加进去之后,你的文件传输工具才算真正有了“工业级”的底气。