
先问一个问题:你有没有想过,为什么网页聊天、股票行情、在线协作这些功能,以前做起来那么费劲?
答案藏在 HTTP 的规矩里。
HTTP 是一条单行道
HTTP 的工作方式特别像寄信:你寄一封信(请求),对方回一封信(响应)。一封信对应一封信,有来有往,规矩分明。
这个模型在"你问,我答"的场景里完美——打开网页、提交表单、加载图片,都是你主动开口,服务器被动回答。
但问题来了:服务器想主动找你说话怎么办?
比如聊天软件,别人给你发了条消息,服务器得立刻告诉你。可 HTTP 的规矩是"你不寄信,我就不回信"。服务器不知道你什么时候在线,也没法主动开口。
早期解决方案很笨:轮询。前端每隔一秒发一个"有没有新消息?"的请求,服务器每次都回"没有"或者"有一条"。这就像每隔几秒给对方打个电话问"你说话了吗?"——浪费电话费不说,消息还有延迟。
聪明的你应该已经感觉到了:这里需要的不是"更频繁地问",而是一条真正保持连接的电话线。
电话线:WebSocket 的模型
WebSocket 就是那条电话线。
- HTTP 是寄信:每次都要写地址、贴邮票、等回信。
- WebSocket 是打电话:拨通一次,之后你说你的,我说我的,随时都能说话,不用重新拨号。
专业说法叫"全双工"——双方可以同时说,也可以同时听。你要做的只是拨通一次,之后所有消息都在同一条线路上传。
这就是 WebSocket 的全部核心。剩下的,都是这条电话线的工程细节。
重建它:这条电话线是怎么搭起来的
现在从基本事实出发,看看"电话线"到底怎么搭。不需要背协议规范,只需要想清楚一个问题:浏览器和服务器之间只有 HTTP 这么一种说话方式,怎么让它变成一条持续的线路?
答案:利用 HTTP 本身,但只利用它一次——握手。
浏览器发一个特殊的 HTTP 请求,说:"我想升级成 WebSocket。"服务器回一句:"同意。"就这么两句话,线路就建立了。之后两边都不再走 HTTP 那一问一答的规矩,而是用 WebSocket 的帧(frame)直接传数据。
握手长这样(这些头是浏览器替你写的):
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
注意中间两行:Upgrade: websocket 和 Connection: Upgrade。这就是在说:"我要换一种通信方式。" 服务器回 101 Switching Protocols,意思就是"换好了"。
之后传数据用的"帧",你可以想成电话里的每一句话。浏览器把你要发的字符串自动打包成帧,收到帧自动解包——这些细节你根本不用碰。
所以,WebSocket 不是凭空出现的新协议,它是在 HTTP 握手的基础上"升级"出来的。知道它怎么来的,比记住一堆状态码有用得多。
浏览器里怎么用:极简代码
原生 API 就四个事件,没有魔法。
// 拨号(建立连接)
const ws = new WebSocket('ws://localhost:3000/chat')
// 电话接通了
ws.onopen = () => {
ws.send('大家好') // 说话
}
// 对方说话了
ws.onmessage = (event) => {
console.log('收到:', event.data)
}
// 线路断了(无论什么原因)
ws.onclose = () => {
console.log('连接关闭')
}
// 出错
ws.onerror = (err) => {
console.log('出错了', err)
}
把这四行事件处理函数写对,你就能和服务器聊起来。
有个细节值得注意:send() 是主动发,但 onmessage 是被动的——你不知道对方什么时候说话,所以只能"听着"。这跟 HTTP 里"发请求→等响应"的写法完全不同,很多人第一次就卡在这:想不通消息是从哪冒出来的。
想通它很简单:因为线路一直是通的,对方随时可能说话,浏览器只能把"对方说话了"当成一个事件告诉你。你没法预测它,只能监听它。
连接的一生
一条 WebSocket 连接会经历四个阶段,对应你代码里的四个事件:
| 阶段 | 事件 | 你该做什么 |
|---|---|---|
| 建立 | onopen |
发送初始消息 |
| 通信 | onmessage |
处理收到的数据 |
| 出错 | onerror |
记录日志,准备重连 |
| 关闭 | onclose |
清理资源,决定是否重连 |
注意 onerror 和 onclose 常常先后发生——出错之后连接就断了,所以别写重复的重连逻辑。
三个绕不开的工程问题
懂了上面的模型,你已经掌握了 WebSocket 的本质。但现实世界的电话线会出各种问题,初级前端迟早会撞上这三个:
1. 心跳:怎么知道线还通着?
TCP 连接断开时,浏览器不一定能立刻感知——比如断网、服务器重启。你以为线还通着,其实对面已经没人了。
解决方法是"心跳":前端每隔一段时间发一个约定的消息(比如 ping),服务器回 pong。如果一段时间没收到回应,就认为连接死了,主动重连。
这就像打电话时每隔几分钟问一句"喂,还在吗?"——信号不好时,靠这个确认对方还听着。
有个细节要分清:WebSocket 协议底层确实有 ping/pong 控制帧,但浏览器的 JS API 不让你手动发协议级 ping(浏览器收到会自动回 pong)。所以前端代码里的心跳,通常是应用层自己约定的消息格式——你和服务器说好"发 ping 算心跳,回 pong 算活着"就行。
2. 断线重连:掉线了怎么办?
现实中连接总会断(网络波动、服务器重启、手机切网)。所以生产环境里几乎都要写重连逻辑:检测到 onclose,过几秒重新 new WebSocket(...),最好加上退避策略(越重连间隔越长)。
function connect() {
const ws = new WebSocket('ws://example.com/socket')
ws.onclose = () => {
console.log('断了,3 秒后重连')
setTimeout(connect, 3000) // 简化版:固定 3 秒
}
}
connect()
注意:WebSocket 没有内置自动重连。 社区库(如 reconnecting-websocket)本质就是把上面这段逻辑做成了工程化版本。
3. 二进制数据:不只是文本
send() 不仅能发字符串,还能发 ArrayBuffer 和 Blob——图片、音频、文件都能走这条线路。接收时记得判断类型:
const ws = new WebSocket('ws://example.com/socket')
ws.binaryType = 'arraybuffer' // 收到二进制时以 ArrayBuffer 形式给你
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// 处理二进制数据
} else {
// 处理文本
}
}
你可能会困惑的地方
作为初学者,有三个问题几乎一定会困惑,提前说破:
困惑一:"为什么不用 HTTP 轮询?" —— 因为轮询是"假装实时":消息最快也有一个轮询周期的延迟,而且服务器压力大。WebSocket 是"真实时":消息一到立刻推给你。
困惑二:"WebSocket 和 HTTP 是什么关系?" —— 不是替代关系,是升级关系。握手走的是 HTTP,握手成功后换协议。所以 WebSocket 必须基于支持升级的 HTTP 服务(或网关)才能建立。
困惑三:"和 SSE 有什么区别?" —— SSE(Server-Sent Events)是服务器单向推送(服务器→浏览器),基于 HTTP,简单但只能单向。WebSocket 是双向的。做"服务器推消息、浏览器被动接收"的场景(如行情、通知),SSE 够用;要做聊天、协作这种双向交互,才需要 WebSocket。
延伸:AI 流式输出,用 SSE 还是 WebSocket?
现在最火的应用——让 AI 的回答像打字机一样一个字一个字蹦出来——很多前端会在选型时纠结:这该用 WebSocket,还是 SSE?
别纠结,用刚才的思路拆一下就清楚了。先问一个问题:这个场景里,数据往哪个方向流?
你发一条消息,然后 AI 开始输出。输出期间你基本不说话,只是看着。数据方向是单向的:服务器 → 浏览器。
单向推送,就是 SSE 的主场。
SSE 从名字就能看出来——Server-Sent Events,服务器主动发事件。它基于 HTTP,浏览器原生支持 EventSource,而且断线自动重连(这点 WebSocket 反而没有,要自己写):
const es = new EventSource('/api/chat') // 打开水龙头
es.onmessage = (event) => {
// AI 每吐出一段,这里就触发一次
console.log('新内容:', event.data)
}
打开水龙头,水自己流出来,你只管接——这个类比就是流式输出的全部。为单向放水专门拨一条电话线(WebSocket),是杀鸡用牛刀:握手、心跳、断线重连,复杂度全落在你这边。
这不是纸上谈兵——现在主流大模型接口的流式输出,几乎清一色走 SSE 格式(text/event-stream)。
那什么时候才轮到 WebSocket? 还是回到那个问题:数据是不是双向的。
| 场景 | 数据方向 | 选型 |
|---|---|---|
| AI 流式输出(打字机效果) | 单向:服务器→浏览器 | SSE |
| 行情、进度、通知推送 | 单向 | SSE |
| AI 边说话你边打断、插话 | 双向 | WebSocket |
| 聊天室、在线协作 | 双向 | WebSocket |
两个实战细节值得记住:
-
EventSource只能发 GET、不能带自定义请求头。如果鉴权 token 必须放请求头里,就改用fetch读取响应流——SSE 只是一种数据格式,用fetch照样能解析,还能精细控制"中途停止生成"。 - 既要流式又要双向的场景(AI 边输出你边插话),可以 SSE 打底、另用普通 HTTP 请求补发指令,不一定要升级到 WebSocket。
判断标准就一句话:数据单向,用 SSE;数据双向,才上 WebSocket。
动手:本地搭一个 echo 服务
想练手,最稳的路是在本地起一个自己的 echo 服务,顺便把服务器端也看明白了:
// server.js —— 先 npm install ws
const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({ port: 3000 })
wss.on('connection', (ws) => {
ws.on('message', (data) => ws.send(data.toString())) // 收到什么,回什么
})
console.log('echo 服务跑在 ws://localhost:3000')
npm install ws && node server.js
然后打开浏览器控制台,连它:
const ws = new WebSocket('ws://localhost:3000')
ws.onopen = () => { console.log('已连接'); ws.send('你好') }
ws.onmessage = e => console.log('收到:', e.data)
localhost 不经过任何外网,连接必成。你发"你好",它回"你好"。
一句话总结
HTTP 是寄信,WebSocket 是打电话。拨通一次,双向随时说。
把上面的本地 echo 服务跑起来,四行事件处理函数敲一遍,发条消息看它回来。自己动手连一次,比读十篇文章都有用。