使用Vue3+TS重构百星websocket插件

2022-04-10 09:27:35 浏览数 (1)

前言

前几天我用Vue3重构了我那个Vue2的开源项目,最后还遗留了一个问题:项目中用的一个websocket插件还不能正常使用。于是,我决定重写这个插,让其支持Vue3。

本文将记录下重写这个插件的过程并将其发布至npm仓库,顺便给插件作者提个PR,欢迎各位感兴趣的开发者阅读本文。

插件解读

image-20201103005333494

如上图所示就是即将要重构的插件,目前有735个star,我们先将插件代码clone到本地。

代码语言:javascript复制
git clone https://github.com/nathantsoi/vue-native-websocket

下载到本地后,用你喜欢的ide打开它,其目录如下:

image-20201101194150523

目录解读

经过一番梳理后,其各个目录的作用如下:

  • vue-native-websocket 项目文件夹
    • Emitter.js websocket的事件队列与分发的实现
    • Main.js vue 插件入口代码
    • Observer.js 观察者模式,websocket服务核心功能封装
    • build.js 编译后的代码文件
    • dist 编译后的项目文件夹
    • node_modules 项目依赖库
    • src 项目源码文件夹
    • test 单元测试文件
    • .eslintrc.json 项目的eslint配置
    • .gitignore 上传至git仓库需要忽略的文件
    • .nvmrc 指定项目期望用的node版本
    • .travis.yml 自动化构建配置文件
    • CHANGELOG.md 版本发布记录文件
    • npm-shrinkwrap.json npm包版本锁定文件
    • package.json 项目依赖配置文件
    • PUBLISH.md 修改完插件后的发布规范
    • README.md 插件使用文档
    • webpack.config.js webpack配置文件
    • yarn.lock yarn包版本锁定文件

读完代码后,我们发现他的实现逻辑很精简,一个字:妙。

该插件的核心代码就src目录下的3个文件,接下来我们就从插件的入口文件Main.js开始解读。

如下所示,它引入了两个文件以及Vue官方要求的插件作为一个对象时必须提供的install方法。

代码语言:javascript复制
import Observer from './Observer'
import Emitter from './Emitter'

export default {
    install (Vue, connection, opts = {}) {
      // ... 其它代码省略 ... //
    }
}

那么,我们就先来看看第一个引入的文件Observer.js的代码。

如下所示,它引入了Emitter.js文件,以及它自身的实现代码。

代码语言:javascript复制
import Emitter from './Emitter'

export default class {
  constructor (connectionUrl, opts = {}) {
    // ... 其它代码省略... //
  })
}

Emitter.js

同样的,我们先从他引入的文件开始读,即Emitter.js,其代码如下,我读完代码后并添加了相关注释,它实现了一个事件监听队列,以及一个事件触发函数emit

代码语言:javascript复制
class Emitter {
  constructor () {
    this.listeners = new Map()
  }

    /**
     * 添加事件监听
     * @param label 事件名称
     * @param callback 回调函数
     * @param vm this对象
     * @return {boolean}
    */
  addListener (label, callback, vm) {
    if (typeof callback === 'function') {
      // label不存在就添加
      this.listeners.has(label) || this.listeners.set(label, [])
      // 向label添加回调函数
      this.listeners.get(label).push({callback: callback, vm: vm})
      return true
    }
    return false
  }

    /**
     * 移除监听
     * @param label 事件名称
     * @param callback 回调函数
     * @param vm this对象
     * @return {boolean}
    */
  removeListener (label, callback, vm) {
    // 从监听列表中获取当前事件
    let listeners = this.listeners.get(label)
    let index

    if (listeners && listeners.length) {
      // 寻找当前事件在事件监听列表的位置
      index = listeners.reduce((i, listener, index) => {
        if (typeof listener.callback === 'function' && listener.callback === callback && listener.vm === vm) {
          i = index
        }
        return i
      }, -1)

      if (index > -1) {
        // 移除事件
        listeners.splice(index, 1)
        this.listeners.set(label, listeners)
        return true
      }
    }
    return false
  }
  /**
     * 触发监听
     * @param label 事件名称
     * @param args 参数
     * @return {boolean}
  */
  emit (label, ...args) {
     // 获取事件列表中存储的事件
    let listeners = this.listeners.get(label)

    if (listeners && listeners.length) {
      listeners.forEach((listener) => {
        // 扩展callback函数,让其拥有listener.vm中的方法
        listener.callback.call(listener.vm, ...args)
      })
      return true
    }
    return false
  }
}

