Vue-Socket.io原始碼解讀

Juliiii發表於2017-12-19

背景

有一個專案,今年12月份開始重構,專案涉及到了socket。但是socket用的是以前一個開發人員封裝的包(這個一直被當前的成員吐槽為什麼不用已經千錘百煉的輪子)。因此,趁著這個重構的機會,將vue-socket.io引入,後端就用socket.io。我也好奇看了看vue-socket.io的原始碼(我不會說是因為這個庫的文件實在太簡略了,我為了穩點去看原始碼瞭解該怎麼用)

開始

  • 檔案架構
    檔案架構
    我們主要看src下的三個檔案,可以看出該庫是用了觀察者模式
  • Main.js
// 這裡建立一個observe物件,具體做了什麼可以看Observer.js檔案
let observer = new Observer(connection, store)

// 將socket掛載到了vue的原型上,然後就可以
// 在vue例項中就可以this.$socket.emit('xxx', {})
Vue.prototype.$socket = observer.Socket;
複製程式碼
import store from './yourstore'
Vue.use(VueSocketio, socketio('http://socketserver.com:1923'), store);
複製程式碼

我們如果要使用這個庫的時候,一般是這樣寫的程式碼(上圖2)。上圖一的connection和store就分別是圖二的後兩個引數。意思分別為socket連線的url和vuex的store啦。圖一就是將這兩個引數傳進Observer,新建了一個observe物件,然後將observe物件的socket屬性掛載在Vue原型上。那麼我們在Vue的例項中就可以直接 this.$sockets.emit('xxx', {})

