我问她:WebSocket 怎么发大文本,她说:豆子,别一口气塞下去,会噎到的

痛点 · 使用问题

办公室的灯只剩几盏还亮着,像没关完的星星。空调出风口还在呼呼地响,小豆的屏幕上卡着一行发送失败的提示。

"又断了?"小语端着杯冷掉的咖啡走过来,俯身看了一眼,眉毛往上一挑。

"嗯,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 标记 startchunkack,发送每一片的时候都带上编号,接收后再确认拼完整了才算完工。

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 协议,确保完整性。
相关推荐
tiandyoin2 小时前
调教 DeepSeek - 输出精致的 HTML MARKDOWN
前端·html
Electrolux4 小时前
【使用教程】一个前端写的自动化rpa工具
前端·javascript·程序员
赵大仁5 小时前
深入理解 Pinia:Vue 状态管理的革新与实践
前端·javascript·vue.js
小小小小宇5 小时前
业务项目中使用自定义Webpack 插件
前端
小小小小宇5 小时前
前端AST 节点类型
前端
小小小小宇6 小时前
业务项目中使用自定义eslint插件
前端
骷大人6 小时前
Thinkphp6实现websocket
网络·websocket·网络协议
babicu1236 小时前
CSS Day07
java·前端·css
小小小小宇6 小时前
业务项目使用自定义babel插件
前端
前端码虫6 小时前
JS分支和循环
开发语言·前端·javascript