export default new Emitter()

Observer.js

接下来,我们在回过头来看Observer.js的代码,他实现了websocket服务核心功能的封装,是这个插件的核心。它的constructor部分代码如下所示,他定义了插件调用者可以传的参数以及初始值。

代码语言:javascript复制
constructor (connectionUrl, opts = {}) {
    // 获取参数中的format并将其转成小写
    this.format = opts.format && opts.format.toLowerCase()

   // 如果url以//开始对其进行处理添加正确的websocket协议前缀
    if (connectionUrl.startsWith('//')) {
      // 当前网站如果为https请求则添加wss前缀否则添加ws前缀
      const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
      connectionUrl = `${scheme}:${connectionUrl}`
    }

    // 将处理好的url和opts赋值给当前类内部变量
    this.connectionUrl = connectionUrl
    this.opts = opts

    // 是否开启重连,默认值为false
    this.reconnection = this.opts.reconnection || false
   // 最大重连次数,默认值为无穷大
    this.reconnectionAttempts = this.opts.reconnectionAttempts || Infinity
    // 重连间隔时间,默认为1s
    this.reconnectionDelay = this.opts.reconnectionDelay || 1000
    // 重连超时id,默认为0
    this.reconnectTimeoutId = 0
   // 已重连次数,默认为0
    this.reconnectionCount = 0

   // 传输数据时的处理函数
    this.passToStoreHandler = this.opts.passToStoreHandler || false

   // 建立连接
    this.connect(connectionUrl, opts)

    // 如果配置参数中有传store就将store赋值
    if (opts.store) { this.store = opts.store }
    // 如果配置参数中有传vuex的同步处理函数就将mutations赋值
    if (opts.mutations) { this.mutations = opts.mutations }
    // 事件触发
    this.onEvent()
}
连接函数

我们再来看看connet方法的实现,它的代码如下,它会根据用户传入的websocket服务端地址以及插件参数来建立websocket连接。

代码语言:javascript复制
  // 连接websocket
  connect (connectionUrl, opts = {}) {
    // 获取配置参数传入的协议
    let protocol = opts.protocol || ''
    // 如果没传协议就建立一个正常的websocket连接否则就创建带协议的websocket连接
    this.WebSocket = opts.WebSocket || (protocol === '' ? new WebSocket(connectionUrl) : new WebSocket(connectionUrl, protocol))
    // 启用json发送
    if (this.format === 'json') {
      // 如果websocket中没有senObj就添加这个方法对象
      if (!('sendObj' in this.WebSocket)) {
        // 将发送的消息转为json字符串
        this.WebSocket.sendObj = (obj) => this.WebSocket.send(JSON.stringify(obj))
      }
    }

    return this.WebSocket
  }

重连函数

我们再来看看reconnect方法的实现,它的代码如下,它会读取用户传进来的最大重连次数,然后重新与websocket服务端建立链接。

代码语言:javascript复制
  // 重新连接
 reconnect () {
    // 已重连次数小于等于设置的连接次数时执行重连
    if (this.reconnectionCount <= this.reconnectionAttempts) {
      this.reconnectionCount  
      // 清理上一次重连时的定时器
      clearTimeout(this.reconnectTimeoutId)

      // 开始重连
      this.reconnectTimeoutId = setTimeout(() => {
        // 如果启用vuex就触发vuex中的重连方法
        if (this.store) { this.passToStore('SOCKET_RECONNECT', this.reconnectionCount) }

        // 重新连接
        this.connect(this.connectionUrl, this.opts)

        // 触发WebSocket事件
        this.onEvent()
      }, this.reconnectionDelay)
    } else {
      if (this.store) {    
        // 如果启用vuex则触发重连失败方法
        this.passToStore('SOCKET_RECONNECT_ERROR', true) }
    }
  }
事件触发函数

我们再来看看onEvent函数,它的实现代码如下,它会调用Emitter中的emit方法,对websocket中的4个监听事件进行分发扩展,交由Emitter类来管理。

