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
按照命令行提示输入项目名base-tutorial
,之后选择vue
项目,语言javascript
。
1 2 3 cd base-tutorial npm install npm run dev
通过上边的命令可以启动vue3项目了。
electron 安装electron
electron比较大,根据网络情况可能会安装失败,多尝试几次。
安装nodemon 开发方便
之后在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
应用的主入口文件
在项目根目录下新建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
,这里为了使用主进程->渲染进程->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(); });
在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。
方法二 electron
打包需要打包工具,安装
1 npm i electron-packager -D
window端 配置可以直接写在scripts脚本中,package.json
增加
1 "make2": "electron-packager . godex-printer --platform=win32 --arch=x64 --icon=build/icon.ico --out=out --overwrite"
这个打包方式比第一个要快很多,--icon=build/icon.ico
这个参数是生成的exe图标设置
方法三
此方法可以打包生成安装文件
electron
打包需要打包工具,安装
1 npm i electron-builder -D
在项目根目录下创建electron-builder.yml
文件,内容如下
1 2 3 4 5 6 7 8 9 nsis: oneClick: false # 创建一键安装程序还是辅助安装程序(默认是一键安装) allowElevation: true # 是否允许请求提升,如果为false,则用户必须使用提升的权限重新启动安装程序 (仅作用于辅助安装程序) allowToChangeInstallationDirectory: true # 是否允许修改安装目录 (仅作用于辅助安装程序) createStartMenuShortcut: true # 是否创建开始菜单快捷方式 artifactName: ${productName}-${version}-${platform}-${arch}.${ext} shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always
在package.json
增加
1 2 3 "scripts": { "makebuilder": "electron-builder --win --config" },
第一次执行打包时需要下载打包工具,根据网络实际情况可能会失败,多尝试几次。 打包后在dist
目录下的.exe
文件就是安装执行文件,双击下一步即可安装。
打包问题 开发调试时,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">
将绝对路径改成相对路径。
打包后resources文件加密 在打包之后,输出的可执行exe文件夹下有一个resources文件夹,里边是源代码,因为执行时需要这些源代码文件,不能删除。但是,代码暴露了,为了安全,可以使用asar
对源代码封装加密。
安装asar
执行asar封装源码
将src文件夹封装成了src.asar
如何使用 比如,之前main.js
文件需要调用src
文件夹里的内容
1 import './src/index.js';
封装之后
1 import './src.asar/index.js';
就这么简单,重新打包之后在resources下可以看到src.asar文件了,asar文件轻易破解不了的,起到了一定的安全性,这时可以把文件夹下src源代码删除,提供给客户使用。
应用更新 使用electron-updater
实现应用更新 详情暂略
总结 以上完成了一个简单的electron+vue的项目。
代码1 本教程代码source
代码2 保存网站的app 代码source