前言
前几天我用Vue3重构了我那个Vue2的开源项目,最后还遗留了一个问题:项目中用的一个websocket插件还不能正常使用。于是,我决定重写这个插,让其支持Vue3。
本文将记录下重写这个插件的过程并将其发布至npm仓库,顺便给插件作者提个PR,欢迎各位感兴趣的开发者阅读本文。
插件解读
image-20201103005333494
如上图所示就是即将要重构的插件,目前有735个star,我们先将插件代码clone
到本地。
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
方法。
import Observer from './Observer'
import Emitter from './Emitter'
export default {
install (Vue, connection, opts = {}) {
// ... 其它代码省略 ... //
}
}
那么,我们就先来看看第一个引入的文件Observer.js
的代码。
如下所示,它引入了Emitter.js
文件,以及它自身的实现代码。
import Emitter from './Emitter'
export default class {
constructor (connectionUrl, opts = {}) {
// ... 其它代码省略... //
})
}
Emitter.js
同样的,我们先从他引入的文件开始读,即Emitter.js
,其代码如下,我读完代码后并添加了相关注释,它实现了一个事件监听队列,以及一个事件触发函数emit
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
部分代码如下所示,他定义了插件调用者可以传的参数以及初始值。
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连接。
// 连接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服务端建立链接。
// 重新连接
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
类来管理。
// 事件分发
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
事件处理函数,用于触发前的事件处理。
/**
* 触发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事件监听,组件销毁前移除全局添加的方法
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目录下生成声明文件。
{
"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文件部分代码如下:
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
来替换,其部分代码如下:
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
来扩展即可,代码如下:
// 扩展全局对象
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,将我修改的代码丢给作者