WebTransport 提供基于 HTTP/3 的数据报接口,允许在客户端与服务器之间交换长度受限的不可靠消息。与基于 QUIC 流的可靠传输不同,Datagram 的发送结果不会携带确认信息,也没有自动重传。如果某个控制包丢失,上层业务要么容忍,要么自行设计重传。在 TypeScript 项目中,如果没有清晰定义重传类型和接口,很容易出现魔法数字与状态混乱。本文会从类型系统层面给出一个可落地的设计。

先厘清不可靠数据报的重传边界
WebTransport 规范把 Datagram 定义为一种无确认、无顺序保证的传输方式。浏览器端通常通过 WebTransport.datagrams 拿到一个双工流对象,其中 readable 是 ReadableStream<Uint8Array>,writable 是 WritableStream<Uint8Array>。与可靠流不同,datagram 流没有接收确认,也不会在丢包后自动重发。规范中出现的 outgoingMaxAge 和 incomingMaxAge 只是控制数据报在发送队列或接收缓冲中的最长存活时间,超过该时间的数据报会被丢弃,它们并不实现重传。
因此,重传只能放在应用层。常见做法是给每个数据报分配唯一标识,维护一个未确认条目表,并按照一定的超时策略重复发送。这样做的好处是业务可以按需选择可靠级别:关键信令使用带重传的队列,实时位置更新则直接发送一次即可。为了在 TypeScript 中安全地实现这一层,必须先定义好核心数据结构,避免给 any 留出空间。
interface DatagramDuplexStream {
readable: ReadableStream<Uint8Array>;
writable: WritableStream<Uint8Array>;
maxDatagramSize: number;
incomingMaxAge: number;
outgoingMaxAge: number;
}
interface DatagramTransport {
datagrams: DatagramDuplexStream;
ready: Promise<void>;
closed: Promise<WebTransportCloseInfo>;
close: (info?: WebTransportCloseInfo) => void;
}
interface WebTransportCloseInfo {
closeCode: number;
reason: string;
}
定义重传策略与待确认条目类型
重传策略的类型应该独立于业务载荷,这样同一个策略可以复用到不同消息通道。上面的 RetransmissionPolicy 包含最大尝试次数、基础超时、最大超时和退避系数。把超时拆成基础值和最大值,是因为指数退避很容易在弱网下迅速膨胀,导致重传间隔过长;用 maxTimeoutMs 可以限制最坏情况。
待确认条目 DatagramEntry<T> 使用了泛型参数,允许载荷类型从默认的 Uint8Array 切换成自定义序列化结构。状态字段 status 没有随意写成字符串,而是限制了五个可判别状态:待处理、发送中、已确认和已过期。这样在写判断逻辑时,TypeScript 可以根据状态缩小类型范围,减少错误处理分支。
type RetryStatus = 'idle' | 'pending' | 'sent' | 'acked' | 'expired';
interface RetransmissionPolicy {
maxAttempts: number;
baseTimeoutMs: number;
maxTimeoutMs: number;
backoffMultiplier: number;
}
interface DatagramEntry<T = Uint8Array> {
readonly id: string;
readonly payload: T;
readonly createdAt: number;
attempts: number;
nextSendAt: number;
status: RetryStatus;
}
实现带指数退避的重传队列管理器
有了类型之后,下一步是把散落的定时器和条目查询收敛到一个类中。DatagramRetransmitQueue 接收策略对象、发送回调与确认回调,内部维护两个 Map:一个保存条目,另一个保存定时器句柄。泛型约束 T extends Uint8Array 是为了让发送回调明确拿到已经编码好的字节,而不是在运行时才猜测数据形态。
重传调度器的核心是 scheduleRetry。每个条目在首次发送后进入待确认状态,并根据当前尝试次数计算下一次延迟。代码中使用 Math.pow 实现指数退避,同时用 Math.min 截断到最大超时。定时器触发后先检查状态,已经确认或过期的条目直接返回,避免无效重发。这种判断必须在定时器回调内部进行,因为外部调用的 acknowledge 已经清理了定时器和条目,但 JavaScript 定时器回调仍然可能已经进入任务队列。
需要特别注意的是,enqueue 方法在入队时立即发送一次并增加尝试计数,然后才安排第一次重传。这样做可以保持发送时延最低,同时把重传逻辑集中在调度器中。最大尝试次数达到后,条目会被标记为过期并从表中移除,业务层可以通过回调或状态查询感知失败。
type AckHandler<T> = (id: string, entry: DatagramEntry<T>) => void;
type SendHandler<T> = (payload: T) => void;
class DatagramRetransmitQueue<T extends Uint8Array> {
private entries = new Map<string, DatagramEntry<T>>();
private timers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly policy: RetransmissionPolicy,
private readonly sendHandler: SendHandler<T>,
private readonly ackHandler: AckHandler<T>
) {}
enqueue(id: string, payload: T): void {
const now = Date.now();
const entry: DatagramEntry<T> = {
id,
payload,
createdAt: now,
attempts: 0,
nextSendAt: now,
status: 'pending'
};
this.entries.set(id, entry);
this.sendHandler(payload);
entry.attempts += 1;
entry.status = 'sent';
entry.nextSendAt = now + this.policy.baseTimeoutMs;
this.scheduleRetry(id, entry);
}
acknowledge(id: string): void {
const entry = this.entries.get(id);
if (!entry) return;
entry.status = 'acked';
const timer = this.timers.get(id);
if (timer) clearTimeout(timer);
this.entries.delete(id);
this.timers.delete(id);
this.ackHandler(id, entry);
}
private scheduleRetry(id: string, entry: DatagramEntry<T>): void {
const delay = Math.min(
this.policy.baseTimeoutMs * Math.pow(this.policy.backoffMultiplier, entry.attempts),
this.policy.maxTimeoutMs
);
const timer = setTimeout(() => {
if (entry.status === 'acked' || entry.status === 'expired') return;
if (entry.attempts >= this.policy.maxAttempts) {
this.expire(id);
return;
}
entry.attempts += 1;
entry.status = 'sent';
entry.nextSendAt = Date.now() + delay;
this.sendHandler(entry.payload);
this.scheduleRetry(id, entry);
}, delay);
this.timers.set(id, timer);
}
private expire(id: string): void {
const entry = this.entries.get(id);
if (!entry) return;
entry.status = 'expired';
this.timers.delete(id);
this.entries.delete(id);
}
}
与 WebTransport 事件流集成并处理确认
重传队列本身不负责网络收发,它只通过回调与外部交互。这样设计让 WebTransport 的适配层可以独立变化。在实际项目中,发送方可以把 queue.enqueue 包装在 transport.datagrams.writable.getWriter().write 的后面,接收方则读取 transport.datagrams.readable 中的字节,解析出控制报文。
为了区分数据报文和确认报文,线路上可以使用可判别联合 WireMessage,通过 kind 字段进行判别。isAckMessage 的作用是通过类型守卫把普通 WireMessage 缩小到确认消息类型,从而安全地访问 id。数据报文则交给业务处理函数。这样即使消息格式日后扩展,TypeScript 也会提示所有需要处理的分支。
集成时还要注意背压。数据报写入虽然是不可靠的,但如果发送速度长时间超过网络容量,writable 队列会积压。重传队列的发送回调应优先于业务主动发送,或者在检测到 writable.getWriter().desiredSize 过低时暂停新数据入队。可靠性与实时性之间需要根据场景刻意取舍。
type WireMessage =
| { kind: 'data'; id: string; payload: Uint8Array }
| { kind: 'ack'; id: string };
function isAckMessage(msg: WireMessage): msg is Extract<WireMessage, { kind: 'ack' }> {
return msg.kind === 'ack';
}
async function handleIncomingDatagrams(
transport: DatagramTransport,
queue: DatagramRetransmitQueue<Uint8Array>
): Promise<void> {
const reader = transport.datagrams.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
let message: WireMessage;
try {
message = JSON.parse(new TextDecoder().decode(value)) as WireMessage;
} catch {
continue;
}
if (isAckMessage(message)) {
queue.acknowledge(message.id);
} else if (message.kind === 'data') {
console.log('received data', message.id);
}
}
} finally {
reader.releaseLock();
}
}
这套类型定义和队列实现并不与 WebTransport 的直接 API 绑定,你可以方便地替换为 WebSocket 的二进制帧或任意传输通道。关键是明确不可靠数据报的重传策略属于应用层协议的一部分,而 TypeScript 负责把这种协议表达得足够精确。

