做一个订单状态实时刷新的功能时,我最早的方案是每三秒轮询一次接口。上线之后运维找过来,说那个接口的 QPS 涨了几十倍,而且绝大多数请求返回的都是「没有变化」。这才是我真正开始认真看 WebSocket 的起点。
这篇分两部分。前半部分讲原生 WebSocket,从最小可运行的例子到封装成 class,再到断线重连和心跳保活这两个绕不开的问题。后半部分讲 socket.io,分别在原生 Node、Express、Koa 三种环境下把服务端和客户端跑通,顺带说清楚 socket.emit 和 io.emit 这两个方法为什么能分别做出机器人和聊天室。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 轮询到底浪费在哪,WebSocket 解决的是什么问题
- 一个最小的 WebSocket 例子,四个回调分别在什么时候触发
- 把 WebSocket 封装成 class,以及原文那个 class 里的一处真实 bug
- WebSocket 为什么会莫名断开,重连和心跳两种方案的取舍
readyState的四个状态值,以及发送二进制数据的写法- WebSocket 的优点,以及「没有同源限制」这句话背后的安全隐患
- socket.io 在原生 Node、Express、Koa 三种环境下的接法
socket.emit和io.emit的区别,聊天室和智能机器人的实现原理- socket.io 新旧版本的 API 差异,老代码迁移时要注意什么
# 一、WebSocket 解决了什么问题
客户端(浏览器)和服务器端进行通信,只能由客户端发起 ajax 请求才能进行通信,服务器端无法主动向客户端推送信息。
当出现类似体育赛事、聊天室、实时位置之类的场景时,客户端要获取服务器端的变化,就只能通过轮询(定时请求)来了解服务器端有没有新的信息变化。
轮询效率低,非常浪费资源,需要不断发送请求、不停连接服务器。
这个浪费具体在哪?每次轮询都是一次完整的 HTTP 请求,要走 TCP 连接(没有 keep-alive 的话还要三次握手)、要带上完整的请求头和 Cookie、服务端要走一遍鉴权和路由。而这一整套开销换来的往往是一句「数据没变」。你把轮询间隔调短,服务器压力线性上升;调长,实时性又不够。这是个没有最优解的取舍。
WebSocket 的出现,让服务器端可以主动向客户端发送信息,使得浏览器具备了实时双向通信的能力,这就是 WebSocket 解决的问题。
原文这句话写的是「让服务器端可以主动向服务器端发送信息」,前后都是服务器端,是个笔误,正确的是服务端主动推给客户端。
它的做法是:先用一个普通的 HTTP 请求发起握手(带上 Upgrade: websocket 头),服务端同意之后,这条 TCP 连接就从 HTTP 协议切换成 WebSocket 协议,之后双方都可以随时往这条连接上发数据,不需要再有请求响应的配对关系。
# 1.1 一个超简单的例子
新建一个 html 文件,把下面这段找个地方跑一下,就算入门 WebSocket 了。
function socketConnect(url) {
// 客户端与服务器进行连接
let ws = new WebSocket(url); // 返回WebSocket对象,赋值给变量ws
// 连接成功回调
ws.onopen = e => {
console.log('连接成功', e)
ws.send('我发送消息给服务端'); // 客户端与服务器端通信
}
// 监听服务器端返回的信息
ws.onmessage = e => {
console.log('服务器端返回:', e.data)
// do something
}
return ws; // 返回websocket对象
}
let wsValue = socketConnect('ws://121.40.165.18:8800'); // websocket对象
这段代码信息量不大但每一行都有讲究。
new WebSocket(url) 是立刻发起连接的,构造函数返回时连接还没建好。所以 ws.send() 必须写在 onopen 里面,写在构造函数后面会报 InvalidStateError,因为那时候连接还处于 CONNECTING 状态。这是新手第一个会撞的墙。
onmessage 拿到的 e.data 是原始数据。服务端如果发的是 JSON 字符串,这里拿到的是字符串不是对象,得自己 JSON.parse。而且要包一层 try/catch,因为服务端偶尔会发一些非 JSON 的控制消息(比如心跳的 pong),直接 parse 会抛异常把整个 onmessage 打断。
ws:// 和 wss:// 的关系相当于 http:// 和 https://。页面是 HTTPS 的话,浏览器不允许你连 ws://(混合内容会被拦截),必须用 wss://。
上述例子中 WebSocket 的接口地址出自 WebSocket 在线测试站点,在开发的时候也可以用于测试后端给的地址是否可用。

