一、产生背景
在Web开发中,我们经常和HTTP协议打交道,它是一个请求-响应的通信方式。但是除了HTTP 2.0版本以外,服务器端是无法主动发送消息的,它只支持单向数据通信的方式。
如果要去实现一个以双向通信方式的简易聊天室,浏览器需要以一种轮询的方式去不停地发送AJAX请求更新数据,这样做会造成资源高消耗和低性能。于是WebSocket协议就这么诞生了,虽然它诞生的时间比HTTP协议晚,但它实现的功能比HTTP更加成熟和可靠。
WebSocket最初在HTML5规范中被引用为TCPConnection,作为基于TCP的套接字API的占位符。2008年6月,Michael Carter进行了一系列讨论,最终形成了称为WebSocket的协议。
二、特点
WebSocket是一种网络传输协议,可在单个TCP连接上进行全双工通信,位于OSI模型的应用层。
补充一个小知识:TCP上的连接通信分为半双工通信、全双工通信、单向通信三种方式。
目前大部分浏览器均已支持WebSocket,可以放心使用。
它主要特点如下:
- 较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小,而HTTP协议每次请求需要携带完整的头部。
- 更强的实时性。支持全双工通信,低消耗,高性能。
- 可以保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。
- 更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。
- 支持跨域。(这个太强了!)
- 与 HTTP 协议有着良好的兼容性。默认端口也是80(ws)和443(wss),并且握手阶段采用 HTTP 协议中的
Upgrade头来升级协议,支持HTTP代理和中介。 - 可以支持扩展。Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等。
- 更好的压缩效果。相对于HTTP压缩,Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。
思考:从上面的特点可以看出,同样是应用层上的协议,WebSocket协议要比HTTP协议要优秀很多。所以,数据传输都用WebSocket协议它不香吗?(没得标准答案,我自己也要思考。我没有歧视HTTP协议的意思啊...😝)
三、客户端API介绍
WebSocket提供了用于创建和管理 WebSocket 连接以及可以通过该连接发送和接收数据的 API。
我们可以通过创建一个实例对象ws建立与本地服务器(localhost:8080)的WebSocket连接:
let ws = new WebSocket('ws://localhost:8080')
下面的案例和知识都会围绕这个实例对象来展开讲述。
3.1 常量
下表是WebSocket构造函数的原型中存在的一些常量介绍:
| Constant | Value |
|---|---|
| WebSocket.CONNECTING(正在连接中) | 0 |
| WebSocket.OPEN(连接已打开,可以通信) | 1 |
| WebSocket.CLOSING (连接正在关闭) | 2 |
| WebSocket.CLOSED (表示连接已经关闭,或者打开连接失败。) | 3 |
WebSocket和XMLHttpRequest对象很相似,它们都可以通过readyState属性来判断当前连接状态(XMLHttpRequest对象有5个状态)。
if (ws.readyState === WebSocket.OPEN) {
// 连接建立后做点啥...
}
3.2 常见属性
WebSocket.bufferedAmount:是一个只读属性,用于返回已经被send方法放入队列中但还没有被发送到网络中的数据的字节数。一旦队列中的所有数据被发送至网络,则该属性值将被重置为0。但是,若在发送过程中连接被关闭,则属性值不会重置为0。它可以用来判断二进制数据发送是否结束。WebSocket.onclose:用于指定连接关闭后的回调函数。WebSocket.onerror:用于指定连接失败后的回调函数。WebSocket.onmessage:用于指定当从服务器接受到信息时的回调函数。WebSocket.onopen:用于指定连接成功后的回调函数。WebSocket.readyState: 只读属性,表示当前的连接状态。
3.3 方法
WebSocket.send:发送数据。数据类型只能是utf-8编码的字符串和二进制数据(Blob或ArrayBuffer)这两种。WebSocket.close:关闭连接。
这里补充一个小知识:Blob对象和ArrayBuffer对象的区别在于前者用于操作二进制文件,而后者用于操作内存(模拟内存)。如果大家对这个小知识感兴趣可以去看这篇文章。
结合上述提及的WebSocket的属性和方法,我给出一个浏览器端简单使用WebSocket的例子:
demo.html
<!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>
</head>
<body>
<input type="file" name="filename" />
<button>关闭连接</button>
<script>
let ws = new WebSocket("ws://localhost:8080");
let buffer = new ArrayBuffer(8);
let file = document.querySelector('input[type="file"]');
let btn = document.querySelector("button");
let blob = null;
// 1. 可以自己指定接受二进制数据类型
ws.binaryType = "arraybuffer" || "blob";
// 2. 可获取二进制数据占用内存长度(字节大小)
console.log(buffer.byteLength); // 8 bytes
// 3. 使用Blob读取二进制文件数据
file.addEventListener("change", function(event) {
blob = new Blob(this.files, { type: "mutipart/form-data" });
});
// 4. 点击按钮关闭连接
btn.addEventListener("click", function() {
ws.close();
});
// 5. 监听连接建立事件
ws.addEventListener("open", function(event) {
console.log("连接已建立");
// 5.1 发送不同类型数据
ws.send(buffer);
ws.send(blob);
ws.send("send message...");
// 5.2 判断二进制数据是否完全发送
if (ws.bufferedAmount === 0) {
console.log("发送完毕!");
} else {
console.log("还没发送成功!");
}
});
// 6. 监听接受信息事件
ws.addEventListener("message", function({ data }) {
// 6.1 可以对数据类型先进行判断,然后对数据进行分类处理
if (typeof data === "string") {
console.log("Received data string" + data);
}
if (data instanceof ArrayBuffer) {
console.log("Received arraybuffer");
}
if (data instanceof Blob) {
console.log("Received blob");
}
});
// 7. 监听连接关闭事件
ws.addEventListener("close", function(event) {
console.log("连接关闭了...");
});
// 8. 监听错误事件
ws.addEventListener("error", function(event) {
console.log("连接出错了...");
});
</script>
</body>
</html>
四、Node服务器应用WebSocket
上面已经提到:WebSocket协议是基于底层TCP协议的,而且客户端需要借助HTTP发起一个请求,服务器通过请求头中的Upgrade字段来完成协议升级。所以接下来我会使用Node中的net模块来建立一个TCP服务器来实现这个过程。
客户端
const ws = new WebSocket('ws://localhost:8080')
ws.addEventListener('open', function() {
console.log('连接已打开!')
})
服务端
引入net模块和crypto模块(后面使用sha1来加密处理key需要crypto模块),然后建立一个TCP服务器,监听8080端口。当连接成功后(自己监听connection事件)会触发一个回调函数,该函数有一个参数为socket,它是Socket对象的实例,也是TCP套接字的抽象。
补充知识点:TCP套接字是客户端和服务端通信的基础,由ip+端口号组成。
const net = require('net')
const crypto = require('crypto')
net.createServer(socket => {
// socket
}).listen(8080, () => {
console.log('TCP服务器已经启动在8080')
})
然后监听socket的data事件,拿到buffer数据后转化为字符串。
socket.on('data', buffer => {
console.log(buffer.toString())
})
我们启动一下服务器,然后打印一下HTTP头:

我们可以看到它和正常HTTP请求相比多了几个比较重要的头部字段,我在下方列了一个表详细地说明了它们的作用:
| 请求头字段 | 作用 |
|---|---|
| Sec-WebSocekt-Extensions | 拓展信息,可以忽略 |
| Sec-WebSocket-Key | 随机的字符串。这段字符串只有支持WebSocket协议的服务器才理解,这样可以判断服务器是否支持WebSocket协议,同时通过一些算法返回同样类型的字符串来回应。 |
| Sec-WebSocket-Version | 协议版本,目前还是13 |
| Upgrade | 请求升级的协议类型,这里为websocket |
当然,还有一个Connection字段,表明此次连接时协议升级(Upgrade )。
服务器需要对这些字段进行判断,并且返回一些响应头,告诉客户端协议升级成功了,并且连接已经建立。所以,下一步,服务器就需要解析请求头,这里我定义了一个parseHeaders函数解析成一个对象:
function parseHeaders(headers) {
console.log(headers)
let obj = {}
// 将每一行字符串以`\r\n`分隔,并去掉空行
let arr = headers.split('\r\n').filter(line => line)
// 去掉请求行
arr.shift()
arr.forEach(item => {
let [name, value] = item.split(': ')
obj[name.toLowerCase()] = value
})
return obj
}
let headers = parseHeaders(buffer.toString())
注意:由于客户端没有传递其他二进制数据过来,服务端拿到的是HTTP头部字段,因此可以转为字符串。但是如果有数据过来,就不可以直接转化字符串。
此时我们拿到的头长这样:

Sec-websocket-Acceptkey,这个key必须是官方规定的key校验生成算法:
把Sec-WebSocket-Key加上一个特殊字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,然后计算SHA-1摘要,之后进行BASE-64编码,将结果做为Sec-WebSocket-Accept头的值,返回给客户端。
这里使用node中的crypto模块处理。
if (headers['upgrade'] !== 'websocket') {
console.log('invalid upgrade value')
socket.end()
} else if (headers['sec-websocket-version'] !== '13') {
console.log('invalid version')
socket.end()
} else {
// 校验算法 base64(sha1(key+uuid))
const key = headers['sec-websocket-key']
const uuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
let hash = crypto.createHash('sha1')
hash.update(key + uuid)
let resKey = hash.digest('base64')
// 返回一个响应头,完成协议升级
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\rSec-websocket-Accept: ${resKey}\r\n\r\n`)
}
启动Node服务器测试一下:

发现连接成功了,然后打开控制台看看响应头:

WebSocket 了,可以进行数据交互了。这还只是客户端和服务器建立连接,服务器还需要做很多很多事情,如怎么处理二进制数据,如何缓存等等,超级复杂,自己去做这些事情简直就是找die🌚。所以,接下来我们肯定需要一个很优秀的库:socket.io。
五、使用socket.io库
我们先来介绍这个库,这个库是对WebSocket的一个封装,实现了很多原始没有的功能,比如超时重传、断开后状态缓存、数据解析、兼容低版本浏览器、自动重连等功能,它十分强大而且使用很简单。下面教大家如何简单使用这个库:
本地安装
npm i socket.io -D
步骤
- 引入http模块,建立http服务
- 引入
socket.io库,监听http服务,三次握手后建立Websocket连接 - 建立是否连接,设置连接成功后的回调函数,参数设为socket,参数是一个socket server对象
- 在回调函数中可以发送数据和接受数据
const http = require('http')
const io = require('socket.io')
// 1. 先建立http服务
let server = http.createServer().listen(8080)
// 2. 转化协议,将http请求转化成websocket
let ws = io.listen(server)
// 3. 建立websocket连接,可以通信,emit 发数据, on 监听数据
ws.on('connection', socket => {
setInterval(() => {
socket.emit('sayHi', 'hi,laocao')
}, 1000)
socket.on('meet', data => {
console.log(data)
})
})
在客户端(这里使用浏览器)也必须引入socket.io库
<script src="http://localhost:8080/socket.io/socket.io.js"></script>
然后使用io对象(这是一个封装好的WebSocket对象)来发起连接,然后通过on方法接收数据,使用emit发送数据。
注意的是,两个方法第一个参数必须要和服务端的对应起来,我这里为了简单就发送了一个简单的字符串。
let ws = io.connect('ws://localhost:8080')
ws.on('sayHi', data => {
console.log(data)
})
ws.emit('meet', 'hi,nice to meet you!')
我们打开服务器控制台看看:


六、总结
本文我简单介绍了WebSocket的产生背景、特点以及API的简单应用。我们可以看到,使用原生的WebSocket是很麻烦的,还要考虑很多东西,比如断开连接了如何重连,状态如何保持等问题等。在实际开发中我们还是得借助现在已经很成熟的库如socket.io,这样开发效率才会高。当然,为了更好去深入学习这些东西,还是得看原生的知识,这样才能走得更远。
我希望这个入门级的介绍对大家有帮助,当然最好的方式就是看文档,我也是找了很多资料进行了学习。当然,光学理论是没得用的,接下来我会找个聊天室的项目来练练手,之后也会总结一篇文章。如果本文章中存在错误,恳请大家可以帮忙纠正,感激不尽!😀