Vue Socket.io源码解读

背景

有一个项目,今年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])|[Ws_]+(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或者直接评论吧

最后,源码的详细的注释在这里

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持来客网。