代码语言:javascript复制
 // 事件分发  
 onEvent () {
    ['onmessage', 'onclose', 'onerror', 'onopen'].forEach((eventType) => {
      this.WebSocket[eventType] = (event) => {
        Emitter.emit(eventType, event)

         // 调用vuex中对应的方法
        if (this.store) { this.passToStore('SOCKET_'   eventType, event) }

        // 处于重新连接状态切事件为onopen时执行
        if (this.reconnection && eventType === 'onopen') {
           // 设置实例
          this.opts.$setInstance(event.currentTarget)
          // 清空重连次数
          this.reconnectionCount = 0
        }

        // 如果处于重连状态且事件为onclose时调用重连方法
        if (this.reconnection && eventType === 'onclose') { this.reconnect() }
      }
    })
  }
vuex事件处理函数

我们再来看看处理vuex事件的实现函数,它的实现代码如下,它用于触发vuex中的方法,它允许调用者传passToStoreHandler事件处理函数,用于触发前的事件处理。

代码语言:javascript复制
  /**
     * 触发vuex中的方法
     * @param eventName 事件名称
     * @param event 事件
  */
 passToStore (eventName, event) {
    // 如果参数中有传事件处理函数则执行自定义的事件处理函数,否则执行默认的处理函数
    if (this.passToStoreHandler) {
      this.passToStoreHandler(eventName, event, this.defaultPassToStore.bind(this))
    } else {
      this.defaultPassToStore(eventName, event)
    }
  }

 /**
     * 默认的事件处理函数
     * @param eventName 事件名称
     * @param event 事件
  */
  defaultPassToStore (eventName, event) {
    // 事件名称开头不是SOCKET_则终止函数
    if (!eventName.startsWith('SOCKET_')) { return }
    let method = 'commit'
    // 事件名称字母转大写
    let target = eventName.toUpperCase()
    // 消息内容
    let msg = event
    // data存在且数据为json格式
    if (this.format === 'json' && event.data) {
      // 将data从json字符串转为json对象
      msg = JSON.parse(event.data)
      // 判断msg是同步还是异步
      if (msg.mutation) {
        target = [msg.namespace || '', msg.mutation].filter((e) => !!e).join('/')
      } else if (msg.action) {
        method = 'dispatch'
        target = [msg.namespace || '', msg.action].filter((e) => !!e).join('/')
      }
    }
    if (this.mutations) {
      target = this.mutations[target] || target
    }
    // 触发store中的方法
    this.store[method](target, msg)
  }

Main.js

上面我们读完了插件的核心实现代码,最后我们来看看插件的入口文件,它的代码如下,他会将我们前面实现的websocket相关封装应用到Vue全局。他做了以下事情:

  • 全局挂载$socket属性,便于访问socket建立的socket连接
  • 启用手动连接时,向全局挂载手动连接方法和关闭连接方法
  • 全局混入,添加socket事件监听,组件销毁前移除全局添加的方法
代码语言:javascript复制
import Observer from './Observer'
import Emitter from './Emitter'

export default {

  install (Vue, connection, opts = {}) {
    // 没有传入连接,抛出异常
    if (!connection) { throw new Error('[vue-native-socket] cannot locate connection') }

    let observer = null

    opts.$setInstance = (wsInstance) => {
      // 全局属性添加$socket
      Vue.prototype.$socket = wsInstance
    }

    // 配置选项中启用手动连接
    if (opts.connectManually) {
      Vue.prototype.$connect = (connectionUrl = connection, connectionOpts = opts) => {
        // 调用者传入的参数中添加set实例
        connectionOpts.$setInstance = opts.$setInstance
        // 创建Observer建立websocket连接
        observer = new Observer(connectionUrl, connectionOpts)
        // 全局添加$socket
        Vue.prototype.$socket = observer.WebSocket
      }

      // 全局添加连接断开处理函数
      Vue.prototype.$disconnect = () => {
        if (observer && observer.reconnection) {
          // 重新连接状态改为false
          observer.reconnection = false
        }
        // 如果全局属性socket存在则从全局属性移除
        if (Vue.prototype.$socket) {
          // 关闭连接
          Vue.prototype.$socket.close()
          delete Vue.prototype.$socket
        }
      }
    } else {
      // 未启用手动连接
      observer = new Observer(connection, opts)
      // 全局添加$socket属性,连接至websocket服务器
      Vue.prototype.$socket = observer.WebSocket
    }
    const hasProxy = typeof Proxy !== 'undefined' && typeof Proxy === 'function' && /native code/.test(Proxy.toString())

    Vue.mixin({
      created () {
        let vm = this
        let sockets = this.$options['sockets']

        if (hasProxy) {
          this.$options.sockets = new Proxy({}, {
            set (target, key, value) {
              // 添加监听
              Emitter.addListener(key, value, vm)
              target[key] = value
              return true
            },
            deleteProperty (target, key) {
              // 移除监听
              Emitter.removeListener(key, vm.$options.sockets[key], vm)
              delete target.key
              return true
            }
          })
          if (sockets) {
            Object.keys(sockets).forEach((key) => {
              // 给$options中添加sockets中的key
              this.$options.sockets[key] = sockets[key]
            })
          }
        } else {
          // 将对象密封,不能再进行改变
          Object.seal(this.$options.sockets)

          // if !hasProxy need addListener
          if (sockets) {
            Object.keys(sockets).forEach(key => {
              // 添加监听
              Emitter.addListener(key, sockets[key], vm)
            })
          }
        }
      },
      beforeDestroy () {
        if (hasProxy) {
          let sockets = this.$options['sockets']

          if (sockets) {
            Object.keys(sockets).forEach((key) => {
              // 销毁前如果代理存在sockets存在则移除$options中给sockets添加过的key
              delete this.$options.sockets[key]
            })
          }
        }
      }
    })
  }
}

