WebSocket 技术已经逐渐成熟,在生产环境下也已经带给我们非常多的便利。本文首先会努力阐明 webSocket 的基本原理,然后会结合实际叙述如何使用它。


WebSocket 不会完全取代 HTTP

首先需要明确的是 WebSocket 的定位。WebSocket 是建立在 HTTP 基础上,为客户端与服务端之间提供文本和二进制数据的全双工通信的技术。这里有几个地方需要注意:

  • 建立在 HTTP 基础上。WebSocket 需要在建立了 HTTP 连接之后,客户端才能发起 WebSocket "握手" 请求,握手成功后客户端与服务端才能进行 WebSocket 通信
  • 提供文本 (Text) 和二进制数据 (Binary Data) 两种数据的传输
  • 全双工通信。一旦确立 WebSocket 通信连接,无论是客户端还是浏览器,任意一方都可以想对方发送消息,即实现了服务器想客户端推送数据的功能

WebSocket 是为了满足基于 Web 的日益增长的实时通信需求而产生的。在传统的 Web 中,要实现实时通信 (比如网页版 QQ),常用的方式是通过轮询。在特定的时间间隔,由浏览器向服务器发送 HTTP 请求,然后将最新的数据返回给浏览器。这样的方法最明显的缺点就是需要不断的发送请求,不仅占用了宽带,也占用服务器 CPU 资源 (没有信息也要接受请求)。

如此看来,WebSocket 的出现相比于纯 HTTP 实现实时通信至少有两个优势:

  • 实时性提高,只要 WebSocket 连接建立,就可以一直保持连接状态,发送消息时不需要额外的连接建立过程,通信的实时性大大提高
  • 总数据流量降低,WebSocket 的首部相比于 HTTP 首部的体积要小的多,尤其是发送消息次数较多,这会节省下相当客观的数据流量

然而,WebSocket 在实现高效实时通信的过程却也不再享有在一些本由浏览器提供的服务和优化,如状态管理、压缩、缓存等。以后浏览器厂商是不是会针对 WebSocket 作出一些服务和优化不得而知,但至少现在 WebSocket 还完全不足以撼动 HTTP 的地位。总的来说,WebSocket 弥补了 HTTP 在某些通信领域的短板,但绝不可能完全取代 HTTP。


WebSocket API

WebSocket 原本是 html5 标准的一部分,随其发展壮大,现在已逐渐变成了独立的协议标准。在客户端,HTML5 提供了一套非常简洁的 API 供我们使用。

构造方法

  1. WebSocket(url: string, protocols ? :string[] | string)
  • url 表示要连接的 URL。注意协议名是 ws 或者 wss。
  • protocols 可以是一个单个的协议名字字符串或者包含多个协议名字字符串的数组。这些字符串用来表示子协议,这样做可以让一个服务器实现多种 WebSocket 子协议(例如你可能希望通过制定不同的协议来处理不同类型的交互)。如果没有制定这个参数,它会默认设为一个空字符串。

原型方法

一共就只有两个。

  1. close(code ? :number, reason ? :string) : void

关闭 WebSocket 连接或停止正在进行的连接请求。如果连接的状态已经是 closed,这个方法不会有任何效果。

  • code 表示关闭连接的状态号,表示连接被关闭的原因。如果这个参数没有被指定,默认的取值是 1000 ,即正常连接关闭。更多取值查看 CloseEvent 页面。
  • reason 一个描述性的字符串,表示连接被关闭的原因。
  1. send(data: string | ArrayBuffer | Blob) : void

通过 WebSocket 连接向服务器发送数据。

  • data 表示要发送到服务器的数据,可以是 String、 ArrayBuffer 或者 Blob 类型。String 类型相当于是文本数据,ArrayBuffer 和 Blob 是二进制数据。

事件

事件遵从原生 Javascript 的两种写法:

  • onevent = handler
    1. addEventListener(event, handler)

支持的事件有 open,message,close 和 error。注意 message 事件在接收到所有数据时出发。

属性

WebSocket 对象的属性用来描述通信细节和状态。

  • binaryType: string 表示被传输二进制的内容的类型。取值应当是'blob' 或者'arraybuffer'
    1. var ws = new WebSocket('wss://example.com/socket');
    2. ws.binaryType = "arraybuffer"; // 强制将接收的二进制数据转为 ArrayBuffer 类型
    3.  
    4. ws.onmessage = function(msg) {
    5. if(msg.data instanceof ArrayBuffer) {
    6. processArrayBuffer(msg.data);
    7. } else {
    8. processText(msg.data);
    9. }
    10. }
    1. bufferedAmount: number
    调用 send() 方法将多字节数据加入到队列中等待传输,但是还未发出。该值会在所有队列数据被发送后重置为 0。而当连接关闭时不会设为 0。如果持续调用 send(),这个值会持续增长。只读。
  • protocol: string 一个表明服务器选定的子协议名字的字符串。这个属性的取值为构造器传入的 protocols 参数或者之一。
  • readyState: number 连接的当前状态。取值是 Ready state constants 之一。 只读。
  • url: string 传入构造器的 URL。它必须是一个绝对地址的 URL。只读。

最简单的一个例子

  1. // Create WebSocket connection.
  2. const socket = new WebSocket('ws://localhost:8080');
  3.  
  4. // Connection opened
  5. socket.addEventListener('open', function (event) {
  6. socket.send('Hello Server!');
  7. });
  8.  
  9. // Listen for messages
  10. socket.addEventListener('message', function (event) {
  11. console.log('Message from server', event.data);
  12. });

然而以上只是在客户端的 WebSocket 实现,如果没有服务端的配合,WebSocket 是不能进行通信的。关于 WebSocket 的在服务端的实现有多种方案,下一节会在 Node.js 上进行实现。

