electron基础入门

  目录

electron+vue3+pinia构建一个桌面应用

Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 Chromium 和 Node.js 到 二进制的 Electron 允许您保持一个 JavaScript 代码代码库并创建 在Windows上运行的跨平台应用 macOS和Linux。

本教程使用vite+vue3+pinia配合electron开发一个桌面应用。

app应用功能,点击添加按钮,弹出dialog,填写内容后提交,将内容保存在public/files文件夹下,并且当前列表会展示出目前的文件列表。点击相应的文件会跳转到详情页展示。

基础功能

vue3

安装vue3

1
npm create vite@latest

按照命令行提示输入项目名base-tutorial,之后选择vue项目,语言javascript

1
2
3
cd base-tutorial
npm install
npm run dev

通过上边的命令可以启动vue3项目了。

electron

安装electron

1
npm i electron -D

electron比较大,根据网络情况可能会安装失败,多尝试几次。

安装nodemon

开发方便

1
npm i nodemon -D

之后在package.json中添加

1
2
3
"scripts": {
"start": "nodemon --exec electron . --watch ./ --ext .js,.html,.css,.vue",
},

监控.js,.html,.css,.vue这些文件变化,自动重启electron应用。

安装electron-win-state

1
npm i electron-win-state --save

electron-win-state可以记录应用上次关闭前窗口大小和位置。

编写electron应用

package.json中添加electron应用的主入口文件

1
"main": "main.js"

在项目根目录下新建main.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { app, BrowserWindow } from 'electron';
import WinState from 'electron-win-state'; // 保存窗口位置和大小的调整

const createWindow = ()=> {
const winState = new WinState.default({
defaultWidth: 1000,
defaultHeight: 800,
electronStoreOptions: {
name: 'window-state-main' // 开启多个窗口的时候分别记录
}
});
const win = new BrowserWindow({
...winState.winOptions,
show: false,
});

win.loadURL('http://localhost:5173');
win.webContents.openDevTools();
winState.manage(win);
win.on('ready-to-show', ()=> {
win.show();
});
}

app.whenReady().then(()=> {
createWindow();

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
});
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
});

运行npm start,app顺利启动
但是在调试工具打印台会看到Electron Security Warning (Insecure Content-Security-Policy)警告,解决办法是在index.html中加入

1
<meta http-equiv="content-security-policy" content="default-src 'self'; script-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'">

添加新功能

添加路由,状态管理,ui库,css预编译,css初始化,lodash等

安装对应的库

1
2
3
4
5
6
npm i vue-router --save
npm i stylus --save
npm i pinia --save
npm i normalize.css --save
npm i lodash --save
npm i element-plus --save

改造vue内容

修改App.vue

1
2
3
4
5
6
7
8
9
10
<script setup>
</script>
<template>
<div>
<h1>electron基础入门</h1>
<router-view></router-view>
</div>
</template>
<style lang="stylus" scoped>
</style>

添加路由插槽<router-view></router-view>

新建views

src目录下,新建views文件夹
views下新建Home.vue,Detail.vue文件

新建router

