朴素的 WebRTC 文件传输实现都有一个共同问题:它们遍历文件并在紧密循环中调用 dataChannel.send(),把无上限的数据倾倒进 SCTP 发送缓冲区。这对小载荷还行,但对于超出可用系统内存余量的文件(在没有背压的情况下,通常观察到约 100 MB 及以上就会出问题),RTCDataChannel.bufferedAmount 会不受控制地攀升,内存压力可能导致标签页崩溃。用 TypeScript 通过 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件的流式传输,需要一套根本不同的架构——它围绕背压流控、逐块 AES-GCM 加密,以及 SHA-256 完整性校验来构建。
本教程将构建一个模块化、无服务端的点对点流式文件传输引擎,它完全运行在浏览器中,具备端到端加密,且服务器完全不中继文件数据。技术栈包括 TypeScript 5.x、WebRTC DataChannel、WebCrypto API(AES-GCM 用于加密,SHA-256 用于完整性)、用于分块处理的 Streams API,以及用于开发工具的 Vite。

如何用 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件流式传输
- 搭建脚手架:创建一个 Vite + TypeScript 项目,并把
tsconfig.json配置为严格模式,采用 ES2022 和 DOM 库目标。 - 建立 WebRTC 对等连接,使用 BroadcastChannel(本地开发)或 WebSocket 信令来完成 offer/answer/ICE 交换。
- 创建一个有序 DataChannel,设置
binaryType = 'arraybuffer',并定义一套带类型字节前缀的帧协议。 - 切片:用
TransformStream重新切分原生File.stream()的输出,把源文件切成固定的 64 KB 块。 - 加密:通过 WebCrypto 用 AES-GCM 加密每个块,在每个密文前加上唯一的 12 字节 IV(4 字节索引 + 8 个随机字节)。
- 用
bufferedAmountLowThreshold实现背压,当 SCTP 缓冲超过 256 KB 时暂停,在bufferedamountlow事件上恢复。 - 校验:接收端把每个解密后的块与预先共享的 SHA-256 摘要清单比对,一旦不匹配立即中止。
- 重组:把校验通过的块重新组装成 Blob,并通过
URL.createObjectURL触发下载。
为什么标准 WebRTC 文件传输在规模上会崩
朴素的 WebRTC 文件传输实现都有一个共同问题:它们遍历文件并在紧密循环中调用 dataChannel.send(),把无上限的数据倾倒进 SCTP 发送缓冲区。这对小载荷还行,但对于超出可用系统内存余量的文件(在没有背压的情况下,通常观察到约 100 MB 及以上就会出问题),RTCDataChannel.bufferedAmount 会不受控制地攀升,内存压力可能导致标签页崩溃。用 TypeScript 通过 WebRTC DataChannel 与 WebCrypto 实现 P2P 加密文件的流式传输,需要一套根本不同的架构——它围绕背压流控、逐块 AES-GCM 加密,以及 SHA-256 完整性校验来构建。
本教程将构建一个模块化、无服务端的点对点流式文件传输引擎,它完全运行在浏览器中,具备端到端加密,且服务器完全不中继文件数据。技术栈包括 TypeScript 5.x、WebRTC DataChannel、WebCrypto API(AES-GCM 用于加密,SHA-256 用于完整性)、用于分块处理的 Streams API,以及用于开发工具的 Vite。
架构遵循这样一条流水线:信令建立对等连接 → 交换对称加密密钥 → 文件被切成固定大小的块并通过加密变换流式处理 → 背压门控发送速率 → 接收端解密、对照预先共享的清单校验完整性、重组并触发下载。
架构概览与项目搭建
高层数据流图
数据流在发送端与接收端之间清晰分离:
发送端: File → 切片(64 KB 块)→ 加密(AES-GCM)→ DataChannel.send()
↕ 信令(offer/answer/ICE) ↕
接收端: DataChannel.onmessage → 解密(AES-GCM)→ 校验(SHA-256,逐块内联)→ 重组 → 下载
四个模块负责这一切:SignalingBroker 管理 offer/answer/ICE 交换,TransferCoordinator 编排传输生命周期。加密方面,CryptoStream 负责加密与解密的变换,IntegrityVerifier 计算并检查 SHA-256 摘要。
注意:在当前实现中,接收端是逐块内联校验的,而不是作为独立的 Streams API 流水线阶段。
前置条件
- Node.js 18.0.0 或更高版本(用
node --version确认) - npm ≥9.x 或等价工具
- TypeScript ≥5.0 与 Vite ≥4.0(
moduleResolution: "bundler"所需) - 现代浏览器:Chrome ≥96、Edge ≥96 或 Firefox ≥96
- 本地开发时在同一台机器上开两个浏览器标签页(见下文
BroadcastChannel的限制)
用 Vite 和 TypeScript 搭建脚手架
npm create vite@latest p2p-transfer -- --template vanilla-ts
cd p2p-transfer && npm install
项目结构:
src/
signaling.ts # SignalingBroker
coordinator.ts # TransferCoordinator
crypto.ts # CryptoStream(加密/解密变换、密钥工具)
integrity.ts # IntegrityVerifier(SHA-256 摘要)
main.ts # UI 接线
tsconfig.json
tsconfig.json 必须把目标库设为一组包含 WebCrypto 与 Streams API 类型定义的集合:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}
在这里设置 strict: true 至关重要。它会启用 strictNullChecks 和完整的类型安全,捕获在处理 ArrayBuffer 与 Uint8Array、跨 WebCrypto 和 DataChannel API 时常见的可空性错误及其他类型不匹配问题。
注意:moduleResolution: "bundler" 需要 TypeScript 5.0+ 和 Vite 4.0+。如果你用的是更早的版本,请改用 "moduleResolution": "node16"。
建立 WebRTC 对等连接与 DataChannel
通过 BroadcastChannel(本地开发)或 WebSocket 实现最小信令
信令与文件传输这一课本身是正交的。对于本地双标签页开发,BroadcastChannel 提供一条零依赖的信令通路。在生产环境中,替换为 WebSocket 服务器或 Firebase Realtime Database。
重要:BroadcastChannel 仅限同源、同一浏览器。它无法跨不同机器或浏览器连接对等方,只适用于本地开发测试。
// src/signaling.ts
type SignalMessage = { type: string; payload: unknown };
export class SignalingBroker {
private bc: BroadcastChannel;
private handler: ((msg: SignalMessage) => void) | null = null;
constructor(channel: string) {
this.bc = new BroadcastChannel(channel);
this.bc.onmessage = (ev: MessageEvent<SignalMessage>) => {
this.handler?.(ev.data);
};
}
send(type: string, payload: unknown): void {
this.bc.postMessage({ type, payload });
}
onMessage(cb: (msg: SignalMessage) => void): void {
this.handler = cb;
}
close(): void {
this.bc.close();
}
}
创建 RTCPeerConnection 并配置 DataChannel
DataChannel 必须使用 ordered: true 模式。由于每个块都用基于顺序索引派生出的 IV 加密,并且要对照有序清单校验,乱序投递会导致解密失败和完整性不匹配。两端都设置 binaryType = 'arraybuffer',可以避免 Blob 转换带来的开销。
注意:ordered: true 会引入队头阻塞。对于有丢包的网络路径,可以考虑使用无序通道配合应用层排序。
// src/coordinator.ts(片段 —— 连接建立)
const ICE_CONFIG: RTCConfiguration = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
};
export function createPeerConnection(
signaling: SignalingBroker,
onChannel: (ch: RTCDataChannel) => void
): RTCPeerConnection {
const pc = new RTCPeerConnection(ICE_CONFIG);
pc.onicecandidate = (ev) => {
if (ev.candidate) {
signaling.send('ice-candidate', ev.candidate.toJSON());
}
};
pc.ondatachannel = (ev) => {
const ch = ev.channel;
ch.binaryType = 'arraybuffer';
onChannel(ch);
};
signaling.onMessage(async (msg) => {
if (msg.type === 'offer') {
await pc.setRemoteDescription(msg.payload as RTCSessionDescriptionInit);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signaling.send('answer', answer);
} else if (msg.type === 'answer') {
await pc.setRemoteDescription(msg.payload as RTCSessionDescriptionInit);
} else if (msg.type === 'ice-candidate') {
await pc.addIceCandidate(msg.payload as RTCIceCandidateInit);
}
});
return pc;
}
export async function initiateSenderConnection(
pc: RTCPeerConnection,
signaling: SignalingBroker
): Promise<RTCDataChannel> {
const channel = pc.createDataChannel('filetransfer', { ordered: true });
channel.binaryType = 'arraybuffer';
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send('offer', offer);
return channel;
}
用 Streams API 做分块切片
为什么是 64 KB 的块
按 RFC 8831,SCTP 消息大小的互操作性安全下限是 64 KB;浏览器会协商出更大的值,但 64 KB 块是安全且可移植的默认选择。更小的 64 KB 块能提供更细粒度的背压响应,而 AES-GCM 每 64 KB 块只增加 16 字节(0.02%)的认证标签开销。它们还能降低峰值内存占用,因为任意时刻在途的字节更少。
从 File Blob 构建 ReadableStream
File.stream() 返回原生 ReadableStream<Uint8Array>,但其内部分块大小因浏览器而异。TransformStream 把流重新切分成精确的 64 KB 片段,在内部缓冲区中累积不完整的读取,并把剩余部分作为最后一块较小的块冲刷出去。
// src/coordinator.ts(分块工具)
export function chunkStream(
file: File,
chunkSize: number = 65536
): ReadableStream<Uint8Array> {
const accumulator: Uint8Array[] = [];
let accumulatedLength = 0;
const ts = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
accumulator.push(chunk);
accumulatedLength += chunk.length;
while (accumulatedLength >= chunkSize) {
const out = new Uint8Array(chunkSize);
let written = 0;
while (written < chunkSize) {
const head = accumulator[0];
if (head === undefined) break;
const need = chunkSize - written;
if (head.length <= need) {
out.set(head, written);
written += head.length;
accumulatedLength -= head.length;
accumulator.shift();
} else {
out.set(head.subarray(0, need), written);
accumulator[0] = head.subarray(need);
accumulatedLength -= need;
written += need;
}
}
controller.enqueue(out);
}
},
flush(controller) {
if (accumulatedLength > 0) {
const out = new Uint8Array(accumulatedLength);
let written = 0;
for (const piece of accumulator) {
out.set(piece, written);
written += piece.length;
}
controller.enqueue(out);
}
},
});
return file.stream().pipeThrough(ts);
}
用 bufferedAmountLowThreshold 做背压流控
问题所在:无界发送排队
RTCDataChannel.bufferedAmount 报告的是已排入 SCTP 发送缓冲区、但尚未传给对端的数据字节数。没有任何门控机制时,读取一个 1 GB 的文件并在循环中调用 send(),会在网络来得及排空之前把整 GB 数据推进缓冲区,导致内存耗尽并让标签页崩溃。
实现背压感知的发送循环
解决办法是一个基于拉取的发送循环:当 bufferedAmount 超过阈值时暂停,缓冲区排空后恢复。设置 dataChannel.bufferedAmountLowThreshold = 256 * 1024(256 KB),意味着一旦缓冲区降到该水平以下,bufferedamountlow 事件就会触发。配合 64 KB 的块,这大约允许任意时刻有四个块在途。
这是全文最关键的代码示例:
解决办法是一个基于拉取的发送循环:当
bufferedAmount超过阈值时暂停,缓冲区排空后恢复。
// src/coordinator.ts(带背压的发送器)
export async function sendWithBackpressure(
channel: RTCDataChannel,
stream: ReadableStream<Uint8Array>,
onProgress?: (bytesSent: number) => void
): Promise<void> {
const THRESHOLD = 256 * 1024;
channel.bufferedAmountLowThreshold = THRESHOLD;
const reader = stream.getReader();
let bytesSent = 0;
async function waitForDrain(): Promise<void> {
if (channel.readyState !== 'open') {
throw new Error(`DataChannel 未打开(状态:${channel.readyState})`);
}
if (channel.bufferedAmount <= THRESHOLD) {
return;
}
return new Promise<void>((resolve, reject) => {
const onLow = () => {
cleanup();
resolve();
};
const onClose = () => {
cleanup();
reject(new Error('等待排空期间 DataChannel 被关闭'));
};
const cleanup = () => {
channel.removeEventListener('bufferedamountlow', onLow);
channel.removeEventListener('close', onClose);
};
channel.addEventListener('bufferedamountlow', onLow, { once: true });
channel.addEventListener('close', onClose, { once: true });
});
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 前置消息类型字节:0x01 = 加密块
const frame = new Uint8Array(1 + value.length);
frame[0] = 0x01;
frame.set(value, 1);
channel.send(frame);
bytesSent += value.length;
onProgress?.(bytesSent);
if (channel.bufferedAmount > THRESHOLD) {
await waitForDrain();
}
}
// 发送传输结束信号
channel.send(new Uint8Array([0x02]));
}
为吞吐量调优阈值与块大小
这里存在直接权衡:阈值设得太低会导致频繁暂停,饿死网络、降低吞吐量;阈值设得太高又会让内存占用攀升,重新引入最初的问题。建议的起点是 64 KB 块配 256 KB 阈值。对于高带宽局域网传输,把两者都翻倍(128 KB 块、512 KB 阈值)可以提升吞吐量,但超过这一点后,瓶颈就变成 SCTP 拥塞控制,而不再是缓冲策略。
用 WebCrypto 做端到端 AES-GCM 加密
密钥生成与安全交换
WebCrypto API 完全在浏览器内生成 AES-GCM 密钥。密钥以原始 ArrayBuffer 导出,并作为控制消息(首字节 0x00)通过 DataChannel 传给对端。
警告:DataChannel 的 DTLS 加密能保护密钥免受被动网络窃听,但被攻陷的信令服务器仍可替换密钥(中间人攻击)。生产环境不要使用原始密钥交换。请把原始密钥传输替换为 ECDH 密钥协商(见生产清单)。
// src/crypto.ts(密钥工具)
export async function generateKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
export async function exportKey(key: CryptoKey): Promise<ArrayBuffer> {
return crypto.subtle.exportKey('raw', key);
}
export async function importKey(raw: ArrayBuffer): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
raw,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
}
逐块加密与唯一 IV
AES-GCM 要求在同一密钥下的每一次加密操作都使用唯一的 12 字节初始化向量(IV)。在同一密钥下复用 IV 是灾难性的,会导致明文可被恢复。这里的策略是把 4 字节大端块索引与 8 个随机字节拼接起来,降低跨传输的碰撞概率;有了 8 个随机字节,生日界约为每密钥 2^32 个块(按 64 KB 块算约 274 TB)。每次传输后请轮换密钥。IV 会被前置到密文上,以便接收端在解密前取出。
AES-GCM 要求在同一密钥下的每一次加密操作都使用唯一的 12 字节初始化向量(IV)。在同一密钥下复用 IV 是灾难性的,会导致明文可被恢复。
// src/crypto.ts(逐块加密/解密)
function buildIV(chunkIndex: number): Uint8Array {
const iv = new Uint8Array(12);
const view = new DataView(iv.buffer);
view.setUint32(0, chunkIndex, false); // 大端索引
crypto.getRandomValues(iv.subarray(4)); // 8 个随机字节
return iv;
}
export async function encryptChunk(
key: CryptoKey, chunkIndex: number, data: Uint8Array
): Promise<Uint8Array> {
const iv = buildIV(chunkIndex);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
data
);
const result = new Uint8Array(12 + ciphertext.byteLength);
result.set(iv, 0);
result.set(new Uint8Array(ciphertext), 12);
return result;
}
export async function decryptChunk(
key: CryptoKey, payload: Uint8Array
): Promise<Uint8Array> {
const iv = payload.slice(0, 12);
const ciphertext = payload.slice(12);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
return new Uint8Array(plaintext);
}
把加密整合进流式流水线
加密与解密以 TransformStream 工厂的形式接入 Streams API。每个阶段(分块切片、加密、背压发送、解密、完整性校验)都可以被替换或单独测试,而无需修改相邻阶段。
// src/crypto.ts(流式集成)
export function createEncryptTransform(key: CryptoKey): TransformStream<Uint8Array, Uint8Array> {
let index = 0;
return new TransformStream({
async transform(chunk, controller) {
if (index > 0xFFFF_FFFF) {
controller.error(new Error('块索引溢出:请轮换加密密钥'));
return;
}
const encrypted = await encryptChunk(key, index++, chunk);
controller.enqueue(encrypted);
},
});
}
export function createDecryptTransform(key: CryptoKey): TransformStream<Uint8Array, Uint8Array> {
return new TransformStream({
async transform(chunk, controller) {
const decrypted = await decryptChunk(key, chunk);
controller.enqueue(decrypted);
},
});
}
SHA-256 分块完整性校验
计算与验证块摘要
接收端用 crypto.subtle.digest('SHA-256', decryptedChunk) 计算每个块的 SHA-256 摘要。在传输开始之前,发送端会计算一份清单,其中包含每个明文块的十六进制 SHA-256 摘要,并作为控制消息传输出去。接收端把每个解密后的块与清单中对应条目比对,一旦不匹配立即中止。
// src/integrity.ts
export async function computeDigest(data: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export function createVerifyTransform(
manifest: string[]
): TransformStream<Uint8Array, Uint8Array> {
let index = 0;
return new TransformStream({
async transform(chunk, controller) {
const expected: string | undefined = manifest[index];
if (expected === undefined) {
controller.error(
new Error(`块 ${index} 超出清单长度(${manifest.length})`)
);
return;
}
const digest = await computeDigest(chunk);
if (digest !== expected) {
controller.error(
new Error(`块 ${index} 完整性校验失败:期望 ${expected},实际 ${digest}`)
);
return;
}
index++;
controller.enqueue(chunk);
},
});
}
传输协调器:把所有部分串起来
协议消息类型
协议使用不同的类型字节在 DataChannel 上对消息进行分帧:添加到对话
| 类型字节 | 消息 | 格式 |
|---|---|---|
0x00 | 原始 AES-256 密钥 | 1 个类型字节 + 32 字节密钥(共 33 字节) |
0x03 | 元数据 + 清单(JSON) | 1 个类型字节 + UTF-8 编码的 JSON |
0x01 | 加密块 | 1 个类型字节 + IV(12 字节)+ 密文 |
0x02 | 传输结束信号 | 1 字节 |
发送端流程
TransferCoordinator 把所有模块串联在一起。发送端一侧:生成密钥 → 计算完整的分块清单 → 打开 DataChannel → 发送控制消息(密钥、清单、文件元数据)→ 然后把文件依次通过分块切片、加密和背压发送器进行管道传输。
注意:这种清单方式会把文件读两遍:一遍用于计算块摘要,一遍用于流式加密传输。对于内存受限的环境,可以考虑在传输过程中计算摘要、并在传输后通过最终清单校验的流式方案。
接收端流程
接收端等待控制消息、导入密钥、保存清单,并把进来的加密块依次通过解密和完整性校验处理。一旦收到传输结束信号,累积起来的块就组成一个 Blob,通过 URL.createObjectURL 触发下载。
警告:这个实现会把所有解密后的块缓存在内存中。对于超出可用内存的文件,请使用生产清单中描述的 File System Access API 或 StreamSaver.js 方案。
这个实现会把所有解密后的块缓存在内存中。对于超出可用内存的文件,请使用生产清单中描述的 File System Access API 或 StreamSaver.js 方案。
// src/coordinator.ts(TransferCoordinator —— 完整编排器)
//
// chunkStream 和 sendWithBackpressure 在本文件(coordinator.ts)前面已定义
import { SignalingBroker } from './signaling';
import {
generateKey, exportKey, importKey,
encryptChunk, decryptChunk,
createEncryptTransform,
} from './crypto';
import { computeDigest, createVerifyTransform } from './integrity';
export class TransferCoordinator {
private signaling: SignalingBroker;
constructor(channelName: string) {
this.signaling = new SignalingBroker(channelName);
}
async send(
file: File,
channel: RTCDataChannel,
onProgress?: (pct: number) => void
): Promise<void> {
const key = await generateKey();
const rawKey = await exportKey(key);
if (rawKey.byteLength !== 32) {
throw new Error(`密钥导出的长度异常:${rawKey.byteLength}`);
}
// 从明文块计算清单
const manifest: string[] = [];
const preReader = chunkStream(file, 65536).getReader();
try {
while (true) {
const { done, value } = await preReader.read();
if (done) break;
manifest.push(await computeDigest(value));
}
} finally {
preReader.releaseLock();
}
// 发送控制消息:原始密钥(类型 0x00)
const keyFrame = new Uint8Array(1 + rawKey.byteLength);
keyFrame[0] = 0x00;
keyFrame.set(new Uint8Array(rawKey), 1);
channel.send(keyFrame);
// 发送控制消息:元数据 + 清单(类型 0x03)
const metaPayload = JSON.stringify({
name: file.name,
size: file.size,
totalChunks: manifest.length,
manifest,
});
const encoded = new TextEncoder().encode(metaPayload);
const metaFrame = new Uint8Array(1 + encoded.length);
metaFrame[0] = 0x03;
metaFrame.set(encoded, 1);
channel.send(metaFrame);
// 流式处理:切片 → 加密 → 带背压发送
const encrypted = chunkStream(file, 65536).pipeThrough(createEncryptTransform(key));
await sendWithBackpressure(channel, encrypted, (bytes) => {
onProgress?.(Math.round((bytes / file.size) * 100));
});
}
receive(channel: RTCDataChannel): Promise<void> {
return new Promise((resolve, reject) => {
let key: CryptoKey | null = null;
let meta: { name: string; manifest: string[] } | null = null;
const chunks: Uint8Array[] = [];
channel.onmessage = (ev) => {
(async () => {
const data = new Uint8Array(ev.data as ArrayBuffer);
const msgType = data[0];
if (msgType === 0x00) {
if (data.byteLength !== 33) {
throw new Error(`密钥消息无效:期望 33 字节,实际 ${data.byteLength}`);
}
// .slice() 会生成一个恰好 32 字节的新 ArrayBuffer
key = await importKey(data.slice(1, 33).buffer);
} else if (msgType === 0x03) {
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(data.slice(1)));
} catch {
throw new Error('元数据格式错误:JSON 解析失败');
}
if (
typeof parsed !== 'object' || parsed === null ||
typeof (parsed as Record<string, unknown>)['name'] !== 'string' ||
!Array.isArray((parsed as Record<string, unknown>)['manifest'])
) {
throw new Error('元数据格式错误:缺少必需字段');
}
meta = parsed as { name: string; manifest: string[] };
} else if (msgType === 0x01 && key && meta) {
const plain = await decryptChunk(key, data.slice(1));
const digest = await computeDigest(plain);
const expected: string | undefined = meta.manifest[chunks.length];
if (expected === undefined) {
throw new Error(`意外的块 ${chunks.length}:超出清单长度`);
}
if (digest !== expected) {
throw new Error(`块 ${chunks.length} 完整性失败`);
}
chunks.push(plain);
} else if (msgType === 0x02) {
const blob = new Blob(chunks);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = meta?.name ?? 'download';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 300_000);
resolve();
}
})().catch(reject);
};
channel.onerror = (e) => {
reject(new Error((e as RTCErrorEvent).error?.message ?? 'DataChannel 错误'));
};
});
}
}
进度报告与错误处理
发送端通过把 chunksSent 除以清单中的 totalChunks 来跟踪进度。DataChannel 的 onerror 与 onclose 事件必须上报到 UI 层。接收端必须在解密失败(认证标签不匹配时由 WebCrypto 抛出)或完整性不匹配时立即终止传输,并通知用户。
运行与测试应用
运行 npx vite,打开两个指向 localhost:5173 的标签页,指定一个为发送端、一个为接收端。用不同大小的文件测试:1 MB 验证基本功能,100 MB 压测背压,1 GB 确认内存保持有界。在浏览器 DevTools 中运行 setInterval(() => console.log(channel.bufferedAmount), 200),应当看到数值徘徊在 256 KB 阈值附近或之下,而不是不受控制地攀升。Chrome、Edge 和 Firefox 都支持所需的 API。Safari 15.4 加入了可靠的 ArrayBuffer DataChannel 消息支持,解决了早先的二进制传输问题;但请针对你的具体用例测试,因为在较老的 WebKit 构建上,超过 256 KB 的二进制消息大小限制可能仍然适用。
性能考量与生产环境加固
吞吐量基准
在 localhost 回环连接上,吞吐量随调优而变化。以下数字是非正式观察所得,会随硬件、操作系统和浏览器版本而变化。把它们当作方向性参考,而非保证:添加到对话
| 块大小 | 阈值 | 观察到的吞吐量 |
|---|---|---|
| 16 KB | 64 KB | 约 30 MB/s |
| 64 KB | 256 KB | 约 80 MB/s |
| 128 KB | 512 KB | 约 95 MB/s |
| 256 KB | 1 MB | 约 100 MB/s(收益递减) |
在真实网络路径上,吞吐量会降到大约 10–50 MB/s,具体取决于路径 RTT 和丢包率,因为 SCTP 拥塞控制会限制有效发送速率。
生产清单
把 BroadcastChannel 信令替换为基于 WebSocket 或 SSE 的信令服务器。为处于对称 NAT 之后的对等方配置 TURN 服务器回退,否则直连会失败。使用临时 ECDH(ECDHE):每次会话用 crypto.subtle.generateKey 生成一对全新的 P-256 密钥。静态 ECDH 不提供前向保密,只有临时密钥对才有。若要在接收端实现真正流式下载而不完整缓冲 Blob,在 Chromium 浏览器中优先使用 File System Access API(showSaveFilePicker)。StreamSaver.js 是一个兼容性更广的替代方案,但已不再活跃维护。设置 CSP 头以允许用于触发下载的 blob: URL。
下一步
在这个基础之上,开发者可以进一步探索多对等方扇出(同时把块发送给多个接收端)、通过持久化块索引实现可续传传输,以及把加密工作卸载到 Web Worker 以在数百兆字节的传输中保持主线程响应。注意 Web Worker 无法直接访问 RTCDataChannel;请用 postMessage 配合可转移的 ArrayBuffer 在 Worker 加密上下文与主线程的 DataChannel 之间搬运数据。
本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/72293.html