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

前言

关于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();
export function createRequest any, CB extends (buffer: Uint8Array | Reader, askId?: number) => any>
  (req: F, cb: CB): (...args: FormData) => Promise> {  
  const askId = getAskId();
  return (...args) => new Promise((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,可进行全局的事件监听,目前主要用于快捷键监听。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应用的一些分享)