为何要用 WebSocket

HTTP 协议遵循经典的客户端-服务器模型,客户端发送一个请求,然后等待服务器端的响应,服务器端只能在接收到客户端的请求之后进行响应,不能主动的发送数据到客户端。

客户端想要在不刷新页面的情况下实时获取到服务器端最新的数据,可以通过以下途径:

  • 轮询:客户端(浏览器)定时向服务器端发送请求,获取最新的数据。轮询的间隔过长会导致用户不能及时接收到更新的数据;轮询的间隔过短会增加服务器端的负担。
  • 长轮询:客户端发起一个请求到服务器端,服务器端一直保持连接打开,直到有数据推送,再返回这个请求,客户端收到服务器端返回的数据后,处理数据并发起一个新请求。使用这种方法,每有一个客户端服务器端就要一直保持一条连接,当达到服务器处理的上限的时候,服务器将无法响应新的请求。
  • SSE:是基于 HTTP 实现的一套服务器向客户端发送数据的 API。他是针对上面说到的三种方法(轮询,长轮询,HTTP 流)的一个标准 API 实现。但不兼容 IE 浏览器。
  • Web Sockets:Web Sockets 采用了一套全新的协议(ws/wss)来建立客户端到服务器端的全双工、双向通信连接,相较于 HTTP 请求更加高效(不需要握手,连接始终存在;无需携带头部信息)。

在使用的过程中,根据产品将来的使用环境(支持的浏览器类型、版本),使用场景(双向通信、单向通信)这些点,并结合每一种方法的优缺点去考虑,然后选取对应的策略。

WebSocket 协议在 2008 年诞生,2011 年成为国际标准。所有浏览器都已经支持了。最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

其他特点包括:

  1. 建立在 TCP 协议之上,服务器端的实现比较容易。
  2. 与 HTTP 协议有着良好的兼容性。默认端口也是 80 和 443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
  3. 数据格式比较轻量,性能开销小,通信高效。
  4. 可以发送文本,也可以发送二进制数据。
  5. 没有同源限制,客户端可以与任意服务器通信。
  6. 协议标识符是 ws(如果加密,则为 wss),服务器网址就是 URL。

下面从服务器端和客户端分别展开讨论。

服务器端

目前有两个流行的 ws 通信库,分别是 ws 和 socket.io。两个库各有特点,简单说,ws 通信速度更快,socket.io 兼容性更好,最低可支持 IE9,而且易用性更好,封装了心跳检测等功能。

两个库用法类似,下面以 ws 为例展开。(ws 的官网:https://github.com/websockets/ws)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
const WebSocket = require('ws')
const http = require('http')
const wss = new WebSocket.Server({ noServer: true })
const server = http.createServer()
const jwt = require('jsonwebtoken')

const timeInterval = 1000
// 多聊天室的功能
// roomid -> 对应相同的roomid进行广播消息
let group = {}

wss.on('connection', function connection(ws) {
// 初始的心跳连接状态
ws.isAlive = true

console.log('one client is connected')
// 接收客户端的消息
ws.on('message', function (msg) {
const msgObj = JSON.parse(msg)
if (msgObj.event === 'enter') {
ws.name = msgObj.message
ws.roomid = msgObj.roomid
if (typeof group[ws.roomid] === 'undefined') {
group[ws.roomid] = 1
} else {
group[ws.roomid]++
}
}
// 心跳检测
if (msgObj.event === 'heartbeat' && msgObj.message === 'pong') {
ws.isAlive = true
return
}

// 主动发送消息给客户端
// ws.send('server:' + msg)
// 广播消息
wss.clients.forEach((client) => {
// 判断非自己的客户端
if (client.readyState === WebSocket.OPEN && client.roomid === ws.roomid) {
msgObj.name = ws.name
msgObj.num = group[ws.roomid]
client.send(JSON.stringify(msgObj))
}
})
})

// 当ws客户端断开链接的时候
ws.on('close', function () {
if (ws.name) {
group[ws.roomid]--
}
let msgObj = {}
// 广播消息
wss.clients.forEach((client) => {
// 判断非自己的客户端
if (client.readyState === WebSocket.OPEN && ws.roomid === client.roomid) {
msgObj.name = ws.name
msgObj.num = group[ws.roomid]
msgObj.event = 'out'
client.send(JSON.stringify(msgObj))
}
})
})
})

server.on('upgrade', function upgrade(request, socket, head) {
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request)
})
})