这类公共回声服务的可用性变化很快,2019 年能用的地址现在多半已经下线了。要找可用的测试端点,去搜当下还在维护的在线工具,或者自己用 ws 这个 npm 包起一个本地服务,二十行代码就够,比依赖别人的服务靠谱。
# 二、把 WebSocket 封装成 class
当项目中很多地方使用 WebSocket,把它封成一个 class 类是更好的选择。
class WebSocketClass {
/**
* @description: 初始化实例属性,保存参数
* @param {String} url ws的接口
* @param {Function} msgCallback 服务器信息的回调传数据给函数
* @param {String} name 可选值 用于区分ws,用于debugger
*/
constructor(url, msgCallback, name = 'default') {
this.url = url;
this.msgCallback = msgCallback;
this.name = name;
this.ws = null; // websocket对象
this.status = null; // websocket是否关闭
}
/**
* @description: 初始化 连接websocket或重连webSocket时调用
* @param {*} 可选值 要传的数据
*/
connect(data) {
// 新建 WebSocket 实例
this.ws = new WebSocket(this.url);
this.ws.onopen = e => {
// 连接ws成功回调
this.status = 'open';
console.log(`${this.name}连接成功`, e)
// this.heartCheck();
if (data !== undefined) {
// 有要传的数据,就发给后端
return this.ws.send(data);
}
}
// 监听服务器端返回的信息
this.ws.onmessage = e => {
// 把数据传给回调函数,并执行回调
return this.msgCallback(e.data);
}
// ws关闭回调
this.ws.onclose = e => {
this.closeHandle(e); // 判断是否关闭
}
// ws出错回调
this.ws.onerror = e => {
this.closeHandle(e); // 判断是否关闭
}
}
// 发送信息给服务器
sendHandle(data) {
console.log(`${this.name}发送消息给服务器:`, data)
return this.ws.send(data);
}
closeHandle(e = 'err') {
// 因为webSocket并不稳定,规定只能手动关闭(调closeMyself方法),否则就重连
if (this.status !== 'close') {
console.log(`${this.name}断开,重连websocket`, e)
this.connect(); // 重连
} else {
console.log(`${this.name}websocket手动关闭`)
}
}
// 手动关闭WebSocket
closeMyself() {
console.log(`关闭${this.name}`)
this.status = 'close';
return this.ws.close();
}
}
这里我改了一处 bug,得单独说一下。
原文写的是 this.onerror = e => {...},少了中间的 ws。这一行是给 WebSocketClass 的实例挂了一个叫 onerror 的属性,跟那个 WebSocket 对象没有任何关系。结果就是连接出错时这个回调永远不会被触发,出错路径下的重连逻辑整个失效。
它为什么没被发现?因为 onclose 是正确的,而 WebSocket 出错时通常也会紧接着触发 close,重连凑巧还是能跑起来。这类 bug 最难查,因为表现上「大部分时候是好的」。正确写法是 this.ws.onerror = ...,我改过来了。
用法是这样。
function someFn(data) {
console.log('接收服务器消息的回调:', data);
}
const wsValue = new WebSocketClass('wss://echo.websocket.org', someFn, 'wsName');
wsValue.connect('立即与服务器通信'); // 连接服务器
这里的 wss://echo.websocket.org 是当年阮一峰老师教程里用的公共回声服务,现在已经不可用了,换成你自己的测试地址即可。
可以把 class 放在一个 js 文件里面 export 出去,然后在需要用的地方再 import 进来,把参数传进去就可以用了。
用这个 class 的时候还有一点要留意:connect() 里每次都是 new WebSocket,旧的那个实例上的回调如果没解绑,重连多次之后可能会有多份回调同时在跑。生产代码里我会在重连前先把旧实例的四个 handler 置空,再 close() 掉。
# 三、WebSocket 为什么会断
WebSocket 并不稳定,在使用一段时间后可能会断开连接,貌似至今没有一个为何会断开连接的公论,所以我们需要让 WebSocket 保持连接状态。
其实原因大多是可以定位的,只是分散在链路的各个环节上。Nginx 有 proxy_read_timeout,默认 60 秒没有数据流动就断;很多云负载均衡有更短的空闲超时;企业网络里的代理和防火墙也会主动清理长时间没流量的连接;手机从 WiFi 切到 4G 时整条 TCP 连接直接失效。这些环节没有一个会告诉你「我要断了」,你只会看到一个突如其来的 onclose。
针对这个问题,这里推荐两种方法。
# 3.1 设置变量,判断是否手动关闭连接
class 类中就是用的这种方式:设置一个变量,在 WebSocket 关闭或报错的回调中判断是不是手动关闭的,如果不是的话就重新连接。
- 优点:请求较少(相对于心跳连接),易设置
- 缺点:可能会导致丢失数据,在断开重连的这段时间中恰好双方正在通信