关于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<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显示,仅保留占位;可复用的组件比如输入框,采用单例
  • 性能相关
    主进程资源按需分步加载;优化处理内存数据方式;查库写库优化;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,026评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,655评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,726评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,204评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,558评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,731评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,944评论 2 314
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,698评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,438评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,633评论 2 247
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,125评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,444评论 3 255
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,137评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,103评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,888评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,772评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,669评论 2 271

推荐阅读更多精彩内容