一、纯前端
可能有很多的同学有用 setInterval 控制 ajax 不断向服务端请求最新数据的经历看下面的代码:
setInterval(function() {
$.get('/get/data-list', function(data, status) {
console.log(data)
})
}, 5000)
这样每隔5秒前端会向后台请求一次数据,实现上看起来很简单但是有个很重要的问题,就是我们没办法控制网速的稳定,不能保证在下次发请求的时候上一次的请求结果已经顺利返回,这样势必会有隐患,有聪明的同学马上会想到用 setTimeout 配合递归看下面的代码:
function poll() {
setTimeout(function() {
$.get('/get/data-list', function(data, status) {
console.log(data)
poll()
})
}, 5000)
}
当结果返回之后再延时触发下一次的请求,这样虽然没办法保证两次请求之间的间隔时间完全一致但是至少可以保证数据返回的节奏是稳定的,看似已经实现了需求但是这么搞我们先不去管他的性能就代码结构也算不上优雅,为了解决这个问题可以让服务端长时间和客户端保持连接进行数据互通h5新增了 WebSocket 和 EventSource 用来实现长轮询,下面我们来分析一下这两者的特点以及使用场景。
二、基础
为什么需要websocket? 疑问? 我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?
答:
- 因为 HTTP 协议有一个缺陷:通信只能由客户端发起
- 我们都知道轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开), 因此websocket应运而生。
1、WebSocket
js跨域的解决方案_js跨域问题的三种解决方案-CSDN博客
是什么: WebSocket是一种通讯手段,基于TCP协议,默认端口也是80和443,协议标识符是ws(加密为wss),它实现了浏览器与服务器的全双工通信,扩展了浏览器与服务端的通信功能,使服务端也能主动向客户端发送数据,不受跨域的限制(需后端配合)。
WebSocket 的其他特点包括:
(1)建立在 TCP 协议之上,服务器端的实现比较容易。
(2)与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
(3)数据格式比较轻量,性能开销小,通信高效。
(4)可以发送文本,也可以发送二进制数据。
(5)没有同源限制,客户端可以与任意服务器通信。
(6)协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。
有什么用:WebSocket用来解决http不能持久连接的问题,因为可以双向通信所以可以用来实现聊天室,以及其他由服务端主动推送的功能例如 在线聊天、实时数据更新、实时天气、股票报价、余票显示、消息通知、大屏数据动态图等。
WebSocket协议
本协议有两部分:握手和数据传输。
握手是基于http协议的。
与http协议一样,WebSocket协议也需要通过已建立的TCP连接来传输数据。具体实现上是通过http协议建立通道,然后在此基础上用真正的WebSocket协议进行通信,所以WebSocket协议和http协议是有一定的交叉关系的。
(1)来自客户端的握手看起来像如下形式:
GET ws://localhost/chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat,superchat
Sec-WebSocket-Version: 13
熟悉 HTTP 可能发现了,这段类似 HTTP 协议的握手请求中,多了这么几个东西。
Upgrade: websocket
Connection: Upgrade
这个就是 WebSocket 的核心了,告诉 Apache 、 Nginx 等服务器:注意啦,我发起的请求要用 WebSocket 协议,快点帮我找到对应的助理处理~而不是那个老土的 HTTP 。
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 18
首先, Sec-WebSocket-Key 是一个 Base64 encode 的值,这个是浏览器随机生成的,告诉服务器:泥煤,不要忽悠我,我要验证你是不是真的是 WebSocket 助理。
然后, Sec_WebSocket-Protocol 是一个用户定义的字符串,用来区分同 URL 下,不同的服务所需要的协议。简单理解:今晚我要服务A,别搞错啦~
最后, Sec-WebSocket-Version 是告诉服务器所使用的 WebSocket Draft (协议版本),在最初的时候,WebSocket 协议还在 Draft 阶段,各种奇奇怪怪的协议都有,而且还有很多期奇奇怪怪不同的东西,什么 Firefox 和 Chrome 用的不是一个版本之类的,当初 WebSocket 协议太多可是一个大难题。。不过现在还好,已经定下来啦~大家都使用同一个版本:服务员,我要的是18岁的噢→_→然后服务器会返回下列东西,表示已经接受到请求, 成功建立 WebSocket 啦!
(2) 来自服务器的握手看起来像如下形式
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept:s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
这里开始就是 HTTP 最后负责的区域了,告诉客户,我已经成功切换协议啦~
Upgrade: websocket
Connection: Upgrade
依然是固定的,告诉客户端即将升级的是 WebSocket 协议,而不是 mozillasocket,lurnarsocket 或者 shitsocket。
然后, Sec-WebSocket-Accept 这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key 。服务器:好啦好啦,知道啦,给你看我的 ID CARD 来证明行了吧。
后面的, Sec-WebSocket-Protocol 则是表示最终使用的协议。至此,HTTP 已经完成它所有工作了,接下来就是完全按照 WebSocket 协议进行了。
原理:
WebSocket是一种在单个TCP连接上进行全双工通信的协议。它通过一个简单的握手过程来建立连接,然后在连接上进行双向数据传输。与传统的HTTP请求不同,WebSocket连接一旦建立,就可以在客户端和服务器之间保持打开状态,直到被任何一方关闭。
WebSocket协议的核心特点包括:
全双工通信:客户端和服务器可以同时发送和接收消息。
持久连接:一旦建立连接,就可以持续进行数据交换,无需像HTTP那样频繁地建立新的连接。
低延迟:由于连接是持久的,数据可以几乎实时地发送和接收。
轻量级协议:WebSocket协议的头部信息非常简单,减少了数据传输的开销。
阐述 WebSocket 与 HTTP 请求的区别
- 通信方式:HTTP 是基于请求/响应模式的,而 WebSocket 是基于持久连接的双向通信。
- 协议开销:HTTP 每次通信都需要进行 TCP 三次握手和四次挥手,而 WebSocket 只需要一次握手即可保持连接。
在WebSocket的前端,直接修改HTTP头部并不总是可行的,因为WebSocket的客户端API设计通常不允许这样做。然而,可以通过一些间接的方法来实现类似的效果。以下是两种常用的方法:
使用Sec-WebSocket-Protocol
属性
控制台查看
(1)打开目标页面
(2)找到ws连接:
f12--》network,然后刷新页面( 如果不刷页面,就会看不到 websocket 请求,因为 websocket 是长连接,页面加载后只发出一次连接请求,不像 http 接口,不用刷新页面,待会儿也能看到,因为 http 接口是短连接,调用一次发出一次请求 );
也可以先f12再打开网址
或者直接筛选WS的消息:
(3)查看消息
点击这个ws,在message的tab中可以看到所有消息:
向上的箭头表示client向服务端发送,向下的箭头表示接收到服务端的消息。
(4)清空消息:
(5)关闭网页
则已连接的WS会断开连接,消息推送失败。
WebSocket 连接通常会在以下几种情况下断开:
- 正常关闭:
客户端或服务器主动关闭连接:WebSocket协议支持通过发送关闭帧来正常关闭连接。无论是客户端还是服务器都可以发起关闭请求,并且另一方会回应关闭帧,完成连接的关闭。 - 网络问题:
网络中断:如果客户端或服务器的网络连接断开(例如,网络不可达或无线信号丢失),WebSocket连接也会中断。 - 服务器异常:
服务器崩溃或重启:如果WebSocket服务器出现故障或重启,连接会被迫中断。 - 客户端异常:
客户端崩溃或退出:如果WebSocket客户端应用程序崩溃或退出,连接会被关闭。 - 时间超时:
长时间没有数据交换:某些WebSocket实现会设置“心跳”机制,定期发送ping/pong消息以保持连接活动。如果超过一段时间没有任何通信,服务器或客户端可能会认为连接已经失效,从而主动断开连接。 - 服务器关闭或拒绝连接:
服务器主动拒绝或关闭连接:如果服务器因负载过高、策略限制或其他原因决定关闭WebSocket连接,它可以发送一个关闭帧,或者直接终止连接。 - 协议错误:
数据帧格式错误:如果在数据交换过程中发生协议错误(如接收到格式不正确的数据帧),服务器或客户端可能会主动断开连接以避免进一步的错误。
2、EventSource
是什么: EventSource的官方名称应该是 Server-sent events(缩写SSE)服务端派发事件,EventSource 基于http协议只是简单的单项通信,实现了服务端推的过程客户端无法通过EventSource向服务端发送数据。喜闻乐见的是ie并没有良好的兼容当然也有解决的办法比如 npm install event-source-polyfill
。虽然不能实现双向通信但是在功能设计上他也有一些优点比如可以自动重连接,event IDs,以及发送随机事件的能力(WebSocket要借助第三方库比如socket.io可以实现重连。)
有什么用: 因为受单项通信的限制EventSource只能用来实现像股票报价、新闻推送、实时天气这些只需要服务器发送消息给客户端场景中。EventSource的使用更加便捷这也是他的优点。
1.1 协议层深度解构
SSE的本质是一个基于HTTP/1.1+的持久化文本流协议,其核心技术特征:
- 单向通道:仅支持Server→Client的单向通信(符合90%推送场景需求)
- 轻量协议头:相比WebSocket的复杂握手,SSE仅需标准HTTP头
GET /stream HTTP/1.1 Host: example.com Accept: text/event-stream Cache-Control: no-cache Connection: keep-alive
- 消息格式化:强制使用
data:
前缀的事件流格式
data: {"price": 1499}\n\n id: 42\n event: stockUpdate\n data: {"symbol": "TSLA"}\n\n
1.2 连接生命周期管理
SSE通过三个核心机制实现可靠通信:
- 自动重连:浏览器内置重试逻辑(默认3秒间隔)
- 事件ID追踪:通过Last-Event-ID头实现消息连续性
- 心跳维持:通过注释行保持连接活性
: 心跳ping\n
data: keepalive\n\n
WebSocket & EventSource 的区别
- WebSocket基于TCP协议,EventSource基于http协议。
- EventSource是单向通信,而websocket是双向通信。
- EventSource只能发送文本,而websocket支持发送二进制数据/文本。
- 在实现上EventSource比websocket更简单。
- EventSource有自动重连接(不借助第三方)以及发送随机事件的能力。
- websocket的资源占用过大EventSource更轻量。
- websocket可以跨域,EventSource基于http跨域需要服务端设置请求头。
3、总结
EventSource (SSE)
- 单向通信:SSE 是一种服务器向客户端推送数据的机制,只能实现从服务器到客户端的单向通信。
- 简单性:相比 WebSocket,SSE 更加简单易用,不需要额外的握手过程,只需要一个标准的 HTTP 请求。
- 自动重连:SSE 内置了重连机制,当连接断开时可以自动尝试重新建立连接。
- 文本数据:SSE 只支持文本数据的传输,通常是以 UTF-8 编码的。
- HTTP 协议:基于 HTTP/1.1 协议,可以很好地与现有的 HTTP 基础设施集成。
- 适用场景:适用于需要服务器向客户端发送更新或通知的场景,如股票价格更新、新闻推送等。
WebSocket
- 双向通信:WebSocket 支持全双工通信,允许客户端和服务器之间互相发送数据。
- 复杂性:WebSocket 需要一个特殊的握手过程来建立连接,比 SSE 更加复杂。
- 数据类型:支持二进制数据和文本数据的传输,更加灵活。
- 协议独立:虽然 WebSocket 通常通过 HTTP 进行初始握手,但一旦连接建立,它就不再受限于 HTTP,可以用于任何类型的网络应用。
- 适用场景:适用于需要频繁双向通信的应用,如在线游戏、实时聊天应用等。
- 发送get请求,状态码是101 Switching Protocols ,轮询的事件间隔是后端定义的
如何选择
- 如果应用需要简单的服务器到客户端的数据推送,且对数据格式没有特殊要求(即可以接受文本格式),那么 SSE 是一个轻量级且易于实现的选择。
- 如果应用需要更复杂的双向通信,或者需要传输二进制数据,那么 WebSocket 更适合,尽管它的实现会相对复杂一些。
- 考虑现有基础设施:如果你的应用已经大量依赖于 HTTP 协议,那么使用 SSE 可能会更加方便,因为它本身就是基于 HTTP 的。相反,如果你的应用需要更强大的功能,WebSocket 提供了更多的可能性。
三、案例
1、EventSource的实现案例
不知道大家有没有见过 Content-Type:text/event-stream
的请求头,这是 HTML5
中的 EventSource
是一项强大的 API
,通过服务器推送实现实时通信。
客户端代码
// 实例化 EventSource 参数是服务端监听的路由
var source = new EventSource('/EventSource-test')
source.onopen = function (event) { // 与服务器连接成功回调
console.log('成功与服务器连接')
}
// 监听从服务器发送来的所有没有指定事件类型的消息(没有event字段的消息)
source.onmessage = function (event) { // 监听未命名事件
console.log('未命名事件', event.data)
}
source.onerror = function (error) { // 监听错误
console.log('错误')
}
// 监听指定类型的事件(可以监听多个)
source.addEventListener("myEve", function (event) {
console.log("myEve", event.data)
})
服务端代码(node.js)
const fs = require('fs')
const express = require('express') // npm install express
const app = express()
// 启动一个简易的本地server返回index.html
app.get('/', (req, res) => {
fs.stat('./index.html', (err, stats) => {
if (!err && stats.isFile()) {
res.writeHead(200)
fs.createReadStream('./index.html').pipe(res)
} else {
res.writeHead(404)
res.end('404 Not Found')
}
})
})
// 监听EventSource-test路由服务端返回事件流
app.get('/EventSource-test', (ewq, res) => {
// 根据 EventSource 规范设置报头
res.writeHead(200, {
"Content-Type": "text/event-stream", // 规定把报头设置为 text/event-stream
"Cache-Control": "no-cache" // 设置不对页面进行缓存
})
// 用write返回事件流,事件流仅仅是一个简单的文本数据流,每条消息以一个空行(\n)作为分割。
res.write(':注释' + '\n\n') // 注释行
res.write('data:' + '消息内容1' + '\n\n') // 未命名事件
res.write( // 命名事件
'event: myEve' + '\n' +
'data:' + '消息内容2' + '\n' +
'retry:' + '2000' + '\n' +
'id:' + '12345' + '\n\n'
)
setInterval(() => { // 定时事件
res.write('data:' + '定时消息' + '\n\n')
}, 2000)
})
// 监听 6788
app.listen(6788, () => {
console.log(`server runing on port 6788 ...`)
})
客户端访问 http://127.0.0.1:6788/
会看到如下的输出:
来总结一下相关的api,客户端的api很简单都在注释里了,服务端有一些要注意的地方:
事件流格式?
事件流仅仅是一个简单的文本数据流,文本应该使用UTF-8格式的编码。每条消息后面都由一个空行作为分隔符。以冒号开头的行为注释行,会被忽略。
注释有何用?
注释行可以用来防止连接超时,服务器可以定期发送一条消息注释行,以保持连接不断。
EventSource规范中规定了那些字段?
event:
事件类型,如果指定了该字段,则在客户端接收到该条消息时,会在当前的EventSource对象上触发一个事件,事件类型就是该字段的字段值,你可以使用addEventListener()方法在当前EventSource对象上监听任意类型的命名事件,如果该条消息没有event字段,则会触发onmessage属性上的事件处理函数。
data:
消息的数据字段,如果该条消息包含多个data字段,则客户端会用换行符把它们连接成一个字符串来作为字段值。
id:
事件ID,会成为当前EventSource对象的内部属性"最后一个事件ID"的属性值。
retry:
一个整数值,指定了重新连接的时间(单位为毫秒),如果该字段值不是整数,则会被忽略。
重连是干什么的?
上文提过retry字段是用来指定重连时间的,那为什么要重连呢,我们拿node来说,大家知道node的特点是单线程异步io,单线程就意味着如果server端报错那么服务就会停掉,当然在node开发的过程中会处理这些异常,但是一旦服务停掉了这时就需要用pm2之类的工具去做重启操作,这时候server虽然正常了,但是客户端的EventSource链接还是断开的这时候就用到了重连。
为什么案例中消息要用\n结尾?
\n是换行的转义字符,EventSource规范规定每条消息后面都由一个空行作为分隔符,结尾加一个\n表示一个字段结束,加两个\n表示一条消息结束。(两个\n表示换行之后又加了一个空行)
注: 如果一行文本中不包含冒号,则整行文本会被解析成为字段名,其字段值为空。
2、WebSocket的实现案例
WebSocket的客户端原生api
// WebSocket 对象作为一个构造函数,用于新建 WebSocket 实例
var ws = new WebSocket('ws://localhost:8080')
// 用于指定连接成功后的回调函数
ws.onopen = function(){}
// 用于指定连接关闭后的回调函数
ws.onclose = function(){}
// 用于指定收到服务器数据后的回调函数
ws.onmessage = function(){}
// 实例对象的send()方法用于向服务器发送数据
ws.send('data')
// 用于指定报错时的回调函数
socket.onerror = function(){}
npm上有很多包对websocket做了实现比如 socket.io、WebSocket-Node、ws、还有很多,本文只对 socket.io以及ws 做简单的分析,细节还请查看官方文档。
socket.io和ws有什么不同
Socket.io:
Socket.io是一个WebSocket库,包括了客户端的js和服务器端的nodejs,它会自动根据浏览器从WebSocket、AJAX长轮询、Iframe流等等各种方式中选择最佳的方式来实现网络实时应用(不支持WebSocket的情况会降级到AJAX轮询),非常方便和人性化,兼容性非常好,支持的浏览器最低达IE5.5。屏蔽了细节差异和兼容性问题,实现了跨浏览器/跨设备进行双向数据通信。
ws:
不像 socket.io 模块, ws 是一个单纯的websocket模块,不提供向上兼容,不需要在客户端挂额外的js文件。在客户端不需要使用二次封装的api使用浏览器的原生Websocket API即可通信。
基于socket.io实现WebSocket双向通信
客户端代码
<button id="closeSocket">断开连接</button>
<button id="openSocket">恢复连接</button>
<script src="/socket.io/socket.io.js"></script>
<script>
// 建立连接 默认指向 window.location
let socket = io('http://127.0.0.1:6788')
openSocket.onclick = () => {
socket.open() // 手动打开socket 也可以重新连接
}
closeSocket.onclick = () => {
socket.close() // 手动关闭客户端对服务器的链接
}
socket.on('connect', () => { // 连接成功
// socket.id是唯一标识,在客户端连接到服务器后被设置。
console.log(socket.id)
})
socket.on('connect_error', (error) => {
console.log('连接错误')
})
socket.on('disconnect', (timeout) => {
console.log('断开连接')
})
socket.on('reconnect', (timeout) => {
console.log('成功重连')
})
socket.on('reconnecting', (timeout) => {
console.log('开始重连')
})
socket.on('reconnect_error', (timeout) => {
console.log('重连错误')
})
// 监听服务端返回事件
socket.on('serverEve', (data) => {
console.log('serverEve', data)
})
let num = 0
setInterval(() => {
// 向服务端发送事件
socket.emit('feEve', ++num)
}, 1000)
服务端代码(node.js)
const app = require('express')()
const server = require('http').Server(app)
const io = require('socket.io')(server, {})
// 启动一个简易的本地server返回index.html
app.get('/', (req, res) => {
res.sendfile(__dirname + '/index.html')
})
// 监听 6788
server.listen(6788, () => {
console.log(`server runing on port 6788 ...`)
})
// 服务器监听所有客户端 并返回该新连接对象
// 每个客户端socket连接时都会触发 connection 事件
let num = 0
io.on('connection', (socket) => {
socket.on('disconnect', (reason) => {
console.log('断开连接')
})
socket.on('error', (error) => {
console.log('发生错误')
})
socket.on('disconnecting', (reason) => {
console.log('客户端断开连接但尚未离开')
})
console.log(socket.id) // 获取当前连接进入的客户端的id
io.clients((error, ids) => {
console.log(ids) // 获取已连接的全部客户机的ID
})
// 监听客户端发送的事件
socket.on('feEve', (data) => {
console.log('feEve', data)
})
// 给客户端发送事件
setInterval(() => {
socket.emit('serverEve', ++num)
}, 1000)
})
/*
io.close() // 关闭所有连接
*/
const io = require('socket.io')(server, {})
第二个参数是配置项,可以传入如下参数:
- path: '/socket.io' 捕获路径的名称
- serveClient: false 是否提供客户端文件
- pingInterval: 10000 发送消息的时间间隔
- pingTimeout: 5000 在该时间下没有数据传输连接断开
- origins: '*' 允许跨域
- ...
上面基于socket.io的实现中 express
做为socket通信的依赖服务基础
socket.io
作为socket通信模块,实现了双向数据传输。最后,需要注意的是,在服务器端 emit
区分以下三种情况:
socket.emit()
:向建立该连接的客户端发送socket.broadcast.emit()
:向除去建立该连接的客户端的所有客户端发送io.sockets.emit()
:向所有客户端发送 等同于上面两个的和io.to(id).emit()
: 向指定id的客户端发送事件
基于ws实现WebSocket双向通信
客户端代码:
let num = 0
let ws = new WebSocket('ws://127.0.0.1:6788')
ws.onopen = (evt) => {
console.log('连接成功')
setInterval(() => {
ws.send(++ num) // 向服务器发送数据
}, 1000)
}
ws.onmessage = (evt) => {
console.log('收到服务端数据', evt.data)
}
ws.onclose = (evt) => {
console.log('关闭')
}
ws.onerror = (evt) => {
console.log('错误')
}
closeSocket.onclick = () => {
ws.close() // 断开连接
}
服务端代码(node.js):
const fs = require('fs')
const express = require('express')
const app = express()
// 启动一个简易的本地server返回index.html
const httpServer = app.get('/', (req, res) => {
res.writeHead(200)
fs.createReadStream('./index.html').pipe(res)
}).listen(6788, () => {
console.log(`server runing on port 6788 ...`)
})
// ws
const WebSocketServer = require('ws').Server
const wssOptions = {
server: httpServer,
// port: 6789,
// path: '/test'
}
const wss = new WebSocketServer(wssOptions, () => {
console.log(`server runing on port ws 6789 ...`)
})
let num = 1
wss.on('connection', (wsocket) => {
console.log('连接成功')
wsocket.on('message', (message) => {
console.log('收到消息', message)
})
wsocket.on('close', (message) => {
console.log('断开了')
})
wsocket.on('error', (message) => {
console.log('发生错误')
})
wsocket.on('open', (message) => {
console.log('建立连接')
})
setInterval(() => {
wsocket.send( ++num )
}, 1000)
})
上面代码中在 new WebSocketServer
的时候传入了 server: httpServer
目的是统一端口,虽然 WebSocketServer 可以使用别的端口,但是统一端口还是更优的选择,其实express并没有直接占用6788端口而是express调用了内置http模块创建了http.Server监听了6788。express只是把响应函数注册到该http.Server里面。类似的,WebSocketServer也可以把自己的响应函数注册到 http.Server中,这样同一个端口,根据协议,可以分别由express和ws处理。我们拿到express创建的http.Server的引用,再配置到 wssOptions.server 里让WebSocketServer根据我们传入的http服务来启动,就实现了统一端口的目的。
要始终注意,浏览器创建WebSocket时发送的仍然是标准的HTTP请求。无论是WebSocket请求,还是普通HTTP请求,都会被http.Server处理。具体的处理方式则是由express和WebSocketServer注入的回调函数实现的。WebSocketServer会首先判断请求是不是WS请求,如果是,它将处理该请求,如果不是,该请求仍由express处理。所以,WS请求会直接由WebSocketServer处理,它根本不会经过express。