WebSocket
握手机制(八股)
WebSocket 是一种应用层通信协议 ,用于在浏览器与服务器之间建立持久的、全双工的数据通道。
这里容易和名字里的 Socket 混淆:底层网络传输确实依赖 TCP socket,但 WebSocket 本身并不是"在传输层建立的 socket",而是运行在 TCP 之上的应用层协议。它规定了浏览器和服务端如何握手、如何编码和收发消息,以及如何关闭连接。
WebSocket 建连时会先发起一次看起来像普通 HTTP 的请求;这一步不是"假装 HTTP",而是为了复用 HTTP 的入口能力,例如 80/443 端口、反向代理、鉴权和 TLS。客户端在请求头中声明希望将连接升级为 WebSocket,以 Manus 为例:

如果服务端支持,便返回 101 Switching Protocols。此后,这条 TCP 连接不再按照 HTTP 的"一问一答"模型传输,而是切换为 WebSocket 帧协议,客户端和服务端都可以随时主动发送消息。

SSE
SSE 是 "chatbot" 选型最常用的方案,你如果让 AI 给你写一个 "chatbot",往往它会给来上一套经典的 Next.js + Tailwind + ai 的方案,因为 npm 里的 ai 和 Next.js 都是 Vercel 出品

手搓 SSE
虽然 SSE 本身有大量的封装 SDK 、开源库或者 npm 包,但面试还是会考一下你手搓滴,下面是一个示例
feature: SSE 封装:心跳 + 自动重连 + 消息缓存 + 卸载销毁
EventSource 版本
javascript
/**
* SSE 封装:心跳 + 自动重连 + 消息缓存 + 卸载销毁
*
* 客户端断开两种语义:
* 1. 关页 / 卸载 → stop:清理资源,不再重连
* 2. 网络断 → onerror / offline + 心跳超时:意外断开,自动重连
*
* @param {object} options
* @param {(msg: object) => void} [options.onMessage] - 用来调试
*/
export function createSSEHook(options) {
const { onMessage: _onMessage } = options;
let eventSource = null;
let stopped = false;
let lastEventId = null;
let lastActivityAt = Date.now();
const messages = [];
const messageId = new Set();
const closeStream = () => {
eventSource?.close();
eventSource = null;
};
const connect = () => {
if (stopped) return;
closeStream();
let url = `${location.protocol}//${location.hostname}:3002/ai-stream`;
if (lastEventId) {
url += "?lastEventId=" + encodeURIComponent(lastEventId);
}
const eventSource = new EventSource(url);
eventSource.addEventListener("message", (event) => {
if (event instanceof MessageEvent) {
lastEventId = event.lastEventId;
}
try {
lastActivityAt = Date.now();
const data = JSON.parse(event.data);
if (data.type === "heart") {
lastActivityAt = Date.now();
} else if (!messageId.has(data.id)) {
messageId.add(data.id);
messages.push(data);
}
} catch {}
});
eventSource.onopen = () => {
lastActivityAt = Date.now();
};
eventSource.onerror = () => {
if (stopped) return;
if (eventSource.readyState === EventSource.CLOSED) {
// 初始连接时,Nginx/网关没挂,但是服务器重启这种 case 不会触发 EventSource
// 自动重连(即 readyState === EventSource.CONNECTING),因此需要实现重连
closeStream();
connect();
}
};
};
/** 场景 2:半开连接(TCP 还在但流已死)→ 心跳探测失败后重连 */
const timer = setInterval(() => {
if (stopped || eventSource?.readyState !== EventSource.OPEN) return;
if (Date.now() - lastActivityAt > 3000) {
connect();
}
}, 1000);
/** 场景 1:关页 / 组件卸载 --- 彻底销毁 */
const stop = () => {
stopped = true;
clearInterval(timer);
closeStream();
window.removeEventListener("pagehide", stop);
};
// pagehide 比 beforeunload 更稳(移动端/ bfcache)
window.addEventListener("pagehide", stop);
connect();
return {
messages,
stop,
};
}
EventSource 建立的 SSE 对应的服务端一般会返回 chunk_id 如下图

在网络抖动或者服务端重启导致的 EventSource 自动重连会带上最后一个 id,这样服务端就能够根据 last-event-id 里去按需返回 redis 缓存里的 chunk,client 渲染的 chunk 就不会重复
重连时服务端日志如下
css
$ npm run start -w sse-hook
> sse-hook@1.0.0 start
> node server.js
SSE demo: http://localhost:3001
Last-Event-ID: undefined
Last-Event-ID: heart-1793717750411
QA
Q: 我在浏览器怎么看不到 last-event-id?
A: Chrome 里的重连连接可能看不到浏览器携带的 last-event-id

