前言
关于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,可进行全局的事件监听,目前主要用于快捷键监听。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显示,仅保留占位;可复用的组件比如输入框,采用单例 - 性能相关
主进程资源按需分步加载;优化处理内存数据方式;查库写库优化;