前端高级面试题:WebSocket 双向流式通信怎么设计?

1. 核心思路(一句话)

WebSocket 只负责提供一条全双工长连接,真正的"多流、可靠传输、流控、断点续传"都需要在 WebSocket 之上设计应用层协议。


2. 先建立整体认知

不要把这道题理解成:

text 复制代码
new WebSocket()
      ↓
onmessage
      ↓
send()

这只能证明你会用 WebSocket API。真正的工程设计应该是:

text 复制代码
                    WebSocket
                        │
                  一条全双工连接
                        │
             ┌──────────┴──────────┐
             ↓                     ↓
        Client → Server       Server → Client
             │                     │
             └──────────┬──────────┘
                        ↓
                 应用层消息协议
                        │
       ┌────────────────┼────────────────┐
       ↓                ↓                ↓
    Stream A          Stream B         Stream C
       │                │                │
    seq 0...          seq 0...         seq 0...
       │                │                │
       └────────────────┼────────────────┘
                        ↓
               ACK / 重传 / 流控
                        │
                        ↓
                  断线恢复 / 续传

主要矛盾:

WebSocket 本身只解决"连接"和"全双工传输",并没有替你解决业务层的多路流管理、可靠性和流控。

次要矛盾:

心跳、重连、降级、错误处理等属于工程可靠性问题。


3. WebSocket 到底是不是"多路复用"?

这是非常容易被面试官追问的地方。

严格来说:不能直接这么说。

WebSocket 是:

一条 TCP 连接上的全双工通信协议。

如果你同时进行:

text 复制代码
AI 对话 A
文件上传 B
实时协作 C

通常并不是 WebSocket 自动帮你创建了三个独立的 WebSocket 流。实际上仍然可能是:

text 复制代码
一个 WebSocket
      │
      ├── streamId=A
      ├── streamId=B
      └── streamId=C

也就是说:

多路流是你在应用层通过 streamId 自己实现的逻辑复用。

这和 HTTP/2 的 Stream 多路复用不是一回事。


4. 应用层协议怎么设计?

这是这道题真正开始体现工程能力的地方。

例如统一设计消息格式:

javascript 复制代码
{
  type: 'chunk',
  streamId: 'chat-123',
  seq: 42,
  payload: '你好'
}

核心字段可以设计成:

字段 作用
type 消息类型
streamId 区分不同业务流
seq 当前数据块的顺序号
payload 实际数据
ack 已确认的数据位置
error 错误信息

消息类型可以定义成:

text 复制代码
start
chunk
end
ack
cancel
error

例如 AI 流式输出:

text 复制代码
Server
  │
  │ start(streamId=chat-1)
  ↓
Client
  │
  │ ack
  ↓
Server
  │
  │ chunk(seq=0)
  ↓
Client
  │
  │ chunk(seq=1)
  ↓
Client
  │
  │ chunk(seq=2)
  ↓
Client
  │
  │ end
  ↓
Client

5. 为什么一定需要 streamId?

假设同时存在两个 AI 请求:

text 复制代码
请求 A:streamId = chat-1

请求 B:streamId = chat-2

服务端可能交错发送:

text 复制代码
chat-1 seq=0
chat-2 seq=0
chat-1 seq=1
chat-2 seq=1
chat-1 seq=2

客户端收到后:

javascript 复制代码
const streams = new Map();

function handleMessage(message) {
  const stream = streams.get(message.streamId);

  if (!stream) {
    return;
  }

  stream.push(message);
}

于是:

text 复制代码
             WebSocket
                 │
       ┌─────────┼─────────┐
       ↓         ↓         ↓
   chat-1     chat-2    upload-1
       │         │         │
       ↓         ↓         ↓
    独立状态   独立状态   独立状态

所以 streamId 解决的是"这条消息属于谁"。


6. seq 又解决什么?

streamId 只能解决:

属于哪个流?

不能解决:

这个流里的第几个数据?

所以还需要:

text 复制代码
streamId = chat-1

seq=0
seq=1
seq=2
seq=3
...

例如收到:

text 复制代码
seq=0
seq=1
seq=3

发现:

text 复制代码
seq=2

没收到,就知道发生了丢失。这也是断线续传、重传等机制的基础。


7. WebSocket 本身可靠,为什么还需要 ACK?

这里要注意一个非常重要的概念:

WebSocket 基于 TCP,TCP 本身已经提供可靠、有序传输。

所以正常情况下:

text 复制代码
TCP
 ↓