最好是在服务端打印观察,比如
javascript
app.get('/ai-stream', (req, res) => {
console.log('Last-Event-ID:', req.headers['last-event-id']);
...
}
Q: 我该怎么模拟网络抖动?
A: 最好是在电脑做服务端,然后手机和电脑连同一个 Wi-Fi,打印电脑端的局域网 iP,然后手机访问对应的 iP + Port 去访问(比如 http://192.168.1.10:3001),再关闭 Wi-Fi,触发 EventSource 的自动重连,然后再开 Wi-Fi,观察服务端有没有接收到重连的请求
如果你有两台电脑,接收端的电脑(如果是 MacOS)记得在打开 系统设置 → 隐私与安全性 → 本地网络 ,允许实际用于访问的应用,比如 Chrome,不然访问不了你的局域网 iP + Port,开启示例如下图

ReadableStream 版本
相对于 EventSource,ReadableStream 的 SSE 实现更好拓展和自定义,可以使用 POST、携带请求体或自定义请求头,也可以在读取前检查响应状态,比如 HTTP 状态码和 Content-Type,决定是否解析 SSE,是否重连和对应的实现也可以自己根据业务决定,一个例子如下:(由于实现代码太长,只使用伪代码,完整代码可以查看参考文档里的 github 链接)
js
function useSseChat() {
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState("ready");
const prevStatusRef = useRef(status);
const canResumeRef = useRef(false);
const sseController = useRef(null);
useEffect(() => () => sseController.current?.abort(), []);
const sendMessage = async (url = "/api/chat") => {
canResumeRef.current = true;
sseController.current?.abort();
const controller = new AbortController();
sseController.current = controller;
setStatus("streaming");
try {
const res = await fetch(url, {
signal: controller.signal,
});
await res.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
.pipeThrough(
new TransformStream({
transform(event) {
const reply = JSON.parse(event.data);
setMessages((curr) => {
const i = curr.findLastIndex((m) => m.id === reply.id);
if (i === -1) return [...curr, reply];
else return curr.with(i, reply);
});
},
}),
)
.pipeTo(new WritableStream());
} catch {
} finally {
if (!controller.signal.aborted) setStatus("ready");
}
};
useEffect(() => {
const prevStatus = prevStatusRef.current;
prevStatusRef.current = status;
if (
prevStatus !== "streaming" ||
status !== "ready" ||
!canResumeRef.current
) {
return;
}
const controller = new AbortController();
let timer,
attemps = 0;
const checkHeartbeat = async () => {
if (attemps >= 10 || controller.signal.aborted) return;
attemps++;
try {
const res = await fetch("/api/chat/heart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
signal: controller.signal,
});
const { canResume } = await res.json();
if (controller.signal.aborted) return;
if (canResume) return sendMessage(`/api/chat/${id}/heart`);
} catch {}
if (!controller.signal.aborted && attemps < 10) {
timer = setTimeout(checkHeartbeat, 1000);
}
};
checkHeartbeat();
return () => {
controller.abort();
clearTimeout(timer);
};
}, [status]);
return {
messages,
sendMessage,
stop() {
canResumeRef.current = false;
sseController.current?.abort();
setStatus("ready");
},
};
}
ReadableStream 版本多了 chunk 的处理
EventSource内置了 SSE 解析,监听器可以直接从event.data取到事件载荷。如果用fetch读取 SSE,就要先把响应字节流解码成文本,再用EventSourceParserStream从连续的文本块中识别事件边界、提取data。服务端把 JSON 放在data:里时,提取出的载荷就是 JSON 字符串。 - codex
EventSource 版本根据心跳处理 TCP 半开连接的场景在 ReadableStream 版本先忽略
边界 Case
- if (canResume) return sendMessage(
/api/chat/${id}/heart):检测到可以重连后需要及时终止心跳,直接return sendMessage...,避免下一轮心跳再次触发,导致连续断点续连 res.body需要等待:ReadableStream的处理是链式调用,类似Promise().then().then(),如果需要等待流式管道完成,需要await + pipeTo
竟态 Case
由于自动重连、续流、停止三个操作都互有关联,因此三方的竟态处理需要处处防御
status驱动的重连的防御:停止SSE也就是stop的调用也驱动了status: streaming -> ready,但这不是我们自动重连该监听的,因此我们需要一个标志位防御下,即canResumeRef(状态驱动自动重连其实不是一个很好的设计,后续类似的状态变多更应该考虑事件驱动,比如redux, useReducer的dispatch,能够追根溯源)
轮询
有些流式可能是轮询实现,比如 OpenAI 的一个例子, 1s 去查一次状态,即 get_run_status,理论上 get_run_status 可以实现返回 message
轮询实现 AI 流式对话的 case 实在太少,就不过多展开
不过可以说下什么时候用它
比如文生视频、文生图,这种耗时较长的工具调用可以用轮询,因为这期间没啥输出
SSE 和 WebSocket 有什么不同?
两者都能让 AI 的回答"一个字一个字地冒出来",但通信模型不同。
SSE 是服务端单向推送 。客户端先用 HTTP 发起请求,服务端保持响应不结束,并持续把生成结果推回客户端。用户发送提问、点击"停止生成"等操作,则另外通过普通 HTTP 请求提交。
客户端 ── HTTP 请求 ──> 服务端
客户端 <── SSE 事件流 ── 服务端
WebSocket 则是全双工通信。连接建立后,客户端和服务端共享一条长期存在的通道;任何一方都能主动发消息。
客户端 <──── WebSocket 双向通道 ────> 服务端
| 对比项 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端 → 客户端 | 客户端 ↔ 服务端 |
| 基于什么 | HTTP 持续响应 |
升级后的 WebSocket 协议 |
| 浏览器原生接口 | EventSource |
WebSocket |
适合 AI 文本流 |
很适合 | 也适合,但常有些"重" |
| 适合实时双向场景 | 一般 | 更适合,如语音、协作、游戏 |
对于普通 AI 文本对话,SSE 往往足够且实现简单:用户发一次请求,服务端流式返回回答。只有当客户端需要频繁、实时地主动向服务端发送数据------例如实时语音、多人协作、持续状态同步------WebSocket 的双向能力才更有价值。
总结:
SSE 是"服务端持续说,客户端另开口";WebSocket 是"双方一直通着电话,谁都能随时说话"。