src目录下,新建router文件夹
router下新建index.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '@/views/Home.vue';
const routes = [
{
path: '/',
redirect: '/home'
},
{
path: '/home',
name: 'home',
component: Home
},
{
path: '/detail',
name: 'detail',
component: ()=> import('@/views/Detail.vue')
}
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
export default router;

修改Home.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue';
import useLoadingStore from '../store/loading';
import List from './List.vue';

const loadingSotate = useLoadingStore();

const dialogVisible = ref(false);
const list = ref([]);
const refForm = ref(null);
const form = reactive({
title: '',
city: '',
desc: '',
});
const rules = {
title: [
{ required: true, message: '请输入题目', trigger: 'blur' }
],
city: [
{ required: true, message: '请选择城市', trigger: 'change' }
],
desc: [
{ required: true, message: '请输入描述', trigger: 'blur' }
]
}
const onAdd = ()=> {
dialogVisible.value = true;
nextTick(()=> {
refForm.value.resetFields();
});
}
const sunmitHandle = ()=> {
refForm.value.validate(async valid=> {
if(valid) {
dialogVisible.value = false;
loadingSotate.set(true);
// 调用渲染进程提供的方法
await rendererApi.saveText(JSON.stringify(form));
loadingSotate.set(false);
getList();
}
});
}
const getList = async ()=> {
list.value = await rendererApi.getText();
}
// 主进程调用vue侧的函数
rendererApi.showAddDialog(()=> {
onAdd();
});
onMounted(async ()=> {
getList();
});
</script>

<template>
<div>
<div>
<el-button type="primary" @click="onAdd">+</el-button>
</div>
<List :listData="list"></List>
<el-dialog
v-model="dialogVisible"
title="添加内容"
width="80%"
>
<el-form :model="form" ref="refForm" :rules="rules" label-width="auto" style="max-width: 600px">
<el-form-item label="题目" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="城市" prop="city">
<el-select v-model="form.city" placeholder="请选择城市">
<el-option label="沈阳" value="沈阳" />
<el-option label="大连" value="大连" />
</el-select>
</el-form-item>
<el-form-item label="描述" prop="desc">
<el-input v-model="form.desc" type="textarea" :row="5" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="sunmitHandle">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>

<style lang="stylus" scoped>
</style>

修改Detail.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script setup>
import { useRoute, useRouter } from 'vue-router';

const route = useRoute();
const router = useRouter();
const query = route.query;
</script>

<template>
<div>
<div class="go-back">
<el-button type="primary" @click="router.go(-1)">返回</el-button>
</div>
<h1>{{ query.title }}</h1>
<div>来自-{{ query.city }}</div>
<pre>{{ query.desc }}</pre>
</div>
</template>

<style lang="stylus" scoped>
.go-back {
text-align: right;
}
</style>

添加pinia

src下新建store文件夹,新建loading.js文件。
此文件内容控制Loading组件显示隐藏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineStore } from 'pinia';
const useLoadingStore = defineStore('websiteStore', {
state() {
return {
bShow: false
}
},
actions: {
set(val) {
this.bShow = val;
}
},
getters: {
getBShow() {
return this.bShow;
}
}
});
export default useLoadingStore;

添加loading

App.vue文件夹下添加Loading组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<script setup>
import useLoadingStore from '../store/loading';

const loadingSotate = useLoadingStore();
</script>

<template>
<div class="loading-wrap" v-if="loadingSotate.getBShow">
<svg class="circular" viewBox="0 0 50 50"><circle class="path" cx="25" cy="25" r="20" fill="none"></circle></svg>
</div>
</template>

<style lang="stylus" scoped>
.loading-wrap {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, .8);
display: flex;
justify-content: center;
align-items: center;
.circular {
stroke: #000000;
width: 50px;
height: 50px;
animation: loading-rotate .5s linear infinite alternate;
}
}
@keyframes loading-rotate {
0% {
transform: scale(1);
opacity: 0
}
100% {
transform: scale(1.2);
opacity: 1
}
}
</style>

比如在Home页面控制Loading组件显示隐藏

1
2
3
4
loadingSotate.set(true);
setTimeout(() => {
loadingSotate.set(false);
}, 3000);

显示后3秒隐藏

改造electron内容

vue侧提交内容后,需要将内容保存到文件里。需要修改electron的主进程和渲染进程

electron主进程

main.js里新增

1
2
3
webPreferences: {
preload: path.resolve(__dirname, './preload/index.js')
},

引入渲染进程文件。

controller

在根目录下新建controller文件夹,再新建saveText.js文件,用于保存vue侧保存的内容。

1
2
3
4
5
6
7
8
9
10
import { ipcMain } from 'electron';
import path from 'path';
import fs from 'fs';
import { __dirnameFn } from '../utils.js';

ipcMain.handle('on-save-text-event', (e, str)=> {
const data = JSON.parse(str);
const filePath = path.resolve(__dirnameFn(import.meta.url), '../public/files/', data.title + '-' + data.city + '.txt');
fs.writeFileSync(filePath, data.desc);
});

ipcMain.handle用来注册事件,渲染进程可以触发这个事件的回调函数。

electron渲染进程

在根目录下新建preload文件夹,再新建index.js文件,这里都是渲染进程的功能。

1
2
3
4
5
6
7
8
9
const { contextBridge, ipcRenderer } = require('electron')

const saveText = async data=> {
let result = await ipcRenderer.invoke('on-save-text-event', data);
return result;
}
contextBridge.exposeInMainWorld('myApi', {
saveText,
});

渲染进程将saveText这个方法暴露给vue侧,可以window.saveText来调用,调用时,渲染进程会触发主进程注册的对应事件的回调函数完成保存文件的功能。

通过vue侧->渲染进程->主进程,也就是vue侧向主进程发起的通讯,后边还会实现electron主进程向vue侧发起的通讯。

后续修改略

之后还会对vue侧,渲染进程,主进程代码修改,这里就不一一列举。思路还是主进程注册事件,渲染进程暴漏给vue侧全局方法调用来触发主进程的事件回调函数。

自定义Menu

