美文网首页
关于electron + vue开发IM应用的一些分享

关于electron + vue开发IM应用的一些分享

作者: 忘川蒿里丶 | 来源:发表于2022-05-16 18:20 被阅读0次

    前言

    关于electron

    实现以前端技术栈,开发桌面端应用的框架,且可以跨平台支持,兼容Mac、Windows、Linux

    electron的一些特点

    1.主进程和渲染进程

    electron应用核心分为主进程和渲染进程两个部分,其中应用本身(app)、窗口(BrowserWindow)等涉及操作系统底层的均为主进程内容;而渲染页面,事件触发等前端相关的,均为子进程。
    electron与web端的主要区别即主进程的操作,且又可通过渲染进程向主进程传递消息,触发主进程的事件,从而实现web代码对底层的操控。
    主进程和渲染进程的通信方式:

    • 渲染进程监听事件,主进程发送对应消息触发回调
    this.$electron.ipcRenderer.on('app-quit', (e, data) => {
          // 回调函数
    })
    mainWindow.webContents.send('app-quit')
    
    • 主进程监听事件,渲染进程发送对应消息触发回调
    ipcMain.on('closeAutoStart', () => {
      // 回调函数
    })
    ipcRenderer.send('closeAutoStart')
    

    2.窗口

    electron应用初始化的时候都需要创建一个主窗口

    mainWindow = new BrowserWindow({
        height: 800,
        width: 1280,
        minHeight: 800,
        minWidth: 1280,
        useContentSize: true,
        frame: false,
        fullscreenable: false,
        icon: path.resolve(__static, 'tray1.ico'), 
        webPreferences: {
          webSecurity: false,
          nodeIntegration: true,
          enableRemoteModule: true,
        },
        show: false
      })
    
      mainWindow.loadURL(winURL);
    

    其中winURL即为项目启动地址

    如何创建一个子窗口,以图片预览为例

    let _previewWindow = new BrowserWindow({
            minWidth: windowWidth,
            minHeight: windowHeight,
            width: windowWidth,
            height: windowHeight,
            x: screenWidth / 2 - windowWidth / 2, //位移居中
            y: screenHeight / 2 - windowHeight / 2, //位移居中
            useContentSize: true,
            movable: true,
            icon: path.resolve(__static, 'tray1.ico'),
            frame: false, //是否显示默认工具栏
            webPreferences: {
                nodeIntegration: true,
                sandbox: true,
                devTools: false,
                enableRemoteModule: true,
                preload: path.resolve(__static, 'preload.js')
            },
            skipTaskbar: false, //任务栏图标
            show: true,
            // window_id
        })
        _previewWindow.loadFile(path.resolve(__static, 'preview/index.html'))
    

    此处采用了新起一个项目,并单独打包,直接加载打包后的首页。此方法的好处是不用重复加载一次原项目的冗余资源,极大提升窗口加载速度,并减少内存消耗。
    另外使用了preload参数,preload.js为所有窗口共用,所有可以在其中定义事件,并且在新的子项目中调用,同时触发小智内部的事件
    此方案之后应该为需要新起窗口时的统一处理方案。
    preload.js

    window.previewImageLoaded = function () {
        ipcRenderer.send("picture-preview-loaded");
    }
    
    ipcRenderer.on("changeImgData", (event, data) => {
        window.previewChangeImgData ? window.previewChangeImgData(data) : ''
    });
    

    通过修改全局变量的方式实现父向子数据传递,通过调用事件发送消息的方式实现子向父的事件传递。

    小智的核心技术方案

    1、websocket连接

    • 心跳与续期
      发送心跳时会判断与本地token有效期是否超过24小时,如果超过,向服务器发送参数,同时重置本地token有效期。这样可以保证token在线时每天续期,不会过期。
    function heartbeat() {
      console.log('socket', 'ping')
      hearbeat_timer = setInterval(() => {
        // 发心跳的时候超过一天更新用户token有效期
        let tempTime = Number(localStorage.getItem('XZUserTokenDate'))
        if (tempTime && new Date().getTime() - tempTime > 24 * 60 * 60 * 1000) {
          var req = new proto.pb.C2SHeartbeat()
          req.Token = String(eStore.get('XZUserToken'))
          sendSocketMsg(
            proto.pb.MSG.Heartbeat,
            proto.pb.C2SHeartbeat.encode(req).finish(),
            null
          )
          localStorage.setItem('XZUserTokenDate', String(new Date().getTime()))
        } else {
          sendSocketMsg(proto.pb.MSG.Heartbeat, 0, null)
        }
      }, 5000)
    }
    
    • 重连机制
      连接异常时,直接弹出服务器异常弹窗,然后每5秒自动重连,重连20次后不再自动重连,转为需手动重连。
      可以保证后台下的无感知重连。
    reConnect() {
          console.log("重新连接" + this.connectTime);
          Log.logInfo("重新连接" + this.connectTime + "_" + new Date().getTime());
          if (this.autoReconnect) {
            if (this.connectTime < this.connectTimes) {
              this.connectTime++;
              this.inConnect = 5;
              this.websocketTimeout = window.setInterval(() => {
                this.inConnect--;
                if (this.inConnect === 0) {
                  clearInterval(this.websocketTimeout);
                  setReconnectStatus(true);
                  initSocket(() => {});
                }
              }, 1000);
            } else {
              this.autoReconnect = false;
              clearInterval(this.websocketTimeout);
            }
          }
        },
    

    2、请求接口

    因为websocket为异步消息,一开始是通过发送消息时记录数据,收到消息时调用store修改值,发送端监听store里面的变量来进行回调处理。此方案会极大增加逻辑复杂度,且不好维护。所以后面封装了异步转同步的方法。核心代码如下

    export const ReqMap = new Map<number, { resolve: Function, reject: Function }>();
    export function createRequest<F extends (askId: number, ...args: any[]) => any, CB extends (buffer: Uint8Array | Reader, askId?: number) => any>
      (req: F, cb: CB): (...args: FormData<F>) => Promise<ReturnType<CB>> {  
      const askId = getAskId();
      return (...args) => new Promise<Uint8Array | Reader>((resolve, reject) => {
        req(askId, ...args);
        ReqMap.set(askId, { resolve, reject });
      }).then((buffer) => {
        return cb(buffer, askId);
      });
    }
    
    export function responseHandler(msg: Uint8Array | Reader, askId: number) {
      const req = ReqMap.get(askId);
      if (req) {
        req.resolve(msg);
        ReqMap.delete(askId);
      }
    }
    

    主要逻辑是构建一个Map对象,发送消息时,将Promise的回调及对应askId存于Map内。收到消息时调用对应askId的promise.resolve方法,从而执行回调。其中askId默认生成
    例子:

    export function getSessionMembersCount(askId: number, sessionId: number) {
      try {
        var res = new client.pb.C2SAskSessionMemberCount()
        res.SessionId = sessionId
        sendSocketMsg(
          client.pb.MSG.AskSessionMemberCount,
          client.pb.C2SAskSessionMemberCount.encode(res).finish(),
          askId
        )
      } catch (e) {
        console.error('操作失败' + e)
      }
    }
    export function sessionMembersCountRes(
      buffer: Uint8Array | Reader,
      askId: number
    ) {
      var res = client.pb.S2CAskSessionMemberCount.decode(buffer)
      if (res.Success.Code == client.pb.ErrorCode.Ok) {
        return res.Count
      } else {
        Message.error(returnErrorMsg(res.Success.Code))
        return null
      }
    }
    createRequest(
            getSessionMembersCount,
            sessionMembersCountRes
          )(this.gid).then((res) => {
            if (res) {
              this.channelMemberCount = res
              this.topWidthChange()
            }
          })
    

    首先传入发送消息和消息回调的处理方法,第二个可以传入消息回调需要的参数。会生成一个Promise对象,并且将其resolve方法存入,在消息回调时调用此resolve方法。从而实现一个闭环,即发送消息 => 收到消息 => 触发resolve,完成Promise,并且通过askId一一对应。从而省去用store的值才能监听发送消息和收到消息之间的对应关系。

    3、关于数据库

    目前使用的是场景主要是存储消息
    初始化数据库(使用的typeorm建立better-sqlite3数据库连接,其中better-sqlite3需要vscode2015/2017环境)
    TODO:尝试用原生语句是否能加快速度

    // 查询
    var res = await getRepository(Msg, dbName)
          .createQueryBuilder('msg')
          .where('sessionId=:sessionId', { sessionId: sessionId })
          .andWhere('msg.seq > :min', { min: minId - 10 })
          .andWhere('msg.seq < :max', { max: minId + 11 })
          .orderBy('seq', 'DESC')
          .getMany()
    // 添加
    await getConnection(dbName)
                .createQueryBuilder()
                .insert()
                .into(Msg)
                .values([_msg])
                .execute()
    

    4、小智的存储数据方式

    首先包括消息的存储方式:数据库
    其次关于频道session等,均存于内存
    用户token\已下载文件列表(需要持久化的),存于electron-store
    服务器列表本地目录,存于用户config.json下,
    其他不需要持久化的用户信息、服务器地址ID,存于localstorage下面
    TODO:存储方式略乱,应细分为两种,

    • 需要持久化存储的,如消息、已下载文件列表、用户token及是否自动登录(为了兼容意外关闭),根据查询要求和数据量,可采用数据库和eStore两种方式
    • 单次登录内使用,不需要持久化,如session、频道、团队等,可存于内存、$store

    5、小智的数据通信方式

    • 主进程和渲染进程通信
    • 通过store监听实现全局通信(目前主要使用的方式)
      即收到推送消息后,进行数据处理,并将操作内存里的值,或者将值直接赋予store.state。页面上,监听store.getters,监听到变化后即可做对应操作
    • 简单的父子组件通信 :event,:data,$emit
    • 全局事件总线eventBus,可进行全局的事件监听,目前主要用于快捷键监听。on创建监听事件,emit调用监听事件。小tips:使用eventBus一定要注意重复使用的页面里,destroy页面时一定得$off移除事件,不然会出现事件未能解绑导致的内存泄漏。

    6、关于内存泄漏

    小智目前已出现多次内存泄漏,而且目前依然有一些没有发现。
    常见造成内存泄漏的情况:

    • 未解绑的事件(绝大多数情况),包括切换页面时,未销毁的eventBus、document.on等事件监听
    • 未销毁的定时器,一些setInterval,快速切换时,并没有执行完成并销毁,如果不手动销毁也会导致内存泄漏
    • 重复的new 对象,目前主要出现在一起统一处理方法上,如处理msg\session,会导致数据层面的内存泄漏,因影响比较小所以暂未处理
    • keep-alive主动缓存,目前少数页面有使用,缓存后无法彻底销毁(已尝试各种方法均无效),但是可以实现0延迟加载页面,慎用。

    如何检测:
    主要利用chrome memory快照,查询detached 相关的dom,即未被销毁的dom元素,按层级慢慢找,然后慢慢定位具体操作,然后找关联的事件绑定是否有未解绑的。有一些第三方组件,比如quill也会有一些自带绑定事件导致内存泄漏,目前已处理了其回车之前的内存泄漏,之后还可以考虑采用单例的方式处理。

    7、小智能打开外部链接

    目前是使用iframe内嵌的方式,根据应用名称来创建iframe,并放于最顶层,通过绝对定位的方式处理位置。同时,记录所有创建的iframe,通过修改其ClassName来控制显示隐藏,为避免网页缓存,URL每次重新打开新增时间戳。
    同时为了实现切换时保留缓存,iframe不会自动销毁,只是隐藏,除非手动关闭。
    TODO:electron内置组件BrowserView尝试

    
    let tempindex = this.tabDatas.findIndex((tab) => {
        return tab.name === app.Name
      })
      if (tempindex == -1) {
        this.tabDatas.push({
          name: app.Name,
          url: jumpUrl,
        })
        let iframe = document.createElement('iframe')
        iframe.className = 'custom_iframe'
        iframe.src = jumpUrl + `&tempTIme=${new Date().getTime()}`
        iframe.setAttribute('frameborder', 0)
        document.body.appendChild(iframe)
        this.iframeArray.push({
          name: app.Name,
          iframe: iframe,
        })
      }
    
    

    8、关于小智桌面端未来的优化方向

    • 存储相关
      team从数据库存储改为内存存储,测试原生语句查库的使用,存库和查库方式及效率优化。
    • 内存相关
      处理数据内存泄漏;不在屏幕内的消息设法减少其dom显示,仅保留占位;可复用的组件比如输入框,采用单例
    • 性能相关
      主进程资源按需分步加载;优化处理内存数据方式;查库写库优化;

    相关文章

      网友评论

          本文标题:关于electron + vue开发IM应用的一些分享

          本文链接:https://www.haomeiwen.com/subject/jibiurtx.html