前言

前几天我用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重构前,我们需要先安装相关依赖包,执行下述命令即可安装。

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

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

    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中导入并使用插件。

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服务端建立连接

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

调用sendObj方法来发送消息。

  this.$socket.sendObj({
       msg: msgText,
       code: 0,
       username: this.$store.state.username,
       avatarSrc: this.$store.state.profilePicture,
       userID: this.$store.state.userID
  });

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

  // 监听消息接收
    this.$options.sockets.onmessage = (res: { data: string }) =>    {

    }

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

image-20201103002555455

给作者提个PR

顺便给作者提个pr,将我修改的代码丢给作者????vue-native-websocket/pulls

image-20201103005547871

发布至npm仓库

至此,插件的重构就结束了,我们修改package.json中的build命令,替换为tsc,修改入口文件main以及类型声明文件入口types。部分呢代码如下,完整代码请移步:package.json

{
    "main": "dist/Main.js",
    "types": "dist/types/Main.d.ts",
    "scripts": {
    "build": "tsc"
  }
}

随后,执行yarn run build命令,就会在项目的根目录下创建dist文件夹并将打包后的js文件放入其中。

image-20201102214629366

dist目录中的文件就是我们要发布至npm仓库的包,在发布至npm仓库之前,我们要先做一些事情,让插件更加规范化。

定义新版本推送规范

我们在项目根目录创建PUBLISH.md文件,用于告知开发者修改本插件后如何进行推送。

## 新版本推送规范

- 对插件进行修改

- 执行 `yarn build` 来生成打包后的文件

- 修改`package.json`中的版本号

- 提交你的修改

- 运行`package.json`中的`changelog`命令来生成更新记录

- 最后将项目推送到你的仓库,然后为主仓库创建一个Pull request

编写插件使用文档

作为一个插件,README.md文件是必不可少的,这个文件会告诉开发者如何使用这个插件,完整代码请移步:README.md

定义提交规范

无规矩不成方圆,插件亦是如此。我们需要通过一些工具来定义提交代码时规范,这样会使插件更易维护。

安装依赖

执行下述命令安装我们需要的插件包

yarn global add commitizen

上述命令会全局安装commitizen工具,它的作用是提供一个脚本工具给到开发者来按照指引生成符合规范的 commit 信息。

执行下述命令,既可将其保存到package.json的依赖项,将config.commitizen配置添加到package.json的根目录,该配置告诉commitizen,当我们尝试提交此仓库时,我们实际上希望使用哪个适配器。

commitizen init cz-conventional-changelog --save-dev --save-exact

然后我们就可以通过git cz命令,来提交 git commit

image-20201102221728435
强制执行commit规范

使用commitizen工具,我们可以通过执行git cz命令来提交符合规范的 commit 信息,但是在开发中,插件开发者不是通过命令行的方式来提交 commit 的,如果我们要强制校验其他人通过 vscode/webstorm 等其他工具的方式提交 commit,可以使用commitlint+husky的方式来配合使用。

安装commitlint检查我们的 commit message 是否符合常规的提交格式,通过下述命令安装。

yarn add  @commitlint/config-conventional @commitlint/cli --dev

在package.json中添加配置,指定提交规范,这里我们选用Angular 格式的配置

  "commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },

做完上述操作后,我们就可以验证命令提交的commit信息校验了,接下来我们来配合husky实现ide的commit校验,执行下述命令安装依赖包。

yarn add husky --dev

在package.json中添加commit-msg 的钩子,用于检查commitlint规范。

  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  }

完成上述配置后,不管我们通过什么方式来提交 commit,如果 commit 信息不符合我们的规范,都会进行报错。

自动生成CHANGELOG

如果commit都符合刚才定义的Angular格式,那么发布新版本时, CHANGELOG 就可以用脚本自动生成。

此处我们使用conventional-changelog-cli 工具来生成它,执行下述命令来安装依赖。

yarn global add conventional-changelog-cli

在项目根目录执行下述命令,即可生成CHANGELOG.md 文件:

conventional-changelog -p angular -i CHANGELOG.md -s

我们可以将上述命令配置进package.json中的scripts中,这样我们就可以通过yarn run changelog来生成了

  "scripts": {
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
  },

生成的文件内容如下所示:

image-20201102235321074

插件发布

最后,我们就可以将插件发布至npm仓库了。

此处,重点内容在插件的重构,想从零开始学插件发布步骤的开发者可移步我的另一篇文章:Vue实现一个全屏加载插件并发布至npm仓库

在终端进入项目根目录,执行下述命令,登录npm仓库,输入自己的用户名和密码

npm login
image-20201103003251083

执行下属命令发布至npm仓库。

npm publish --access public
image-20201103003532065

插件发布成功,我们去npm仓库搜一下vue-native-websocket-vue3,如下所示,已经可以搜到了

image-20201103003826881

npm仓库地址:vue-native-websocket-vue3

最后,我们就可以在项目中使用yarn来安装使用了。

image-20201103004600660

写在最后

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注????

  • 本文首发于掘金,未经许可禁止转载????

Logo

前往低代码交流专区

更多推荐