WebSocket
 ↓
应用层

不会出现 TCP 层随意把:

text 复制代码
1 → 3 → 2

交给你的情况。

但是:

TCP 的可靠 ≠ 你的业务消息已经被业务层处理。

例如:

text 复制代码
服务端
  ↓
TCP/WebSocket
  ↓
浏览器
  ↓
JS
  ↓
业务状态
  ↓
UI

TCP 只能保证数据可靠到达 TCP 对端,并不能保证:

text 复制代码
AI token 已经被业务层持久化
文件块已经被业务层处理
用户已经成功消费这条消息

所以如果业务要求更强的可靠性,可以在应用层增加:

text 复制代码
seq
+
ACK
+
超时重传

8. 断线重连怎么实现断点续流?

这是这道题的重点。

假设:

text 复制代码
streamId = chat-1

seq=0 ✓
seq=1 ✓
seq=2 ✓
seq=3 ✓
seq=4 ✓
seq=5 ✓

突然断线。

客户端记录:

javascript 复制代码
lastSeq = 5;

重新连接:

text 复制代码
Client
  │
  │ reconnect
  ↓
Server
  │
  │ resume
  │ streamId=chat-1
  │ lastSeq=5
  ↓
Server
  │
  │ seq=6
  │ seq=7
  │ seq=8
  ↓
Client

关键点是:

服务端必须能够保留或者重新生成断线期间的数据。

所以不能简单理解成:

"客户端带个 lastSeq 就自动续传了。"

真正完整的条件是:

text 复制代码
客户端保存 lastSeq
        +
服务端保存/可重新获取历史数据
        +
重新连接后发送 resume
        +
服务端从 lastSeq + 1 开始继续

否则:

text 复制代码
Client:我要 seq=100 之后的数据

Server:不好意思,我早就丢了。

依然无法续传。


9. 前端渲染慢,服务端推得太快怎么办?

WebSocket.bufferedAmount 主要反映的是:

浏览器通过 WebSocket 发送出去、但底层还没有完成传输的数据量。

例如:

javascript 复制代码
socket.send(data);

console.log(socket.bufferedAmount);

它主要解决的是:

text 复制代码
浏览器 → 服务端

发送过快的问题。而题目真正问的是:

text 复制代码
服务端 → 浏览器

服务端推得太快,浏览器处理不过来怎么办?这时候不能简单说:

看 bufferedAmount。


10. 真正的流控怎么设计?

如果业务数据量很大,需要在应用层设计:

text 复制代码
发送窗口
+
ACK
+
暂停/恢复

例如客户端告诉服务端:

javascript 复制代码
{
  type: 'ack',
  streamId: 'chat-1',
  seq: 100,
  window: 20
}

意思类似于:

我已经处理到 100,目前还能接受大约 20 个数据块。

服务端根据窗口控制:

text 复制代码
允许发送:

101
102
103
...
120

达到窗口上限
      ↓
暂停
      ↓
等待新的 ACK
      ↓
窗口扩大
      ↓
继续发送

这才是真正意义上的应用层流控。


11. AI 对话场景怎么设计?

例如:

text 复制代码
用户发送:
"解释一下 React Fiber"

        ↓

创建:
streamId = chat-123

        ↓

服务端开始生成

        ↓

chunk seq=0
chunk seq=1
chunk seq=2
chunk seq=3
...

        ↓

前端逐步渲染

如果用户点击:

text 复制代码
停止生成

客户端:

javascript 复制代码
socket.send(JSON.stringify({
  type: 'cancel',
  streamId: 'chat-123'
}));

服务端收到后:

text 复制代码
找到 chat-123
      ↓
取消模型生成任务
      ↓
停止继续发送 chunk
      ↓
发送 end / cancelled

注意:

停止一个流不能直接关闭整个 WebSocket。否则:

text 复制代码
chat-123 cancel
      ↓
socket.close()
      ↓
chat-456
upload-789
全部断掉

正确做法是:

text 复制代码
cancel(streamId)

也就是:取消流,而不是关闭连接。 这才是多流设计真正有价值的地方。


12. 完整的客户端示例

下面给一个简化但完整的应用层协议实现:

javascript 复制代码
class StreamSocket {
  constructor(url) {
    this.url = url;
    this.socket = null;

    // 每个 stream 独立维护自己的状态
    this.streams = new Map();
  }