插件重构

前面我们把插件整体的读了一遍,接下来就可以用Vue3 TypeScript来重构它了。

作者的代码写的很精巧,逻辑方面不用做改动,我只是将它的代码实现从js改成了ts,修改了被Vue3废弃的写法,虽然做的修改比较简单,但是学到了作者的插件设计思想以及踩到的一些ts的坑,收获还算挺大。

接下来,就跟大家分享下我的重构过程以及踩到的一些坑。

安装依赖

在用ts重构前,我们需要先安装相关依赖包,执行下述命令即可安装。

代码语言:javascript复制
yarn add typescript prettier eslint eslint-plugin-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser standard --dev

随后,在项目根目录创建tsconfig.json文件,为typescript的配置文件,添加下述配置,设置"declaration": true即可在运行tsc命令时自动在types目录下生成声明文件。

代码语言:javascript复制
{
  "exclude": [
    "./node_modules"
  ],
  "compilerOptions": {
    "lib": [
      "esnext",
      "dom"
    ],
    "baseUrl": "./",
    "outDir": "./dist/", // 打包到的目录
    "target": "ES2015", // 转换成的目标语言
    "module": "esnext",
    "declaration": true,// 是否生成声明文件
    "declarationDir": "./dist/types/",// 声明文件打包的位置
    "strict": true, // 开启严格模式
    "sourceMap": true, // 便于浏览器调试
    "moduleResolution": "node", // 使用node模块
    "experimentalDecorators": true, // 使用装饰器
    "skipLibCheck": true, // 跳过库检查
    "esModuleInterop": true, // es模块互操作
    "allowSyntheticDefaultImports": true, // 允许默认导入
    "noImplicitAny": true, // 不能使用any
    "noImplicitThis": true, // 不能使用this
    "alwaysStrict": true, // 严格模式
    "noUnusedLocals": true, // 不能有未使用的变量
    "noUnusedParameters": true, // 不能有未使用的参数
    "noImplicitReturns": true // 必须声明返回值
  },
  "include": [
    "src/**/*.ts"
  ]// 要打包的文件
}

修改已经废弃的语法

在插件的入口文件Main.js中,插件需要向Vue全局挂载属性,即Vue.prototype.xx = xx,在vue3中这一写法已经废除,需要用app.config.globalProperties.xx = xx来替换,重构好的main.ts文件部分代码如下:

代码语言:javascript复制
import { App } from "vue";

export default {
    install(app: App, connection: string, opts: websocketOpts = { format: "" }): void {
      // ... 其它代码省略 ....//        
      opts.$setInstance = (wsInstance: EventTarget) => {
            // 全局属性添加$socket
            app.config.globalProperties.$socket = wsInstance;
        };
    }
}

完整代码请移步:src/Main.ts

beforeDestroy生命周期被移除

在插件的入口文件app.mixin中,组件销毁前它需要从全局移除已经添加在全局的属性,即beforeDestroy,在Vue3中这一写法已经被移除,需要用beforeUnmount来替换,其部分代码如下:

