Web端即時通訊、訊息推送的實現
在瀏覽某些網頁的時候,例如 WebQQ
、京東線上客服服務、CSDN私信訊息等類似的情況下,我們可以在網頁上進行線上聊天,或者即時訊息的收取與回覆,可見,這種功能的需求由來已久,並且應用廣泛。
網上關於這方面的文章也能搜到一大堆,不過基本上都是理論,真正能夠執行的程式碼很少,原理性的東西我就不當搬運工了,本文主要是貼示例程式碼,最多在程式碼中穿插一點便於理解,本文主要的示例程式碼基於 javascript
,服務端基於 nodejs
的 koa(1/2)
框架實現。
模擬推送
Web
端 常見的訊息推送實際上大多數都是模擬推送,之所以是模擬推送,是因為這種實現並不是伺服器主動推送,本質依舊是客戶端發起請求,服務端返回資料,起主動作用的是客戶端。
短輪詢
實現上最簡單的一種模擬推送方法,原理就是客戶端不斷地向服務端發請求,如果服務端資料有更新,服務端就把資料傳送回來,客戶端就能接收到新資料了。
一種實現的示例如下:
const loadXMLDoc = (url, callback) => {
let xmlhttp
if(window.XMLHttpRequest) {
// IE7+ Firefox Chrome Safari 等現代瀏覽器執行的程式碼
xmlhttp = new XMLHttpRequest()
} else {
// IE5 IE6瀏覽器等老舊瀏覽器執行的程式碼
xmlhttp = new ActiveXObject('Microsoft.XMLHTTP')
}
xmlhttp.onreadystatechange = () => {
if(xmlhttp.readyState === 4 && xmlhttp.status === 200) {
document.getElementById('box1').innerHTML = xmlhttp.responseText
callback && callback()
}
}
// 開啟連結傳送請求
xmlhttp.open('GET' , 'http://127.0.0.1:3000/' + url, true)
xmlhttp.send()
}
// 輪詢
setInterval(function() {
loadXMLDoc('fetchMsg')
}, 2000)
上述程式碼,設定定時任務,每隔 2s
使用 ajax
發起一次請求,客戶端根據服務端返回的資料來進行決定執行對應的操作,除了傳送 ajax
,你還可以使用 fetch
:
fetch('localhost:3000/fetchMsg', {
headers: {
'Accept': 'application/json, text/plain, */*'
}
}
引申:
fetch
目前的瀏覽器支援度還很低,所以在實際生產環境中使用的時候,最好新增一些polyfill
,一種墊片使用順序示例如下:
-es5
的polyfill
— es5-shim
-Promise
的polyfill
— es6-promise -IE8+
-fetch
的polyfill
— fetch -IE10+
如果你在使用某種框架,例如 vue
或者 angular
,那麼你同樣可以使用這些框架自帶的請求方法,總之基於頁面的友好訪問性,在傳送請求的同時不要重新整理頁面就行了。
另外,如果不想在主程式的執行緒中做這種機械的輪詢,可以嘗試使用 Web Worker
,下面是一個使用 Web Worker
輪詢的例子:
// 在主程式中建立 Web Worker
function createWorker(fn) {
const blob = new Blob([fn.toString()])
const url = window.URL.createObjectURL(blob)
const worker = new Worker(url)
return worker
}
// 輪詢函式
const pollingWorker = createWorker(() => {
let cache = null
// 如果這一個 worker只幹這一件事,setInterval是不會受到干擾的
setInterval(function() {
fetch('/my-api').then(res => {
const data = res.json()
if (data !== cache) {
// 輪詢的資料發生變化
cache = data
// worker向主程式傳送通知
self.postMessage(data)
}
})
}, 1000)
})
pollingWorker.onmessage = function() {
// 主程式接收到 worker發來的通知
// do something
}
優點:
前後端程式都很容易編寫,沒什麼技術難度
缺點:
這種方法因為需要對伺服器進行持續不斷的請求,就算你設定的請求間隔時間很長,但在使用者訪問量比較大的情況下,也很容易給伺服器帶來很大的壓力,而且絕大部分情況下都是無效請求,浪費頻寬和伺服器資源,一般不會用於實際生產環境的,自己知道一下就行了。
長輪詢
相比於上一種實現,長輪詢同樣是客戶端發起請求,服務端返回資料,只不過不同的是,在長輪詢的情況下,伺服器端在接到客戶端請求之後,如果發現數據庫中的資料並沒有更新或者不符合要求,那麼就不會立即響應客戶端,而是 hold
住這次請求,直到符合要求的資料到達或者因為超時等原因才會關閉連線,客戶端在接收到新資料或者連線被關閉後,再次發起新的請求。
為了節約資源,一次長輪詢的週期時間最好在 10s ~ 25s
左右,長連線也是實際生產環境中,被廣泛運用於實時通訊的技術。
客戶端程式碼如下:
function getData() {
loadXMLDoc('holdFetchMsg', ()=>{
getData()
})
}
getData()
想要在連線斷開或發生錯誤的時候,再次發起請求連線,實現也很簡單,以下問使用 fetch
實現示例:
function getData() {
let result = fetch('http://127.0.0.1:3000/holdFetchMsg', {
headers: {
'Accept': 'application/json, text/plain, */*'
}
})
result.then(res => {
return res.text()
}).then(data => {
document.getElementById('box1').innerHTML = data
}).catch(e => {
console.log('Catch Error:', e)
}).then(() => {
getData()
})
}
getData()
一種較為直觀的伺服器 hold
住連線的實現如下:
router.get('/holdFetchMsg', (ctx, next)=> {
let i = 0
while(true) {
// 這裡的條件在實際環境中可以換成是到資料庫查詢資料的操作
// 如果查詢到了符合要求的資料,再 break
// 不過這種可能會導致伺服器進行例如瘋狂查詢資料庫的操作,非常不友好
if(++i > 2222222222) {
ctx.body = '做我的狗吧'
break
}
}
})
還有一種方法,不過這種純粹是為了 hold
住而 hold
住,可以作為上一種方法的輔助,解決諸如服務端進行瘋狂查詢資料庫的操作,類似於 Java
中的 Thread.sleep()
操作
let delay = 2000, i = 0
while(true) {
let startTime = new Date().getTime()
// 這裡的條件在實際環境中可以換成是到資料庫查詢資料的操作
if(++i > 3) {
ctx.body = '做我的狗吧'
break
} else {
// 休息會,別那麼頻繁地進行諸如查詢資料庫的操作
// delay 為每次查詢後 sleep的時間
while(new Date().getTime() < startTime + delay);
}
}
如果你現在的 Nodejs
版本支援 ES6
中的 Generator
的話,那麼還可以這樣(koa1
環境, Generator
寫法):
app.use(function* (next){
let i = 0
const sleep = ms => {
return new Promise(function timer(resolve){
setTimeout(()=>{
if(++i > 3) {
resolve()
} else {
timer(resolve)
}
}, ms)
})
}
yield sleep(2000)
this.body = '做我的狗吧'
})
如果你現在的 Nodejs
版本支援 ES7
中的 async/await
的話,,那麼還有一種 hold
住連線的方法可供選擇(koa2
環境):
router.get('/holdFestchMsg', async(ctx, next) => {
let i = 0
const sleep = ms => {
return new Promise(function timer(resolve) {
setTimeout(async()=>{
// 這裡的條件在實際環境中可以換成是到資料庫查詢資料的操作
if(++i > 3) {
resolve()
} else {
timer(resolve)
}
}, ms)
})
}
await sleep(2000)
ctx.body = '做我的狗吧'
})
優點:
儘管長輪詢不可能做到每一次的響應都是有用的資料,因為伺服器超時或者客戶端網路環境的變化,以及服務端為了更好的分配資源而自動在一個
心跳
週期的末尾斷掉連線等原因,而導致長輪詢不可能一直存在,必須要不斷地進行斷開和連線操作,但無論如何,相比於短輪詢來說,長輪詢耗費資源明顯小了很多
缺點:
伺服器
hold
連線依舊會消耗不少的資源,特別是當連線數很大的時候,返回資料順序無保證,難於管理維護。
長連線
這種是基於 iframe
或者 script
實現的,主要原理大概就是在主頁面中插入一個隱藏的 iframe(script)
,然後這個 iframe(script)
的 src
屬性指向服務端獲取資料的介面,因為是iframe(script)
是隱藏的,而且 iframe(script)
的 重新整理也不會導致 主頁面重新整理,所以可以為這個 iframe(script)
設定一個定時器,讓其每隔一段時間就朝伺服器傳送一次請求,這樣就能獲得服務端的最新資料了。
先說一下 利用 script
的長連線:
前端實現:
<script>
function callback(msg) {
// 得到後端返回的資料
console.log(msg);
}
function createScript() {
let script = document.createElement('script')
script.src = 'http://127.0.0.1:3000/fetchMsg'
document.body.appendChild(script)
document.body.removeChild(script)
}
</script>
<script>
window.onload = function() {
setInterval(()=>{
createScript()
}, 3000)
}
</script>
後端實現:
router.get('/fetchMsg', (ctx, next)=> {
ctx.body = 'callback("做我的狗吧")'
})
主要是在前端,一共兩條 script
指令碼,大致左右就是在一定的時間間隔內(示例為 3s
)就動態地在頁面中增刪一個連結為用於請求後端資料的 script
指令碼。
後端則返回一段字串,這段字串在返回前端時,有一個 callback
欄位呼叫前端的程式碼,類似於 jsonp
的請求。
注意:修改一個已經執行過的
script
指令碼的src
屬性是沒什麼卵用的,修改之後,最多在頁面的DOM
上發生一些變化,而瀏覽器既不會發請求,也不會執行指令碼,所以這裡採用動態增刪整個script
標籤的做法。
可以看到,這種方法其實與短輪詢沒什麼區別,唯一的區別在於短輪詢保證每次請求都能收到響應,但上述示例的長連線不一定每次都能得到響應,如果下一次長連線開始請求,上一次連線還沒得到響應,則上一次連線將被終止。
當然,如果你想長連線每次也都能保證得到響應也是可以的,大致做法就是在頁面中插入不止一條 script
標籤,每條標籤對應一個請求,等到當前請求到達再決定是否移除當前 script
標籤。
如果想要得到有序的資料響應,則還可以將 setInterval
換成遞迴呼叫,例如:
function createScript() {
let script = document.createElement('script')
script.src = 'http://127.0.0.1:3000/fetchMsg'
document.body.appendChild(script)
script.onload = ()=> {
document.body.removeChild(script)
// 約束輪詢的頻率
setTimeout(()=>{
createScript()
}, 2000)
}
}
window.onload = function() {
createScript()
}
使用 iframe
的方式與此類似,就不贅述了,不過需要注意的是, iframe
可能存在跨域的情況,可能會比 script
方式麻煩一些。
WebSocket
WebSoket
是 HTML5
新增的 API
,具體介紹如下(來源w3c菜鳥教程)
WebSocket是HTML5開始提供的一種在單個 TCP 連線上進行全雙工通訊的協議。
在WebSocket API中,瀏覽器和伺服器只需要做一個握手的動作,然後,瀏覽器和伺服器之間就形成了一條快速通道。兩者之間就直接可以資料互相傳送。
瀏覽器通過 JavaScript 向伺服器發出建立 WebSocket 連線的請求,連線建立以後,客戶端和伺服器端就可以通過 TCP 連線直接交換資料。
當你獲取 Web Socket 連線後,你可以通過 send() 方法來向伺服器傳送資料,並通過 onmessage 事件來接收伺服器返回的資料。
上面所提到的短輪詢、長輪詢、長連線,本質都是單向通訊,客戶端主動發起請求,服務端被動響應請求,但 WebSocket
則已經是全雙工通訊了,也就是說無論是客戶端還是服務端都能主動向對方發起響應,伺服器具備了真正的 推送
能力。
一段簡單的 客戶端 WebSocket
程式碼示例如下:
function myWebSocket() {
let ws = new WebSocket('ws://localhost:3000')
ws.onopen = ()=> {
console.log('send data')
ws.send('client send data')
}
ws.onmessage = (e)=> {
let receiveMsg = e.data
console.log('client get data')
}
ws.onerror = (e)=>{
console.log('Catch Error:', e)
}
ws.onclose = ()=> {
console.log('ws close')
}
}
想要讓客戶端的 WebSocket
能夠連線上伺服器,服務端必須要具備能夠響應 WebSocket
型別的請求才行,一般的伺服器是沒有自帶這種能力的,所以必須要對伺服器端程式程式碼做出些改變。
自己封裝伺服器端響應 WebSocket
的程式碼可能會涉及到很底層的東西,所以一般都是使用第三方封裝好的庫,基於nodejs
的 WebSocket
庫有很多,ws 功能簡單, API
形式更貼近於原生,大名鼎鼎的 socket.io 是與 Nodejs
聯手開發,功能齊全,被廣泛運用於遊戲、實時通訊等應用。
以下給出一種基於 socket.io 實現 簡單客戶端和服務端通訊的示例:
客戶端:
// HTML
<body>
<ul id="messages"></ul>
<form action="" id="msgForm">
<input id="m" autocomplete="off" /><input type="submit" class="submit" value="submit">
</form>
</body>
// 引入 socket.io
<script src='/socket.io/socket.io.js'></script>
<script>
function appendEle(parent, childValue, position = 'appendChild') {
let child = document.createElement('li')
child.innerHTML = childValue
parent[position](child)
}
function socketIO(msgForm, msgBox, msgList) {
const socket = io()
msgForm.addEventListener('submit', (e)=>{
e.preventDefault()
socket.emit('chat message', msgBox.value)
appendEle(msgList, '<b>Client: </b>' + msgBox.value)
msgBox.value = ''
})
socket.on('chat message', (msg)=>{
appendEle(msgList, msg)
})
}
window.onload = ()=>{
let msgForm = document.querySelector('#msgForm')
let msgBox = document.querySelector('#m')
let msgList = document.querySelector('#messages')
socketIO(msgForm, msgBox, msgList)
}
</script>
服務端實現:
const app = require('express')()
const http = require('http').Server(app)
const io = require('socket.io')(http)
app.get('/', (req, res)=> {
res.sendFile(__dirname + '/index.html')
})
io.on('connection', socket=>{
console.log('a user connected')
socket.on('disconnect', ()=>{
console.log('user disconnect')
})
socket.on('chat message', (msg)=>{
console.log('clien get message: ', msg)
setTimeout(()=>{
io.emit('chat message', '<b>Server:</b>' + ' Are you Sure? -- Come from your father')
}, 1500)
})
})
http.listen(3000, ()=> {
console.log('Server running at 3000.')
})
效果如下:
WebSocket
的瀏覽器支援程式為 IE10+
, Android 4.4+
,所以,如果不是 淘寶那樣量級超大的產品,應該都可以使用 這項應該不算是新技術的技術了。