以上部分是个人简单总结,如果想详细了解其 API 可以查阅 MDN 上的文档


WebSocket 的基本使用

使用 WebSocket 其实并不复杂,本节将结合 socket.io 模块进行说明。接下来看看如何构建一个最简单的 WebSocket 应用。

第一步,安装依赖库:

  1. npm install socket.io express

第二步,构建客户端 /public/index.html:

  1. <body>
  2. <script src="/socket.io/socket.io.js">
  3. </script>
  4. <script>
  5. const socket = io('http://localhost:3231');
  6. socket.on('connect', () = >{
  7. socket.emit('greet', 'Hello, websocket!');
  8. socket.on('uppergreet', data = >console.log(data));
  9. });
  10. </script>
  11. </body>

注意上面引入 socket.io 的方式,不用怀疑路径是否正确,因为 socket.io 会自动引入相应客户端部分代码,并且暴露一个 io 全局变量。

第三步,构建服务端 server.js:

  1. const express = require('express');
  2. const app = express();
  3. const http = require('http').Server(app);
  4. const io = require('socket.io')(http);
  5. const port = process.env.PORT || 3000;
  6.  
  7. app.use(express.static(__dirname + '/public'));
  8.  
  9. io.on('connection', socket = >{
  10. socket.on('greet', data = >socket.emit('uppergreet', data.toUpperCase()));
  11. });
  12.  
  13. http.listen(port, () = >console.log('listening on port ' + port));

最后,运行 node server.js 并用浏览器访问

  1. http://localhost:3000
,打开控制台将会看到输出:

HELLO, WEBSOCKET!

可以看到,使用 socket.io 构建 WebSocket 应用相比上一节提到的 WebSocket API 在写法上还是有比较大的区别的。

除了本节提到的 socket.io 模块,在 Node.js 上还有多种实现,比如 websocket nodejs-websocket ,他们不仅实现了服务端的 WebSocket 通信协议,同时也对上一节提到的客户端的 WebSocket API 进行了或多或少的封装,功能完善了很多,在实际工作中已经得到了广泛应用,个人比较倾向于 socket.io 库。


WebSocket 的适用场景

WebSocket 适用于需要高效实时通信的场景,比如网页聊天,对战游戏等。本节将使用 socket.io 模块进行说明,在 socket.io 的官网上有多个典型使用场景,这里我们使用官网给出的较为简单的一个 画板案例 进行说明。 socket.io 官网还给出一个 使用 jQuery 开发的聊天应用示例 ,我根据其最终效果实现了 一个 react 版本

画板案例的关键之处是多个客户端可以同时在一张画板上进行绘画,绘画结果所有客户端都能实时看到,(这个场景是不是和你画我猜的游戏类似?一人画图,多个用户能实时看到画图结果),这里使用 WebSocket 是非常合适的。

首先是服务端的代码 index.js,和上一节的代码非常类似:

  1. const express = require('express');
  2. const app = express();
  3. const http = require('http').Server(app);
  4. const io = require('socket.io')(http);
  5. const port = process.env.PORT || 3000;
  6.  
  7. app.use(express.static(__dirname + '/public'));
  8.  
  9. io.on('connection', socket = >{
  10. socket.on('drawing', data = >socket.broadcast.emit('drawing', data));
  11. });
  12.  
  13. http.listen(port, () = >console.log('listening on port ' + port));

接下来运行如下命令:

node index.js

然后打开两个浏览器窗口都访问

  1. http://localhost:3000
,然后在一个浏览器内进行绘画,另外一个浏览器也能实时看到结果。

可以看到服务端代码非常简洁,在这里关键步骤就是 connection 事件的处理方法,在该方法内部 socket 会监听 drawing 事件,在监听到 drawing 事件之后的回调函数中,再次广播发送 drawing 事件,广播发送的意思就是向所有连上服务端的客户端发送。

然后是客户端的关键代码 /public/main.js:

  1. var socket = io();
  2. socket.on('drawing', onDrawingEvent);
  3.  
  4. var canvas = document.getElementsByClassName('whiteboard')[0];
  5. canvas.addEventListener('mousedown', onMouseDown, false);
  6. canvas.addEventListener('mouseup', onMouseUp, false);
  7. canvas.addEventListener('mouseout', onMouseUp, false);
  8. canvas.addEventListener('mousemove', throttle(onMouseMove, 10), false);

上面代码中的 onDrawingEvent 、mouseUp 和 onMouseMove 方法内部都调用了一个关键的 drawLine 方法:

  1. function drawLine(x0, y0, x1, y1, color, emit){
  2. // ... 绘制 canvas ...
  3.  
  4. if (!emit) return;
  5.  
  6. socket.emit('drawing', {
  7. // ... 绘制 canvas 的状态信息 ...
  8. });
  9. }

drawLine 方法根据传入的 emit 位来判断是否要向服务器发送 drawing 事件,如果是客户端本身进行的绘制,则会向服务端发送 drawing 事件,否则不会。

以上就是实现一个多人同时绘画的实时应用的整体思路,可以看到当我们使用 WebSocket 进行通信,尤其是使用类似 socket.io 这种封装程度很高的工具库时,通信过程是非常简洁的,我们把大部分精力放在业务逻辑中即可。


WebSocket 技术的发展已经让开发者无须了解协议内部即可实现实时通信逻辑,尤其是配合使用 Node.js 作为服务端时,类似 socket.io 的工具库为服务端和客户端都提供了非常方便的调用方法,上手非常快。如果在工作中需要构建高效实时应用,WebSocket 将会是不二选择。


参考资料