// ?就是在vue例項的生命週期做一些操作
Vue.mixin({
    created(){
        let sockets = this.$options['sockets']

        this.$options.sockets = new Proxy({}, {
            set: (target, key, value) => {
                Emitter.addListener(key, value, this)
                target[key] = value
                return true;
            },
            deleteProperty: (target, key) => {
                Emitter.removeListener(key, this.$options.sockets[key], this)
                delete target.key;
                return true
            }
        })

        if(sockets){
            Object.keys(sockets).forEach((key) => {
                this.$options.sockets[key] = sockets[key];
            });
        }
    },
    /**
     * 在beforeDestroy的時候,將在created時監聽好的socket事件,全部取消監聽
     * delete this.$option.sockets的某個屬性時,就會將取消該訊號的監聽
     */
    beforeDestroy(){
        let sockets = this.$options['sockets']

        if(sockets){
            Object.keys(sockets).forEach((key) => {
                delete this.$options.sockets[key]
            });
        }
    }
複製程式碼

下面就是在Vue例項的生命週期做一些操作。建立的時候,將例項中的$options.sockets的值先快取下來,再將$options.sockets指向一個proxy物件,這個proxy物件會攔截外界對它的賦值和刪除屬性操作。這裡賦值的時候,鍵就是socket事件,值就是回撥函式。賦值時,就會監聽該事件,然後將回撥函式,放進該socket事件對應的回撥陣列裡。刪除時,就是取消監聽該事件了,將賦值時壓進回撥陣列的那個回撥函式,刪除,表示,我不監聽了。這樣寫法,其實就跟vue的響應式一個道理。也因此,我們就可以動態地新增和移除監聽socket事件了,比如this.$option.sockets.xxx = () => ()delete this.$option.sockets.xxx。最後將快取的值,依次賦值回去,那麼如下圖的寫法就會監聽到事件並執行回撥函式了:

var vm = new Vue({
  sockets:{
    connect: function(){
      console.log('socket connected')
    },
    customEmit: function(val){
      console.log('this method was fired by the socket server. eg: io.emit("customEmit", data)')
    }
  },
  methods: {
    clickButton: function(val){
        // $socket is socket.io-client instance
        this.$socket.emit('emit_method', val);
    }
  }
})
複製程式碼
  • Emitter.js Emitter.js主要是寫了一個Emitter物件,該物件提供了三個方法:
addListener
addListener(label, callback, vm) {
    // 回撥函式型別是回撥函式才對
    if(typeof callback == 'function'){
        // 這裡就很常見的寫法了,判斷map中是否已經註冊過該事件了
        // 如果沒有,就初始化該事件對映的值為空陣列,方便以後直接存入回撥函式
        // 反之,直接將回撥函式放入陣列即可
        this.listeners.has(label) || this.listeners.set(label, []);
        this.listeners.get(label).push({callback: callback, vm: vm});

        return true
    }

    return false
}
複製程式碼

其實很常規啦,實現釋出訂閱者模式或者觀察者模式程式碼的同學都很清楚這段程式碼的意思。Emiiter用一個map來儲存事件以及它對應的回撥事件陣列。這段程式碼先判斷map中是否之前已經儲存過了該事件,如果沒有,初始化該事件對應的值為空陣列,然後將當前的回撥函式,壓進去,反之,直接壓進去。

removeListener
if (listeners && listeners.length) {
    index = listeners.reduce((i, listener, index) => {
        return (typeof listener.callback == 'function' && listener.callback === callback && listener.vm == vm) ?
            i = index :
            i;
    }, -1);

    if (index > -1) {
        listeners.splice(index, 1);
        this.listeners.set(label, listeners);
        return true;
    }
}
return false;
複製程式碼

這裡也很簡單啦,獲取該事件對應的回撥陣列。如果不為空,就去尋找需要移除的回撥,找到後,直接刪除,然後將新的回撥陣列覆蓋原來的那個就可以了

emit

if (listeners && listeners.length) {
    listeners.forEach((listener) => {
        listener.callback.call(listener.vm,...args)
    });
    return true;
}
return false;
複製程式碼

這裡就是監聽到事件後,執行該事件對應的回撥函式,注意這裡的call,因為監聽到事件後我們可能要修改下vue例項的資料或者呼叫一些方法,用過vue的同學都知道我們都是this.xxx來呼叫的,所以一定得將回撥函式的this指向vue例項,這也是為什麼存回撥事件時也要把vue例項存下來的原因。

  • Observer.js
constructor(connection, store) {
    // 這裡很明白吧,就是判斷這個connection是什麼型別
    // 這裡的處理就是你可以傳入一個連線好的socket例項,也可以是一個url
    if(typeof connection == 'string'){
        this.Socket = Socket(connection);
    }else{
        this.Socket = connection
    }

    // 如果有傳進vuex的store可以響應在store中寫的mutations和actions
    // 這裡只是掛載在這個oberver例項上
    if(store) this.store = store;

    // 監聽,啟動!
    this.onEvent()

}
複製程式碼

這個Observer.js裡也主要是寫了一個Observer的class,以上是它的建構函式,建構函式第一件事是判斷connection是不是字串,如果是就構建一個socket例項,如果不是,就大概是個socket的例項了,然後直接掛載在它的物件例項上。其實這裡我覺得可以引數檢查嚴格點, 比如字串被人搞怪地可能會傳入一個非法的url,對吧。這個時候判斷下,丟擲一個error提醒下也好,不過應該也沒人這麼無聊吧,2333。然後如果傳入了store,也掛在物件例項上吧。最後就啟動監聽事件啦。我們看看onEvent的邏輯

    onEvent(){
        // 監聽服務端發來的事件,packet.data是一個陣列
        // 第一項是事件,第二個是服務端傳來的資料
        // 然後用emit通知訂閱了該訊號的回撥函式執行
        // 如果有傳入了vuex的store,將該事件和資料傳入passToStore,執行passToStore的邏輯
        var super_onevent = this.Socket.onevent;
        this.Socket.onevent = (packet) => {
            super_onevent.call(this.Socket, packet);

            Emitter.emit(packet.data[0], packet.data[1]);

            if(this.store) this.passToStore('SOCKET_'+packet.data[0],  [ ...packet.data.slice(1)])
        };

        // 這裡跟上面意思應該是一樣的,我很好奇為什麼要分開寫,難道上面的寫法不會監聽到下面的訊號?
        // 然後這裡用一個變數暫存this
        // 但是下面都是箭頭函式了,我覺得沒必要,畢竟箭頭函式會自動繫結父級上下文的this
        let _this = this;

        ["connect", "error", "disconnect", "reconnect", "reconnect_attempt", "reconnecting", "reconnect_error", "reconnect_failed", "connect_error", "connect_timeout", "connecting", "ping", "pong"]
            .forEach((value) => {
                _this.Socket.on(value, (data) => {
                    Emitter.emit(value, data);
                    if(_this.store) _this.passToStore('SOCKET_'+value, data)
                })
            })
    }
複製程式碼

這裡就是有點類似過載onevent這個函式了,監聽到事件後,將資料拆包,然後通知執行回撥和傳遞給store。大體的邏輯是這樣子。然後這程式碼實現有兩部分,第一部分和第二部分邏輯基本一樣。只是分開寫。(其實我也不是很懂啦,如果很有必要的話,我猜第一部分的寫法還監聽不了第二部分的事件吧,所以要另外監聽)。最後只剩下一個passToStore了,其實也很容易懂

 passToStore(event, payload){
     // 如果事件不是以SOCKET_開頭的就不用管了
     if(!event.startsWith('SOCKET_')) return

     // 這裡遍歷vuex的store中的mutations
     for(let namespaced in this.store._mutations) {
         // 下面的操作是因為,如果store中有module是開了namespaced的,會在mutation的名字前加上 xxx/
         // 這裡將mutation的名字拿出來
         let mutation = namespaced.split('/').pop()
         // 如果名字和事件是全等的,那就發起一個commit去執行這個mutation
         // 也因此,mutation的名字一定得是 SOCKET_開頭的了
         if(mutation === event.toUpperCase()) this.store.commit(namespaced, payload)
     }
     // 這裡類似上面
     for(let namespaced in this.store._actions) {
         let action = namespaced.split('/').pop()

         // 這裡強制要求了action的名字要以 socket_ 開頭
         if(!action.startsWith('socket_')) continue

         // 這裡就是將事件轉成駝峰式
         let camelcased = 'socket_'+event
                 .replace('SOCKET_', '')
                 .replace(/^([A-Z])|[\W\s_]+(\w)/g, (match, p1, p2) => p2 ? p2.toUpperCase() : p1.toLowerCase())

         // 如果action和事件全等,那就發起這個action
         if(action === camelcased) this.store.dispatch(namespaced, payload)
     }
 }
複製程式碼

passToStore嘛其實就是做兩個事情,一個是獲取與該事件對應的mutation,然後發起一個commit,一個是獲取與該事件對應的action,然後dispatch。只是這裡的實現對mutations和actions的命名有了要求,比如mutations的命名一定得是SOCKET_開頭,action就是一個得socket_開頭,然後還得是駝峰式命名。

最後

  • 首先,這個原始碼是不是略有點簡單,哈哈哈,不過,能給你們一些幫助,我覺得也挺好的
  • 然後,就是如果上面我說的有是很對的,請大家去這裡發issue或者直接評論吧
  • 最後,原始碼的詳細的註釋在這裡,歡迎大家提issue,如果能star和fork就更好了。以後我儘量更新自己閱讀原始碼的感悟,大家一起學習。

相關文章