可以自定义Menu,这里为了使用主进程->渲染进程->vue侧的通讯,也就是在菜单中点击按钮,调用vue侧的一个方法。
这个流程与上面vue侧->渲染进程->主进程的调用正好相反。

渲染进程新增事件注册

在preload的index.js中,新增

1
2
3
4
// 主进程触发渲染进程
const showAddDialog = cb=> ipcRenderer.on('on-show-add-dialog-event', (e, value)=> {
cb(value);
});

vue侧调用

1
2
3
4
// 主进程调用vue侧的函数
rendererApi.showAddDialog(()=> {
onAdd();
});

新建buildMenu.js

controller下新建buildMenu.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import { app, Menu, ipcMain } from 'electron';
import { inject } from 'vue';

const isMac = process.platform === 'darwin';

let mainWindow = null;

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' }
]
}]
: []),
// { role: 'fileMenu' }
{
label: 'File',
submenu: [
isMac ? { role: 'close' } : { role: 'quit' }
]
},
// { role: 'editMenu' }
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [
{ role: 'startSpeaking' },
{ role: 'stopSpeaking' }
]
}
]
: [
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' }
])
]
},
// { role: 'viewMenu' }
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
},
// { role: 'windowMenu' }
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' }
]
: [
{ role: 'close' }
])
]
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
const { shell } = require('electron')
await shell.openExternal('https://electronjs.org')
}
}
]
},
{
label: 'actions',
submenu: [
{
label: '添加',
click: async ()=> {
// 触发renderer进程的on-show-add-dialog-event事件
mainWindow.webContents.send('on-show-add-dialog-event');
},
accelerator: 'CommandOrControl+Alt+O'
}
]
}
]

const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
export const injectMainWindow = win=> {
mainWindow = win;
}

  • vue侧调用渲染进程暴露的函数,把需要执行的vue侧回调函数传进去。
  • 渲染进程函数执行后注册了on-show-add-dialog-event这个渲染进程事件。
  • Menu菜单点击后,主进程触发渲染进程事件,这个事件的回调函数中会执行vue侧提供的回调函数。

通过上述步骤即可完成主进程->渲染进程->vue侧的通信

托盘&app图标

mac端

controller下新建tray.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 兼容mac电脑
import { Tray } from 'electron';
import path from 'path';
import { __dirnameFn } from '../utils.js';

const createTray = (app, win)=> {
const tray = new Tray(path.resolve(__dirnameFn(import.meta.url), '../icon.png'));
tray.setToolTip('electron-tutorial');
tray.on('click', e=> {
if(e.shiftKey) {
app.quit();
}
});
}

export default createTray;

main.js增加

1
2
3
4
// tray 兼容mac托盘
import createTray from './controller/tray.js';

createTray(app, win);

window端

main.js增加

1
icon: nativeImage.createFromPath(path.resolve(__dirnameFn(import.meta.url), './icon.png')),

全屏

全屏功能可以应用在一些终端设备中,比如商场或者机场的自助终端。
main.js中增加

1
2
3
4
// 取消浏览器头部菜单栏
frame: false
// 设置全屏
win.maximize();

打包

electron打包需要打包工具,安装

1
2
npm i @electron-forge/cli -D
npm i @electron-forge/maker-squirrel -D

window端

在根目录下新增forge.config.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
export default {
// ...
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {
certificateFile: './cert.pfx',
certificatePassword: process.env.CERTIFICATE_PASSWORD
}
}
]
// ...
}

package.json增加

1
"make": "electron-forge make"

执行npm run make,(网络不好有时会失败,重试几次),在根目录输出一个out文件夹。在里边找到项目名称的exe文件就是可执行app。

打包问题

开发调试时,main.js中,引入vue侧是win.loadURL('http://localhost:5173');这样引入的,这样打包的话,执行app应用时还需要启动vue项目才可以使用。
如果想直接把vue侧直接打到app应用包中,修改main.js

1
2
// win.loadURL('http://localhost:5173');
win.loadFile(path.resolve(__dirnameFn(import.meta.url), './dist/index.html'));

还需要修改dist/index.html中引入的js和css的路径

1
2
3
4
<!-- <script type="module" crossorigin src="/assets/index-W5Tu2U3a.js"></script> -->
<!-- <link rel="stylesheet" crossorigin href="/assets/index-CrynHw1J.css"> -->
<script type="module" crossorigin src="./assets/index-W5Tu2U3a.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CrynHw1J.css">

将绝对路径改成相对路径。

总结

以上完成了一个简单的electron+vue的项目。

代码1

本教程代码source

代码2

保存网站的app
代码source