  connect() {
    this.socket = new WebSocket(this.url);

    this.socket.onopen = () => {
      console.log('WebSocket connected');

      // 重连后恢复之前的流
      for (const [streamId, stream] of this.streams) {
        this.send({
          type: 'resume',
          streamId,
          lastSeq: stream.lastSeq
        });
      }
    };

    this.socket.onmessage = event => {
      const message = JSON.parse(event.data);

      this.handleMessage(message);
    };

    this.socket.onclose = () => {
      console.log('WebSocket disconnected');

      // 实际项目需要增加:
      // 1. 指数退避
      // 2. 最大重试次数
      // 3. 网络状态判断
      // 4. 重连后的流恢复
    };
  }

  createStream(streamId, onChunk) {
    this.streams.set(streamId, {
      lastSeq: -1,
      onChunk
    });

    this.send({
      type: 'start',
      streamId
    });
  }

  handleMessage(message) {
    const stream = this.streams.get(message.streamId);

    if (!stream) {
      return;
    }

    switch (message.type) {
      case 'chunk': {
        // 检查顺序
        if (message.seq !== stream.lastSeq + 1) {
          console.warn(
            'chunk sequence error',
            message.seq,
            stream.lastSeq
          );

          // 实际项目可以:
          // 请求服务端重新发送缺失的数据
          return;
        }

        stream.lastSeq = message.seq;

        // 交给具体业务处理
        stream.onChunk(message.payload);

        // 告诉服务端:
        // 我已经消费到哪里
        this.send({
          type: 'ack',
          streamId: message.streamId,
          seq: message.seq
        });

        break;
      }

      case 'end':
        this.streams.delete(message.streamId);
        break;

      case 'error':
        console.error(
          'stream error:',
          message.error
        );
        break;
    }
  }

  cancelStream(streamId) {
    this.send({
      type: 'cancel',
      streamId
    });

    // 本地也可以先结束 UI 状态
    this.streams.delete(streamId);
  }

  send(message) {
    if (
      this.socket &&
      this.socket.readyState === WebSocket.OPEN
    ) {
      this.socket.send(JSON.stringify(message));
    }
  }
}

使用:

javascript 复制代码
const client = new StreamSocket('wss://example.com/ws');

client.connect();

client.createStream(
  'chat-123',
  token => {
    console.log('收到 AI token:', token);

    // 实际项目这里再做:
    // 批量更新 UI
    // Markdown 渲染
    // 虚拟列表等
  }
);

停止:

javascript 复制代码
client.cancelStream('chat-123');

13. 前端渲染慢还需要优化什么?

即使网络层已经做了流控,前端也可能因为:

text 复制代码
token 到达速度
      >
React 状态更新速度
      >
浏览器渲染能力

导致卡顿。例如 AI 每 5ms 来一个 token:

text 复制代码
5ms
10ms
15ms
20ms
...

如果每收到一个 token 都:

javascript 复制代码
setState(...)

可能造成大量渲染。更合理的是:

text 复制代码
WebSocket
    ↓
消息缓冲区
    ↓
批量合并 token
    ↓
requestAnimationFrame / 定时批量刷新
    ↓
React 更新
    ↓
浏览器渲染

例如:

javascript 复制代码
let buffer = '';

socket.onmessage = event => {
  buffer += event.data;
};

function render() {
  if (buffer) {
    const text = buffer;
    buffer = '';

    updateUI(text);
  }

  requestAnimationFrame(render);
}

render();

所以这里其实存在两个不同层面的"流控":

text 复制代码
网络层 / 应用层流控
        ↓
控制数据进入速度

前端渲染节流
        ↓
控制 UI 更新速度

不要混为一谈。


14. 心跳和重连

WebSocket 长连接还需要考虑连接存活。典型设计:

text 复制代码
Client ───── ping ─────→ Server
Client ←──── pong ───── Server

如果长时间没有响应:

text 复制代码
判断连接异常
      ↓
关闭旧连接
      ↓
指数退避
      ↓
重新连接
      ↓
resume stream

例如:

text 复制代码
1s
 ↓
2s
 ↓
4s
 ↓
8s
 ↓
16s

避免网络异常时疯狂重连。


15. WebSocket 不可用怎么办?

这里也不能简单说:

WebSocket 不行就 HTTP/2。

更实际的降级策略取决于业务。

例如 AI 对话:

text 复制代码
双向实时通信
       ↓
WebSocket
       ↓
不可用
       ↓
SSE + HTTP POST

因为 AI 对话通常是:

text 复制代码
POST
用户问题
   ↓
SSE
服务端持续返回 token

SSE 本身是服务端 → 客户端单向流式通信。

所以:

text 复制代码
用户发送消息 → POST
服务端返回 token → SSE
停止生成 → POST /cancel

可以组合起来实现类似效果。


16. 最终架构图

把整个答案串起来:

text 复制代码
                    WebSocket
                       │
                 一条全双工连接
                       │
              ┌────────┴────────┐
              ↓                 ↓
         Client → Server   Server → Client
              │                 │
              └────────┬────────┘
                       ↓
                 应用层协议
                       │
          ┌────────────┼────────────┐
          ↓            ↓            ↓
      stream-A      stream-B     stream-C
          │            │            │
     streamId       streamId      streamId
          │            │            │
        seq          seq           seq
          │            │            │
          └────────────┼────────────┘
                       ↓
                ACK / Window
                       │
                       ↓
                  应用层流控
                       │
                       ↓
              前端消息缓冲区
                       │
                       ↓
              批量更新 / RAF
                       │
                       ↓
                    UI 渲染

断线:
   ↓
重连
   ↓
resume(streamId, lastSeq)
   ↓
服务端恢复/重新发送
   ↓
继续流式传输

17. 面试官追问链

这道题真正的高级面试追问,建议直接按这条链准备:

text 复制代码
WebSocket 怎么实现双向通信?
        ↓
为什么 WebSocket 能双向通信?
        ↓
同时多个请求怎么区分?
        ↓
streamId 怎么设计?
        ↓
seq 有什么作用?
        ↓
WebSocket 本身可靠吗?
        ↓
为什么还需要 ACK?
        ↓
断线怎么续传?
        ↓
服务端数据已经丢了怎么办?
        ↓
服务端推得太快怎么办?
        ↓
bufferedAmount 到底解决哪个方向的问题?
        ↓
应用层流控怎么设计?
        ↓
前端渲染慢怎么办?
        ↓
如何取消某一个流?
        ↓
多个流怎么保证互不影响?
        ↓
WebSocket 断了怎么办?
        ↓
如何降级到 SSE + POST?

18. 这道题的真正考点

可以压缩成 5 个关键词:

连接、分流、顺序、流控、恢复。

能力 解决的问题
WebSocket 双向长连接
streamId 多个业务流怎么区分
seq 一个流内部的数据顺序
ACK / Window 消费确认与流控
resume + lastSeq 断线后从哪里继续

因此,面试时不要从:

"我会 new WebSocket(),然后监听 onmessage......"

开始。

高级前端的回答应该直接从:

"WebSocket 本身只提供一条全双工连接,如果我要做真正的双向流式系统,我会在它上面再设计一层应用协议,核心解决五件事:多流通过 streamId 区分,数据通过 seq 保序,ACK/窗口做流控,cancel 控制单个流,断线通过 lastSeq + resume 做恢复。"

开始。这句话基本就把面试官真正想考的核心抓住了。

另外,最需要纠正的一点是:不要把"多路复用"直接归功于 WebSocket。WebSocket 提供的是全双工连接;如果要在一条连接里同时跑多个独立业务流,通常是应用层自己通过 streamId 等字段实现逻辑复用。

相关推荐
可乐鸡翅yeah_1 小时前
新手梳理:HLS 线上问题,哪些该提给 CDN,哪些该找后端切片服务
开发语言·前端·javascript·vue.js·网络协议·http·m3u8在线
zwd20051 小时前
Manim arrange 和 arrange_in_grid 用法详解:buff、aligned_edge、rows/cols(0.21.0 实测)
前端·python·edge
小小龙学IT1 小时前
Go 语言 reflect 反射包深度解析:从 Type/Value 到三大定律与工业实践
前端·golang
小溪学编程2 小时前
C语言篇:枚举类型
java·c语言·前端
xingpanvip2 小时前
星盘接口开发文档:推算星座接口指南
前端·php
Da Da 泓2 小时前
HTML浅浅入门
前端·html
এ慕ོ冬℘゜2 小时前
es6基础
前端·javascript·es6
熊猫钓鱼>_>2 小时前
从2D平铺到3D沉浸:我用HarmonyOS 7端侧AI做了一个全程数据不出设备的空间化私密相册
前端·人工智能·3d·华为·华为云·harmonyos·鸿蒙
网络小江2 小时前
上网第四十一课:从输入网址到页面显示——一次完整的网络之旅
网络协议