server.listen(3000)

setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive && ws.roomid) {
group[ws.roomid]--
delete ws['roomid']
return ws.terminate()
}
// 主动发送心跳检测请求
// 当客户端返回了消息之后,主动设置flag为在线
ws.isAlive = false
ws.send(
JSON.stringify({
event: 'heartbeat',
message: 'ping',
num: group[ws.roomid],
})
)
})
}, timeInterval)

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<script src="https://cdn.staticfile.org/vue/2.6.11/vue.min.js"></script>
</head>

<body>
<div id="app">
<div v-if="isShow">
<p>昵称:<input type="text" v-model="name" /></p>
<p>房间号:<input type="text" v-model="roomid" /></p>
<button type="button" @click="enter()">进入聊天室</button>
</div>
<div v-else>
<ul>
<li v-for="(item,index) in lists" :key="'message' + index">
{{item}}
</li>
<li>在线人数{{num}}</li>
</ul>
<div class="ctrl">
<input type="text" v-model="message" />
<button type="button" @click="send()">按钮</button>
</div>
</div>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
message: '',
lists: [],
ws: {},
name: '',
isShow: true,
num: 0,
roomid: '',
handle: {},
},
mounted() {},
methods: {
init() {
this.ws = new WebSocket('ws://127.0.0.1:3000')
this.ws.onopen = this.onOpen
this.ws.onmessage = this.onMessage
this.ws.onclose = this.onClose
this.ws.onerror = this.onError
},
enter() {
if (this.name.trim() === '') {
alert('用户名不得为空')
return
}
this.init()
this.isShow = false
},
onOpen: function () {
this.ws.send(
JSON.stringify({
event: 'enter',
message: this.name,
roomid: this.roomid,
})
)
},
onMessage: function (event) {
// 当用户未进入聊天室,则不接收消息
if (this.isShow) {
return
}
// 接收服务端发送过来的消息
var obj = JSON.parse(event.data)
switch (obj.event) {
case 'noauth':
// 鉴权失败
// 路由跳转到 /login 重新获取token
break
case 'enter':
// 当有一个新的用户进入聊天室
this.lists.push('欢迎:' + obj.message + '加入聊天室!')
break
case 'out':
this.lists.push(obj.name + '已经退出了聊天室!')
break
case 'heartbeat':
this.checkServer() // timeInterval + t
this.ws.send(
JSON.stringify({
event: 'heartbeat',
message: 'pong',
})
)
break
default:
if (obj.name !== this.name) {
// 接收正常的聊天
this.lists.push(obj.name + ':' + obj.message)
}
}
this.num = obj.num
},
onClose: function () {
// 当链接主动断开的时候触发close事件
console.log('close:' + this.ws.readyState)
console.log('已关闭websocket')
this.ws.close()
},
onError: function () {
// 当连接失败时,触发error事件
console.log('error:' + this.ws.readyState)
console.log('websocket连接失败!')
// 连接失败之后,1s进行断线重连!
var _this = this
setTimeout(function () {
_this.init()
}, 1000)
},
// 发送消息
send: function () {
this.lists.push(this.name + ':' + this.message)
this.ws.send(
JSON.stringify({
event: 'message',
message: this.message,
name: this.name,
})
)
this.message = ''
},
checkServer: function () {
var _this = this
clearTimeout(this.handle)
this.handle = setTimeout(function () {
_this.onClose()
_this.init()
}, 1000 + 500)
},
},
})
</script>
</body>
</html>