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 等字段实现逻辑复用。