經過參考koa中間件,socket.io遠程事件調用,以一種新的姿式來使用WebSocket。html
瀏覽器端使用WebSocket很簡單前端
// Create WebSocket connection. const socket = new WebSocket('ws://localhost:8080'); // Connection opened socket.addEventListener('open', function (event) { socket.send('Hello Server!'); }); // Listen for messages socket.addEventListener('message', function (event) { console.log('Message from server ', event.data); });
MDN關於WebSocket的介紹vue
能註冊的事件有onclose,onerror,onmessage,onopen。用的比較多的是onmessage,從服務器接受到數據後,會觸發message事件。經過註冊相應的事件處理函數,能夠根據後端推送的數據作相應的操做。java
若是隻是寫個demo,單單輸出後端推送的信息,以下使用便可:node
socket.addEventListener('message', function (event) { console.log('Message from server ', event.data); });
實際使用過程當中,咱們須要判斷後端推送的數據而後執行相應的操做。好比聊天室應用中,須要判斷消息是廣播的仍是私聊的或者羣聊的,以及是純文字信息仍是圖片等多媒體信息。這時message處理函數裏可能就是一堆的if else。那麼有沒有什麼別的優雅的姿式呢?答案就是中間件與事件,跨進程的事件的發佈與訂閱。在說遠程事件發佈訂閱以前,須要先從中間件開始,由於後面實現的遠程事件發佈訂閱是基於中間件的。react
前面說了,在WebSocket實例上能夠註冊事件有onclose,onerror,onmessage,onopen。每個事件的處理函數裏可能須要作各類判斷,特別是message事件。參考koa,能夠將事件處理函數以中間件方式來進行使用,將不一樣的操做邏輯分發到不一樣的中間件中,好比聊天室應用中,聊天信息與系統信息(好比用戶登陸屬於系統信息)是能夠放到不一樣的中間件中處理的。git
koa提供use接口來註冊中間件。咱們針對不一樣的事件提供相應的中間件註冊接口,而且對原生的WebSocket作封裝。github
export default class EasySocket{ constructor(config) { this.url = config.url; this.openMiddleware = []; this.closeMiddleware = []; this.messageMiddleware = []; this.errorMiddleware = []; this.openFn = Promise.resolve(); this.closeFn = Promise.resolve(); this.messageFn = Promise.resolve(); this.errorFn = Promise.resolve(); } openUse(fn) { this.openMiddleware.push(fn); return this; } closeUse(fn) { this.closeMiddleware.push(fn); return this; } messageUse(fn) { this.messageMiddleware.push(fn); return this; } errorUse(fn) { this.errorMiddleware.push(fn); return this; } }
經過xxxUse
註冊相應的中間件。 xxxMiddleware
中就是相應的中間件。xxxFn
中間件經過compose處理後的結構web
再添加一個connect方法,處理相應的中間件而且實例化原生WebSocketvuex
connect(url) { this.url = url || this.url; if (!this.url) { throw new Error('url is required!'); } try { this.socket = new WebSocket(this.url, 'echo-protocol'); } catch (e) { throw e; } this.openFn = compose(this.openMiddleware); this.socket.addEventListener('open', (event) => { let context = { client: this, event }; this.openFn(context).catch(error => { console.log(error) }); }); this.closeFn = compose(this.closeMiddleware); this.socket.addEventListener('close', (event) => { let context = { client: this, event }; this.closeFn(context).then(() => { }).catch(error => { console.log(error) }); }); this.messageFn = compose(this.messageMiddleware); this.socket.addEventListener('message', (event) => { let res; try { res = JSON.parse(event.data); } catch (error) { res = event.data; } let context = { client: this, event, res }; this.messageFn(context).then(() => { }).catch(error => { console.log(error) }); }); this.errorFn = compose(this.errorMiddleware); this.socket.addEventListener('error', (event) => { let context = { client: this, event }; this.errorFn(context).then(() => { }).catch(error => { console.log(error) }); }); return this; }
使用koa-compose模塊處理中間件。注意context傳入了哪些東西,後續定義中間件的時候都已使用。
compose的做用可看這篇文章 傻瓜式解讀koa中間件處理模塊koa-compose
而後就可使用了:
new EasySocket() .openUse((context, next) => { console.log("open"); next(); }) .closeUse((context, next) => { console.log("close"); next(); }) .errorUse((context, next) => { console.log("error", context.event); next(); }) .messageUse((context, next) => { //用戶登陸處理中間件 if (context.res.action === 'userEnter') { console.log(context.res.user.name+' 進入聊天室'); } next(); }) .messageUse((context, next) => { //建立房間處理中間件 if (context.res.action === 'createRoom') { console.log('建立房間 '+context.res.room.anme); } next(); }) .connect('ws://localhost:8080')
能夠看到,用戶登陸與建立房間的邏輯放到兩個中間件中分開處理。不足之處就是每一箇中間件都要判斷context.res.action,而這個context.res就是後端返回的數據。怎麼消除這個頻繁的if判斷呢? 咱們實現一個簡單的消息處理路由。
定義消息路由中間件
messageRouteMiddleware.js
export default (routes) => { return async (context, next) => { if (routes[context.req.action]) { await routes[context.req.action](context,next); } else { console.log(context.req) next(); } } }
定義路由
router.js
export default { userEnter:function(context,next){ console.log(context.res.user.name+' 進入聊天室'); next(); }, createRoom:function(context,next){ console.log('建立房間 '+context.res.room.anme); next(); } }
使用:
new EasySocket() .openUse((context, next) => { console.log("open"); next(); }) .closeUse((context, next) => { console.log("close"); next(); }) .errorUse((context, next) => { console.log("error", context.event); next(); }) .messageUse(messageRouteMiddleware(router))//使用消息路由中間件,並傳入定義好的路由 .connect('ws://localhost:8080')
一切都變得美好了,感受就像在使用koa。想一個問題,當接收到後端推送的消息時,咱們須要作相應的DOM操做。好比路由裏面定義的userEnter,咱們可能須要在對應的函數裏操做用戶列表的DOM,追加新用戶。這使用原生JS或JQ都是沒有問題的,可是若是使用vue,react這些,由於是組件化的,用戶列表可能就是一個組件,怎麼訪問到這個組件實例呢?(固然也能夠訪問vuex,redux的store,可是並非全部組件的數據都是用store管理的)。
咱們須要一個運行時註冊中間件的功能,而後在組件的相應的生命週期鉤子裏註冊中間件而且傳入組件實例
運行時註冊中間件,修改以下代碼:
messageUse(fn, runtime) { this.messageMiddleware.push(fn); if (runtime) { this.messageFn = compose(this.messageMiddleware); } return this; }
修改 messageRouteMiddleware.js
export default (routes,component) => { return async (context, next) => { if (routes[context.req.action]) { context.component=component;//將組件實例掛到context下 await routes[context.req.action](context,next); } else { console.log(context.req) next(); } } }
相似vue mounted中使用
mounted(){ let client = this.$wsClients.get("im");//獲取指定EasySocket實例 client.messageUse(messageRouteMiddleware(router,this),true)//運行時註冊中間件,並傳入定義好的路由以及當前組件中的this }
路由中經過 context.component 便可訪問到當前組件。
完美了嗎?每次組件mounted 都註冊一次中間件,問題很大。因此須要一個判斷中間件是否已經註冊的功能。也就是一個支持具名註冊中間件的功能。這裏就暫時不實現了,走另一條路,也就是以前說到的遠程事件的發佈與訂閱,咱們也能夠稱之爲跨進程事件。
看一段socket.io的代碼:
Server (app.js)
var app = require('http').createServer(handler) var io = require('socket.io')(app); var fs = require('fs'); app.listen(80); function handler (req, res) { fs.readFile(__dirname + '/index.html', function (err, data) { if (err) { res.writeHead(500); return res.end('Error loading index.html'); } res.writeHead(200); res.end(data); }); } io.on('connection', function (socket) { socket.emit('news', { hello: 'world' }); socket.on('my other event', function (data) { console.log(data); }); });
Client (index.html)
<script src="/socket.io/socket.io.js"></script> <script> var socket = io('http://localhost'); socket.on('news', function (data) { console.log(data); socket.emit('my other event', { my: 'data' }); }); </script>
注意力轉到這兩部分:
服務端
socket.emit('news', { hello: 'world' }); socket.on('my other event', function (data) { console.log(data); });
客戶端
var socket = io('http://localhost'); socket.on('news', function (data) { console.log(data); socket.emit('my other event', { my: 'data' }); });
使用事件,客戶端經過on訂閱'news'事件,而且當觸發‘new’事件的時候經過emit發佈'my other event'事件。服務端在用戶鏈接的時候發佈'news'事件,而且訂閱'my other event'事件。
通常咱們使用事件的時候,都是在同一個頁面中on和emit。而socket.io的神奇之處就是同一事件的on和emit是分別在客戶端和服務端,這就是跨進程的事件。
那麼,在某一端emit某個事件的時候,另外一端若是on監聽了此事件,是如何知道這個事件emit(發佈)了呢?
沒有看socket.io源碼以前,我設想應該是emit方法裏作了某些事情。就像java或c#,實現rpc的時候,能夠依據接口定義動態生成實現(也稱爲代理),動態實現的(代理)方法中,就會將當前方法名稱以及參數經過相應協議進行序列化,而後經過http或者tcp等網絡協議傳輸到RPC服務端,服務端進行反序列化,經過反射等技術調用本地實現,並返回執行結果給客戶端。客戶端拿到結果後,整個調用完成,就像調用本地方法同樣實現了遠程方法的調用。
看了socket.io emit的代碼實現後,思路也是大同小異,經過將當前emit的事件名和參數按必定規則組合成數據,而後將數據經過WebSocket的send方法發送出去。接收端按規則取到事件名和參數,而後本地觸發emit。(注意遠程emit和本地emit,socket.io中直接調用的是遠程emit)。
下面是實現代碼,事件直接用的emitter模塊,而且爲了能自定義emit事件名和參數組合規則,以中間件的方式提供處理方法:
export default class EasySocket extends Emitter{//繼承Emitter constructor(config) { this.url = config.url; this.openMiddleware = []; this.closeMiddleware = []; this.messageMiddleware = []; this.errorMiddleware = []; this.remoteEmitMiddleware = [];//新增的部分 this.openFn = Promise.resolve(); this.closeFn = Promise.resolve(); this.messageFn = Promise.resolve(); this.errorFn = Promise.resolve(); this.remoteEmitFn = Promise.resolve();//新增的部分 } openUse(fn) { this.openMiddleware.push(fn); return this; } closeUse(fn) { this.closeMiddleware.push(fn); return this; } messageUse(fn) { this.messageMiddleware.push(fn); return this; } errorUse(fn) { this.errorMiddleware.push(fn); return this; } //新增的部分 remoteEmitUse(fn, runtime) { this.remoteEmitMiddleware.push(fn); if (runtime) { this.remoteEmitFn = compose(this.remoteEmitMiddleware); } return this; } connect(url) { ... //新增部分 this.remoteEmitFn = compose(this.remoteEmitMiddleware); } //重寫emit方法,支持本地調用以遠程調用 emit(event, args, isLocal = false) { let arr = [event, args]; if (isLocal) { super.emit.apply(this, arr); return this; } let evt = { event: event, args: args } let remoteEmitContext = { client: this, event: evt }; this.remoteEmitFn(remoteEmitContext).catch(error => { console.log(error) }) return this; } }
下面是一個簡單的處理中間件:
client.remoteEmitUse((context, next) => { let client = context.client; let event = context.event; if (client.socket.readyState !== 1) { alert("鏈接已斷開!"); } else { client.socket.send(JSON.stringify({ type: 'event', event: event.event, args: event.args })); next(); } })
意味着調用
client.emit('chatMessage',{ from:'admin', masg:"Hello WebSocket" });
就會組合成數據
{ type: 'event', event: 'chatMessage', args: { from:'admin', masg:"Hello WebSocket" } }
發送出去。
服務端接受到這樣的數據,能夠作相應的數據處理(後面會使用nodejs實現相似的編程模式),也能夠直接發送給別的客戶端。客戶受到相似的數據,能夠寫專門的中間件進行處理,好比:
client.messageUse((context, next) => { if (context.res.type === 'event') { context.client.emit(context.res.event, context.res.args, true);//注意這裏的emit是本地emit。 } next(); })
若是本地訂閱的chatMessage事件,回到函數就會被觸發。
在vue或react中使用,也會比以前使用路由的方式簡單
mounted() { let client = this.$wsClients.get("im"); client.on("chatMessage", data => { let isSelf = data.from.id == this.user.id; let msg = { name: data.from.name, msg: data.msg, createdDate: data.createdDate, isSelf }; this.broadcastMessageList.push(msg); }); }
組件銷燬的時候移除相應的事件訂閱便可,或者清空全部事件訂閱
destroyed() { let client = this.$wsClients.get("im"); client.removeAllListeners(); }
核心代碼直接從websocket-heartbeat-js copy過來的(用npm包,還得在它的基礎上再包一層),相關文章 初探和實現websocket心跳重連。
核心代碼:
heartCheck() { this.heartReset(); this.heartStart(); } heartStart() { this.pingTimeoutId = setTimeout(() => { //這裏發送一個心跳,後端收到後,返回一個心跳消息 this.socket.send(this.pingMsg); //接收到心跳信息說明鏈接正常,會執行heartCheck(),重置心跳(清除下面定時器) this.pongTimeoutId = setTimeout(() => { //此定時器有運行的機會,說明發送ping後,設置的超時時間內未收到返回信息 this.socket.close();//不直接調用reconnect,避免舊WebSocket實例沒有真正關閉,致使不可預料的問題 }, this.pongTimeout); }, this.pingTimeout); } heartReset() { clearTimeout(this.pingTimeoutId); clearTimeout(this.pongTimeoutId); }
源碼地址:easy-socket-browser
nodejs實現的相似的編程模式(有空再細說):easy-socket-node
實現的聊天室例子:online chat demo
聊天室前端源碼:lazy-mock-im
聊天室服務端源碼:lazy-mock