痛点 · 使用问题
办公室的灯只剩几盏还亮着,像没关完的星星。空调出风口还在呼呼地响,小豆的屏幕上卡着一行发送失败的提示。
"又断了?"小语端着杯冷掉的咖啡走过来,俯身看了一眼,眉毛往上一挑。
"嗯,WebSocket 那边,发大段文本总是超时,用户那边说内容一半都没看到。"
"你是不是......又一股脑塞整篇小说过去了?"她语气轻快,像在讲笑话,但那句话有点像锤子,正好砸在小豆脑壳上。
我不好意思地抓了抓头发:"我想着反正是 WebSocket,就直接传了,没多想......"
小语拿指节轻轻敲了下控制台右侧一行字,翻了个白眼:"豆子,你这叫硬塞。系统都喘不过气来了。"
bufferedAmount: 3.2MB
"你这是在往水管里倒水泥。"她叹了口气,"WebSocket是全双工,不是传送门,发大包会堵。"
起点 · 技术交流
我的第一个反应是:"是不是服务器带宽不够?"
阿辰在会议室里翻着部署日志,头也不抬地说:"不是带宽,是你太冲了。"
"WebSocket 不是适合传数据吗?"
阿辰看了我一眼,语气淡淡:"适合,但你得慢慢来。你有没有试着分片?"
"没......我想着直接发能快一点......"
她没说话,从她的文件夹里调出一个小项目,是她年初写的在线 Markdown 协作编辑器。
"看这个,我当时也是要发大段文档。"她一边说,一边把代码往我这边推了推:
"分片?"我愣了一下。
江暮那时刚从外面拿了瓶水进来,淡淡补了一句:"别一口气塞下去,会噎到的。"
🧩第一招:分片传输
回到工位后,我翻出代码,试着按阿辰说的把文本切成1KB一片,一块一块发过去,接收端也一块块拼回来。
js
// 发送端
const largeText = "很长很长的文本...";
const chunkSize = 1024 // 1KB
const totalChunks = Math.ceil(largeText.length / chunkSize)
for (let i = 0; i < totalChunks; i++) {
const chunk = largeText.slice(i * chunkSize, (i + 1) * chunkSize)
ws.send(JSON.stringify({ type: 'chunk', index: i, data: chunk }))
}
// 接收端
let receivedData = "";
websocket.onmessage = (event) => {
receivedData += event.data;
if (event.data.length < chunkSize) {
console.log("完整数据接收完成:", receivedData);
}
};
"然后接收端收完拼起来。"阿辰补了一句,"可以加个 done
标记,也可以等最后一个 chunk 收完自动合并。"
小语凑过来看看,说:"这样能避免一次性发送太大的数据,但是还不够快,如果数据本身太大,比如是几 MB 的 JSON,光是分片可能还不够高效。"
🧵第二招:压缩传输
她递给小豆一份文档,是她之前搞聊天室时写的小工具,使用压缩算法(如gzip、deflate)减少传输体积 ,用了 pako
这个库,可以在发送前把大段文本压缩掉,减少一半以上体积。
js
import pako from 'pako';
// 发送端压缩
const compressed = pako.deflate(largeText);
websocket.send(compressed);
// 接收端解压
websocket.onmessage = (event) => {
const restored = pako.inflate(event.data, { to: 'string' });
console.log(restored);
};
"你试试看,接收那边再解压回来就好。"
"语宝,你太厉害了。"小豆真心感叹。
"别光嘴上甜,把确认机制也加上。发多少片,收了几片,别发一半丢一半。注意WebSocket单帧默认最大16KB"
于是我加了简单的控制协议,用 JSON 标记 start
、chunk
和 ack
,发送每一片的时候都带上编号,接收后再确认拼完整了才算完工。
js
import pako from 'pako';
// 配置
const CHUNK_SIZE = 1024 * 16; // 16KB 每个分片大小
const MAX_RETRIES = 3; // 最大重试次数
class OptimizedWebSocketTransfer {
constructor(websocket) {
this.ws = websocket;
this.pendingChunks = new Map();
this.retryCounts = new Map();
this.buffers = new Map();
this.setupHandlers();
}
setupHandlers() {
this.ws.onmessage = (event) => {
try {
const packet = JSON.parse(pako.inflate(event.data, { to: 'string' }));
if (packet.type === 'start') {
this.handleStartPacket(packet);
} else if (packet.type === 'chunk') {
this.handleChunkPacket(packet);
} else if (packet.type === 'ack') {
this.handleAckPacket(packet);
}
} catch (error) {
console.error('Packet processing error:', error);
}
};
}
// 发送大文本(自动分片压缩)
sendLargeData(data, id = Date.now().toString()) {
return new Promise((resolve, reject) => {
// 压缩原始数据
const compressed = pako.deflate(data);
// 创建分片
const totalChunks = Math.ceil(compressed.length / CHUNK_SIZE);
this.pendingChunks.set(id, {
total: totalChunks,
received: 0,
chunks: new Array(totalChunks),
resolve,
reject
});
// 发送开始包
this.sendPacket({
type: 'start',
id,
total: totalChunks,
size: compressed.length
});
// 发送数据分片
for (let i = 0; i < totalChunks; i++) {
const chunk = compressed.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
this.sendPacket({
type: 'chunk',
id,
index: i,
data: chunk
});
}
});
}
// 处理开始包
handleStartPacket(packet) {
this.buffers.set(packet.id, {
total: packet.total,
received: 0,
chunks: new Array(packet.total),
size: packet.size
});
// 确认收到开始包
this.sendAck(packet.id, 'start');
}
// 处理数据分片
handleChunkPacket(packet) {
const buffer = this.buffers.get(packet.id);
if (!buffer) return;
// 存储分片
buffer.chunks[packet.index] = packet.data;
buffer.received++;
// 发送确认
this.sendAck(packet.id, 'chunk', packet.index);
// 检查是否接收完成
if (buffer.received === buffer.total) {
this.assembleData(packet.id);
}
}
// 处理确认包
handleAckPacket(packet) {
if (packet.ackType === 'start') {
console.log(`传输 ${packet.id} 已开始`);
} else if (packet.ackType === 'chunk') {
const pending = this.pendingChunks.get(packet.id);
if (pending) {
pending.received++;
// 检查是否全部完成
if (pending.received === pending.total) {
pending.resolve();
this.pendingChunks.delete(packet.id);
this.retryCounts.delete(packet.id);
}
}
}
}
// 发送确认
sendAck(id, ackType, index = null) {
this.sendPacket({
type: 'ack',
id,
ackType,
index
});
}
// 组装数据
assembleData(id) {
const buffer = this.buffers.get(id);
if (!buffer) return;
try {
// 合并所有分片
const compressed = new Uint8Array(buffer.size);
let offset = 0;
for (const chunk of buffer.chunks) {
compressed.set(chunk, offset);
offset += chunk.length;
}
// 解压数据
const data = pako.inflate(compressed, { to: 'string' });
// 触发消息事件
this.ws.dispatchEvent(new MessageEvent('message', {
data: data
}));
// 清理
this.buffers.delete(id);
} catch (error) {
console.error('Data assembly failed:', error);
}
}
// 发送数据包(自动压缩)
sendPacket(packet) {
try {
const compressed = pako.deflate(JSON.stringify(packet));
this.ws.send(compressed);
} catch (error) {
console.error('Packet send failed:', error);
}
}
}
// 使用示例
const ws = new WebSocket('wss://your-websocket-url');
const transfer = new OptimizedWebSocketTransfer(ws);
// 发送大数据
transfer.sendLargeData(veryLargeText)
.then(() => console.log('传输完成'))
.catch(err => console.error('传输失败', err));
// 接收消息
ws.addEventListener('message', (event) => {
console.log('收到完整消息:', event.data);
});
晚上快十一点,小豆在后台看到最后一条日志:「大文本发送成功,耗时 480ms」。
⛱️第三招:二进制传输
"看起来优化的不错。"我点点头,"那如果是二进制数据呢?比如图片、文件之类的?"
"那可以直接用 Array Buffer。"小语写了一段示例:
js
// 发送端
const encoder = new TextEncoder();
const binaryData = encoder.encode(largeText);
websocket.send(binaryData);
// 接收端
websocket.onmessage = (event) => {
const decoder = new TextDecoder();
const text = decoder.decode(event.data);
console.log(text);
};
"文本转成二进制,效率更高。"小语补充道,"而且如果你的服务器支持 WebSocket 的 permessage-deflate
扩展,还可以让协议自动帮你压缩,省掉手动压缩的步骤。"
启用
permessage-deflate
扩展(服务器需支持),自动压缩帧。
javascript
const ws = new WebSocket("ws://example.com", ["permessage-deflate"])
我听得入神,一边记笔记一边点头。
小语站起来收电脑包,路过我椅背时拍了拍:"豆子,今天你没噎着,系统也没呛着。"
窗外的灯光透过百叶窗,落在工位上的键盘边缘。
我点点头,笑着说:"都是你教得好。"
📌 技术回顾 · 小结
在 WebSocket 传输大文本时,可以组合以下策略:
- 分片传输:将数据按固定大小切片,按序发送。
- 压缩算法 :用如
pako
进行 deflate 压缩,减轻传输负担。 - 二进制传输 :用
TextEncoder
编码成Uint8Array
,性能更优。 - 控制协议 :加上
start
/chunk
/ack
协议,确保完整性。