代码语言:javascript复制
import { App } from "vue";

export default {
    install(app: App, connection: string, opts: websocketOpts = { format: "" }): void {
      // .... 其它代码省略 ....//
      app.mixin({
                    beforeUnmount() {
                if (hasProxy) {
                    const sockets = this.$options["sockets"];

                    if (sockets) {
                        Object.keys(sockets).forEach((key) => {
                            // 销毁前如果代理存在sockets存在则移除$options中给sockets添加过的key
                            delete this.$options.sockets[key];
                        });
                    }
                }
            }
      })
    }
}

扩展全局对象

Observer.ts中,需要向Websocket中添加sendObj方法,这在js中很简单,直接websocket.sendObj = ()=>{}即可。但是在ts中它就会报错,Websocket中不存在sendObj方法,一开始我想在lib.dom.d.ts中定义这个方法,但是想了想这样做不妥,不能修改全局的库声明文件,毕竟这是插件。

image-20201102210949765

经过我的一番折腾后,在ts的文档中找到了答案,ts的官方文档描述如下。

image-20201102210650833

正如官方文档所描述,ts查找声明文件会从当前文件开始找,我们只需要在当前类中用declare global来扩展即可,代码如下:

代码语言:javascript复制
// 扩展全局对象
declare global {
    // 扩展websocket对象,添加sendObj方法
    interface WebSocket {
        sendObj(obj: JSON): void;
    }
}

添加上述代码后,报错就解决了,完整代码请移步:src/Observer.ts

image-20201102211101120

回调函数类型定义

Emitter.ts文件里,添加监听的方法调用者可以传一个回调函数进去,这个回调函数的参数是未知的,因此就需要给他指定正确的类型,一开始我用的Function类型,但是eslint报错了,他不建议这么使用,报错如下:

image-20201102212611648

经过我的一番折腾后,找到了如下解决方案,声明类型时只需要将参数解构即可。

代码语言:javascript复制
    addListener(label: T, callback: (...params: T[]) => void, vm: T): boolean {
        if (typeof callback === "function") {
            // label不存在就添加
            this.listeners.has(label) || this.listeners.set(label, []);
            // 向label添加回调函数
            this.listeners.get(label).push({ callback: callback, vm: vm });
            return true;
        }
        return false;
    }

完整代码请移步:src/Emitter.ts

验证插件能否正常工作

插件重构完成后,我们将整个项目的文件复制到一个vue3项目的node_modules/vue-native-websocket下,替换原先的文件。

image-20201103001444839

在main.ts中导入并使用插件。

代码语言:javascript复制
import { createApp } from "vue";

const app = createApp(App);
// 使用VueNativeSock插件,并进行相关配置
app
  .use(store)
  .use(router)
  .mount("#app");
// 使用VueNativeSock插件,并进行相关配置
app.use(
  VueNativeSock,
  `${base.lkWebSocket}/${localStorage.getItem("userID")}`,
  {
    // 启用Vuex集成
    store: store,
    // 数据发送/接收使用使用json
    format: "json",
    // 开启手动调用 connect() 连接服务器
    connectManually: true,
    // 开启自动重连
    reconnection: true,
    // 尝试重连的次数
    reconnectionAttempts: 5,
    // 重连间隔时间
    reconnectionDelay: 3000
  }
);

在组件中与websocket服务端建立连接

代码语言:javascript复制
  mounted() {
    // 判断websocket是否连接: 当前为未连接状态并且本地存储中有userID
    if (
      !this.$store.state.socket.isConnected &&
      localStorage.getItem("userID") !== null
    ) {
      // 连接websocket服务器
      this.$connect(`${base.lkWebSocket}/${localStorage.getItem("userID")}`);
    }
  }

调用sendObj方法来发送消息。

代码语言:javascript复制
  this.$socket.sendObj({
       msg: msgText,
       code: 0,
       username: this.$store.state.username,
       avatarSrc: this.$store.state.profilePicture,
       userID: this.$store.state.userID
  });

调用onmessage方法来接收服务端消息。

代码语言:javascript复制
  // 监听消息接收
    this.$options.sockets.onmessage = (res: { data: string }) =>    {

    }

完整代码请移步:chat-system,最终结果如下:

image-20201103002555455

给作者提个PR

顺便给作者提个pr,将我修改的代码丢给作者

0 人点赞