可能有很多的同學有用 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
是什么: WebSocket是一種通訊手段,基于TCP協議,默認端口也是80和443,協議標識符是ws(加密為wss),它實現了瀏覽器與服務器的全雙工通信,擴展了瀏覽器與服務端的通信功能,使服務端也能主動向客戶端發送數據,不受跨域的限制。
有什么用: WebSocket用來解決http不能持久連接的問題,因為可以雙向通信所以可以用來實現聊天室,以及其他由服務端主動推送的功能例如 實時天氣、股票報價、余票顯示、消息通知等。
EventSource
是什么: EventSource的官方名稱應該是 Server-sent events(縮寫SSE)服務端派發事件,EventSource 基于http協議只是簡單的單項通信,實現了服務端推的過程客戶端無法通過EventSource向服務端發送數據。喜聞樂見的是ie并沒有良好的兼容當然也有解決的辦法比如 npm install event-source-polyfill
。雖然不能實現雙向通信但是在功能設計上他也有一些優點比如可以自動重連接,event IDs,以及發送隨機事件的能力(WebSocket要借助第三方庫比如socket.io可以實現重連。)
有什么用: 因為受單項通信的限制EventSource只能用來實現像股票報價、新聞推送、實時天氣這些只需要服務器發送消息給客戶端場景中。EventSource的使用更加便捷這也是他的優點。
WebSocket & EventSource 的區別
- WebSocket基于TCP協議,EventSource基于http協議。
- EventSource是單向通信,而websocket是雙向通信。
- EventSource只能發送文本,而websocket支持發送二進制數據。
- 在實現上EventSource比websocket更簡單。
- EventSource有自動重連接(不借助第三方)以及發送隨機事件的能力。
- websocket的資源占用過大EventSource更輕量。
- websocket可以跨域,EventSource基于http跨域需要服務端設置請求頭。
EventSource的實現案例
客戶端代碼
// 實例化 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表示換行之后又加了一個空行)
注: 如果一行文本中不包含冒號,則整行文本會被解析成為字段名,其字段值為空。
WebSocket的實現案例
WebSocket的客戶端原生api
var ws = new WebSocket('ws://localhost:8080')
WebSocket 對象作為一個構造函數,用于新建 WebSocket 實例。
ws.onopen = function(){}
用于指定連接成功后的回調函數。
ws.onclose = function(){}
用于指定連接關閉后的回調函數
ws.onmessage = function(){}
用于指定收到服務器數據后的回調函數
ws.send('data')
實例對象的send()方法用于向服務器發送數據
socket.onerror = function(){}
用于指定報錯時的回調函數
服務端的WebSocket如何實現
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。
案例倉庫:https://github.com/cp0725/YouChat/tree/master/webSocket-eventSource-test
部分概念參考自 https://www.w3cschool.cn/socket/