一、Electron是什么?
一个构建桌面应用的框架。
二、构建Electron项目
Electron可以使用html+css+js进行原生开发。也可以通过electron-vite构建工具快速创建一个模板。本文是基于模板electron+vue模板进行介绍。
目录结构
创建项目
:可以通过这行命令创建你想使用的模板。
sql
pnpm create @quick-start/electron my-app --template vue/react/...
或者通过这行命令克隆一个Electron+vue3+Typescript项目模板。
bash
npx degit alex8088/electron-vite-boilerplate electron-app
然后进入项目:cd 项目名
,安装依赖:npm i
,运行:npm start
。
启动后会打开一个窗口。
目录结构
:
src目录:
electron分为主进程和渲染进程,主进程代码在main
下,渲染进程也就是我们业务代码在renderer
目录下
看下主进程入口文件的主要内容:
ts
// src/main/index.ts 主进程入口文件
// 创建窗口
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false, // 窗口创建时是否显示
resizable:false,//不能修改窗口大小
autoHideMenuBar: true,//自动隐藏菜单栏,除非按了`Alt`键
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
打开开发者工具
ts
mainWindow.webContents.openDevTools();
或者直接按f12
显示窗口
ts
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
开发环境加载本地的地址,使用的loadURL,生产环境加载的对应磁盘的文件,使用是loadFile
arduino
// 判断开发环境,展示对应的窗口
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
minWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
minWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
通信
主进程到渲染进程的通信
渲染进程:
arduino
electron.ipcRenderer.invoke('event_name',{
key:value
})
主进程:
javascript
// 接收两个参数,第二个参数是渲染进程传过来的值
ipcMain.handle('event_name', ( event, data )=>{
})
渲染进程到主进程的通
:
主进程:
php
mainWindow.webContents.send('名称', {
a:1,
b:2
})
渲染进程:
javascript
electron.ipcRenderer.on('名称',(e, msg)=>{ msg是参数 })
窗口操作
菜单、托盘
菜单
ts
// src/main/index.ts
// 引入菜单
import { Menu } from 'electron'
const template = [
{
label: '菜单1',
submenu: [
{
label: '选项1'
},
{
label: '选项2'
},
{
label: '选项3'
}
]
},
{
label: '菜单2',
submenu: [
{
label: '劈里啪啦'
}
]
}
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
修改主进程的内容要重启应用
还可以通过一些属性,给菜单设置快捷键、勾选、禁用等,更多参考官网:
ts
{
label: '菜单1',
submenu: [
{
label: '选项1',
// 快捷键
accelerator: 'ctrl+L' // macOS是command
},
// 分割线
{ type: 'separator' },
{
label: '选项2',
// 是否勾选
checked: true,
type: 'checkbox',
click(event) {
// 点击勾选或取消勾选
event.checked = false
}
},
{
label: '选项3',
// 禁用
enabled: false
}
]
},
效果如下:
它还有一些预定义的菜单,通过设置菜单的role
为它指定的菜单名即可,如下:
ts
// 直接用它整个的预定义菜单,子菜单是固定的
{ role: 'editMenu' },
// 或者根据自定义submenu选择自己需要的菜单
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' }
]
},
效果:
role
的属性值可参考官网
还可以通过判断当前系统是否是macOS
来给不同系统添加不同菜单。
ts
// 判断当前系统是否是macOS
const isMac = process.platform === 'darwin';
const template = [
// { role: 'appMenu' }
...(isMac
? [{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
}]
: [])
]
托盘
托盘是啥呢?
就是这东西(windows系统)。
创建托盘
ts
const { app, Menu, Tray } = require('electron')
let tray = null
app.whenReady().then(() => {
tray = new Tray('/path/to/my/icon')
const contextMenu = Menu.buildFromTemplate([
{ label: 'Item1', type: 'radio' },
{ label: 'Item2', type: 'radio' },
{ label: 'Item3', type: 'radio', checked: true },
{ label: 'Item4', type: 'radio' }
])
tray.setToolTip('This is my application.')
tray.setContextMenu(contextMenu)
})
隐藏标题栏
一个桌面应用中肯定会有很多窗口,很多窗口不需要显示标题栏,可以通过设置无边框窗口(frame:false
),隐藏,但是会存在窗口无法拖动的问题,有两种解决方案,如下:
1、设置样式
ts
// src/main/index.ts
const mainWindow = new BrowserWindow({
// frame 设置为 false 时可以创建一个无边框窗口
frame: false,
width: 900,
height: 670,
show: false,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
但是这种方法会导致窗口无法移动,可以在渲染进程里面添加样式:-webkit-app-region: drag
,这样窗口就可以拖动了,但是这会导致设置可拖动的那个盒子的点击事件失效。如下:header
可以拖动窗口,但是点击事件失效(我们可以把点击事件写在header
外面,然后通过定位放到需要的位置),main
部分点击事件有效但不可拖动。
vue
<template>
<div class="box">
<header>
<button @click="btn">头部按钮</button>
</header>
<main>
<button @click="btn">按钮</button>
</main>
</div>
</template>
<script lang="ts" setup>
const btn = () => {
console.log(1)
}
</script>
<style lang="less">
header {
-webkit-app-region: drag;
}
main {
// -webkit-app-region: no-drag;
}
</style>
2、自定义拖动事件
思路是:拖拽窗口的时候,在渲染进程记录鼠标的x
、y
坐标,然后将坐标传给主进程,主进程通过setPosition(x,y)
设置窗口位置。 渲染进程监听窗口窗口拖动:
vue
<template>
<div class="box" @mousedown="mousedown">
<header></header>
<main>
<button @click="btn">按钮</button>
</main>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const mouseDown = ref(false)
const disX = ref(0)
const disY = ref(0)
const mousedown = (e) => {
mouseDown.value = true
disX.value = e.x
disY.value = e.y
document.onmousemove = (ev) => {
if (mouseDown.value) {
const x = ev.screenX - disX.value
const y = ev.screenY - disY.value
// 向主进程通信,传入坐标
electron.ipcRenderer.invoke('custom-adsorption', { x, y })
}
}
document.onmouseup = (ev) => {
mouseDown.value = false
}
}
</script>
主进程接收渲染进程传过来的坐标,设置窗口坐标:
ts
ipcMain.handle('custom-adsorption', (event, res) => {
if (窗口存在且窗口是显示的) {
窗口.setPosition(res.x, res.y)
}
})
隐藏窗口标题栏的标题
ts
const mainWindow = new BrowserWindow({
titleBarStyle: 'hidden',//隐藏标题
titleBarStyle: 'hidden', //隐藏标题
titleBarOverlay: {
color: 'orange',
symbolColor: 'green'
},
// ...
})
只隐藏了标题,标题栏的窗口操作(放大、缩小、关闭)还存在。这个也存在不能拖动的问题,解决方式同上。
创建窗口、关闭窗口、锁定窗口
渲染进程:
vue
<template>
<div class="box">
<main>
<button @click="createWin">创建窗口</button>
<button @click="close">关闭窗口</button>
<button @click="kiosk">锁定窗口</button>
</main>
</div>
</template>
<script lang="ts" setup>
// 渲染进程点击创建窗口,向主进程通信在主进程中创建
const createWin = () => {
electron.ipcRenderer.invoke('createWin')
}
// 渲染进程点击关闭窗口,向主进程通信在主进程中关闭
const close = () => {
electron.ipcRenderer.invoke('closeWindow')
}
// 锁定窗口
const kiosk = () => {
electron.ipcRenderer.invoke('kiosk', {
isKiosk: true
})
}
</script>
主进程:
ts
// 创建窗口
ipcMain.handle('createWin', (event) => {
new BrowserWindow({
width: 300,
height: 300,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
})
//关闭窗口
ipcMain.handle('closeWindow', (event, data) => {
mainWindow.close()
// 或使用destory,与close区别是close关闭可以监听close事件。mainWindow.on('close',()=>{})
// mainWindow.destory()
})
//锁定窗口
ipcMain.handle('kiosk', (event, data) => {
mainWindow.setKiosk(true)
})
ts
// 关闭整个软件
// 可以监听berfore-quit事件
app.quit()
// 或者
// app.exit()
官方提供的锁定API是setKiosk
,但是使用这个进行锁定,窗口会自动放大,所以不能用这个。锁定就是窗口不能移动,可以结合上面的自定义拖动窗口事件,在用户点击锁定的时候,让窗口不能移动,如下:
ts
let isKiosk = null;
//锁定窗口
ipcMain.handle('kiosk', ( event, data )=>{
isKiosk = data.isKiosk
})
// 拖动窗口
ipcMain.handle('custom-adsorption',( event, res )=>{
if( !isKiosk ){ // 锁住的时候不能拖动
mainWindow.setPosition( res.x , res.y );
}
})
举个例子
vue
是单页面应用,通过vue-router
将组件映射到路由上,切换路由渲染不同的视图。而在electron
中,一个路由就代表一个窗口。切换路由的时候,需要向主进程通信,让主进程打开一个新的窗口。举个例子看下如何实现:
安装vue-router
:pnpm add vue-router
。
渲染进程(renderer
目录下),创建路由及对应的路由组件:
ts
// renderer/src/router/index.ts
import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../pages/home.vue'
import About from '../pages/about.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About }
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router;
挂载路由
ts
// renderer/src/mian.ts
import { createApp } from 'vue'
import App from './App.vue'
// 挂载路由
import router from './router'
createApp(App).use(router).mount('#app')
vue
// renderer/src/pages/home.vue
<template>
<div class="container">
<header>
<h1>home</h1>
<h1 @click="goAbout">about</h1>
</header>
</div>
</template>
<script setup>
// 点击about向主进程通信
const goAbout = () => {
electron.ipcRenderer.invoke('aboutPage')
}
</script>
<style scoped>
header {
display: flex;
justify-content: space-between;
align-items: center;
width: 200px;
font-weight: bold;
}
</style>
主进程接收事件后首先需要判断about窗口是否已经被创建了,没有被创建再去创建,防止点击多次创建多个窗口,如下:
ts
// main/index.ts
// 声明一个变量用来保存about窗口信息
const context = {
isShow: false, //显示 & 隐藏
isKiosk: false, //锁定
aboutWindow: null //窗口
}
// 接收渲染进程事件
ipcMain.handle('aboutPage', () => {
if (context.aboutWindow === null) {
// 窗口不存在,创建窗口
createAbout()
} else { // 窗口存在,判断是显示状态还是隐藏状态
if (context.isShow) {//显示状态再次点击隐藏
hideWindow()
} else {//隐藏状态再次点击显示
showWindow()
}
}
})
// 隐藏窗口
const hideWindow = () => {
if (context.aboutWindow && !context.aboutWindow.isDestroyed()) {
context.isShow = false
context.aboutWindow.hide()
}
}
// 展示窗口
const showWindow = () => {
if (context.aboutWindow && !context.aboutWindow.isDestroyed()) {
context.isShow = true
context.aboutWindow.show()
}
}
ts
//创建子窗口
const createAbout = () => {
//创建
context.aboutWindow = new BrowserWindow({
width: 300,
height: 300,
show: false,
frame: false, //无边框
parent: mainWindow,//父子窗口,子窗口跟着父窗口一起移动
transparent: true, // 透明窗口
alwaysOnTop: true, //置顶
hasShadow:false, //取消阴影
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
//显示
context.aboutWindow.on('ready-to-show', () => {
context.aboutWindow.show()
})
context.isShow = true
//显示的路由或页面(html)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
//开发阶段打开的文件路径
context.aboutWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/#/about')
} else {
// 生产阶段打开的文件路径
context.aboutWindow.loadFile(join(__dirname, '../renderer/index.html'), {
hash: '/about'
})
}
// 打开某个h5页面,比如刚打开软件上先展示广告页
// context.listWindow.loadFile(join(__dirname, '../../resources/xxx/xxx.html'))
}
主进程二次封装
一个软件会有很多个窗口(路由),窗口之间会频繁的进行通信,如果这些逻辑全部写在主进程的入口文件中,势必会很难维护,所以需要将其进行二次封装。
将主进程的功能分解成独立的模块,每一个模块负责一组相关的任务。
主要思路是将主进程的功能模块化,分为两个关键组件:窗口管理和事件路由。
具体思路:
1、新建windows/MianWindow.ts
创建一个 MainWindow
类,负责创建和管理主窗口
ts
import { BrowserWindow } from 'electron'
import * as path from 'path'
import { is } from '@electron-toolkit/utils'
export default class MainWindow{
#window:BrowserWindow | null = null;
#width:number = 900;
#height:number = 670;
create():void{
// 创建窗口
this.#window = new BrowserWindow({
width: this.#width,
height: this.#height,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux'
? {
icon: path.join(__dirname, '../../build/icon.png')
}
: {}),
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
sandbox: false
}
})
// 显示窗口
this.#window.on('ready-to-show', () => {
this.#window?.show()
})
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
this.#window.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
this.#window.loadFile(path.join(__dirname, '../renderer/index.html'))
}
}
close(){
this.#window?.close();
}
show(){
this.#window?.show()
}
hide(){
this.#window?.hide()
}
}
2、新建router/EventRoute.ts
定义一个 EventRoute
类,用于描述一个事件路由的基本信息
ts
export default class EventRoute {
name: string;
event: string;
callback: (api: object, data: any) => void;
constructor(name: string, event: string, callback: (api: object, data: any) => void) {
this.name = name;
this.event = event;
this.callback = callback;
}
}
3、新建router/Routes.ts
定义一个 Routes
对象,存储所有需要处理的事件路由
ts
import EventRoute from './EventRoute';
const routers = {
'close': new EventRoute('close', 'event', (api, data) => {
console.log(api.mainWindow)
api.mainWindow.close();
}),
'event_name':new EventRoute('event_name', 'event', () => {
console.log('xxx');
}),
};
export default routers;
4、新建router/EventRouter.ts
创建一个 EventRouter
类,负责管理和分发事件
ts
interface Route {
callback: (api: any, data: any) => void;
}
interface Routes {
[key: string]: Route;
}
interface Api {
[key: string]: any;
}
export default class EventRouter {
#api: Api = {};
routes: Routes = {};
constructor() {}
router(data: any): void {
const route = this.routes[data.name];
if (route && route.callback) {
route.callback(this.#api, data);
}
}
addApi(name: string, api: any): void {
this.#api[name] = api;
}
addRoutes(routes: Routes): void {
this.routes = { ...this.routes, ...routes };
}
}
5、主进程入口文件main.ts
ts
import { app, BrowserWindow,dialog,ipcMain } from 'electron'
import { electronApp, optimizer, } from '@electron-toolkit/utils'
import MainWindow from './windows/MainWindow'
import EventRouter from './router/EventRouter'
import routers from './router/Routes'
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// 创建eventRouter实例
const eventRouter = new EventRouter();
//引入主窗口
const mainWindow = new MainWindow();
mainWindow.create();
// 将外部依赖添加到eventRouter
eventRouter.addApi('mainWindow',mainWindow);
eventRouter.addApi('dialog',dialog);
// 将所通信的事件数组添加到eventRouter里
eventRouter.addRoutes(routers);
//主窗口接收:渲染进程通信内容
ipcMain.handle('renderer-to-main',( e, data )=>{
eventRouter.router( data );
})
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow.create();
mainWindow.show();
}
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.
接下来就可以在渲染进程向主进程通信了
css
window.electron.ipcRenderer.invoke('renderer-to-main',{
name:'close',
data:{
a:1
}
});
软件自动更新
打包:npm run build:win/mac
打包后dist文件夹下包含以下文件:
1:构建过程中使用的所有配置信息。
2:这个文件里面包含版本号。检查软件更新时,是拿本地的版本号(package.json
里的版本号)跟这个文件里面的版本号做对比,如若不同则更新本地软件。
3:这个文件就是我们要安装到本地的软件,在检查更新后,如若需要更新,则从线上下载最新的这个文件(mac是.dmg
)
打包前要修改electron-builder.yml
,这个文件是 electron-builder
的配置文件,用于定义如何打包和构建你的 Electron 应用。它包括很多方面的配置,比如打包格式、目标平台、图标、构建脚本、签名证书等信息。其中,关于自动更新的部分,主要是定义了发布配置,即你的应用更新包(更新服务器)的位置。如下:
yml
# dev-app-update.yml 这个文件用于开发环境下测试自动更新
provider: generic
# 这个url要换成你的存放更新文件的服务器地址。electron-updater会从这个URL下载最新的更新。
url: https://example.com/auto-updates
updaterCacheDirName: my-app-updater
检查软件更新操作:
在打开软件时,先打开检查软件更新的窗口,如果当前版本跟线上版本不一致,则更新本地版本,更新完成打开窗口。
下载electron-updater
:npm i electron-updater
引入:import { autoUpdater } from 'electron-updater'
ts
// 软件自动更新操作
function update() {
const updateWin = new BrowserWindow({
width: 300,
height: 300,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
// 显示窗口
updateWin.on('ready-to-show', () => {
updateWin.show()
})
// app.isPackaged 应该是否被打包,开发阶段为false,在开发阶段测试自动更新时,需要改为true来模拟打包后的环境
if (is.dev) {
Object.defineProperty(app, 'isPackaged', {
get() {
return true
}
})
//开发阶段测试自动更新的配置文件
autoUpdater.updateConfigPath = join(__dirname, '../../dev-app-update.yml')
}
//检查软件更新事件
autoUpdater.on('checking-for-update', () => {})
//有新版本==》下载
autoUpdater.on('update-available', () => {})
//更新中错误
autoUpdater.on('error', (e) => {
console.log('error', e)
})
//当前版本已经是最新版本,无需更新
autoUpdater.on('update-not-available', () => {
updateWin.destroy()
mainWindow.show()
})
//下载
autoUpdater.on('download-progress', (progressInfo) => {
console.log('进度', progressInfo.percent)
})
//下载完成,准备安装
autoUpdater.on('update-downloaded', () => {
dialog
.showMessageBox(updateWin, {
title: '安装新版本',
message: '新版本已下载完成,是否立即安装?',
type: 'info',
buttons: ['安装','取消']
})
.then(() => {
// 退出开始直接安装
autoUpdater.quitAndInstall()
})
})
//检查是否有新版本
if (is.dev) {
autoUpdater.checkForUpdates()
} else {
autoUpdater.checkForUpdatesAndNotify()
}
//打开更新页面
updateWin.loadFile(join(__dirname, '../../resources/update.html'))
}
注:对于mac os系统,要实现软件自动更新的功能需要将软件上架到app store,自动更新才会生效。window系统不需要。
小结
1、窗口的操作:创建窗口、创建菜单、托盘。无标题窗口的实现方式。
2、通信:主进程==>渲染进程 渲染进程==>主进程
3、主进程二次封装。
4、软件的自动更新。