前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版

Node.js系列之WebSocket与socket.io实战

首页2019-01-24 15:00:43Back-End
SocketWebSocketNode

做一个订单状态实时刷新的功能时,我最早的方案是每三秒轮询一次接口。上线之后运维找过来,说那个接口的 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 在线测试站点,在开发的时候也可以用于测试后端给的地址是否可用。

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 关闭或报错的回调中判断是不是手动关闭的,如果不是的话就重新连接。

  • 优点:请求较少(相对于心跳连接),易设置
  • 缺点:可能会导致丢失数据,在断开重连的这段时间中恰好双方正在通信
fe
  • 一、WebSocket 解决了什么问题
    • 1.1 一个超简单的例子
  • 二、把 WebSocket 封装成 class
  • 三、WebSocket 为什么会断
    • 3.1 设置变量,判断是否手动关闭连接
    • 3.2 心跳机制
  • 四、关于 WebSocket 的几个细节
    • 4.1 当前状态 readyState
    • 4.2 发送和接收二进制数据
    • 4.3 优点与它的边界
  • 五、原生 Node 结合 socket.io
    • 5.1 搭建服务
    • 5.2 新建页面
    • 5.3 服务器端通过 emit 广播,通过 on 接收
    • 5.4 客户端通过 emit 发送,通过 on 接收
  • 六、聊天室与智能机器人的实现原理
    • 6.1 Express 结合 socket.io
    • 6.2 用 Express 实现智能机器人
    • 6.3 结合数据库的智能机器人
  • 七、Koa 中使用 socket.io
  • 总结
  • 参考

← Node基础篇回顾 从HTTP模块到Express与MongoDBMongoDB拾遗(一),环境搭建、基本概念与常用查询 →