Electron+Vue桌面端
Electron+Vue桌面端
背景
因具体业务需要,技术栈要能够对电脑硬盘文件进行读写。最开始使用技术栈为 javaFx 构建,使用过程中发现构建页面,自定义 UI 页面困难,而且整个生态环境非常不好,与 Winforms 一样,自定义一些控件相对比较困难,研究起来比较耗费事件,项目周期又紧急,所以决定放弃 javaFx 技术栈,寻找新的技术栈。在一轮新的技术调研后,考虑到代码的可维护性和自定义 UI 页面以及结合项目业务。方案还剩两个技术方案。
- 方案一:C++ 和 Qt 相结合
- 方案二: electron 和 vue 全家桶 最终采用采用方案二,方案二便于自定义页面,通过
node.js
的fs
模块可以实现对硬盘的读写。再加上scss
和less
和现有前端框架的支撑。能够满足自定义 UI 页面。再加上electron
本身的生态环境较好。而且此业务场景下不需要桌面应用太高的执行效率。再加上便于前后端分离开发,提高了效率。简单说一下方案一,具有很好的跨平台性,Qt
还有可视化编辑器,可以用来自定义 UI,而且执行效率更好,可以说是桌面端开发利器了,这里不用的原因是学习成本有点高,项目周期紧,没时间研究
项目环境
- 环境:
node.js v14.18.1
,python3
,c/c++编译环境
,vue-cli,yarn
- 注意事项:yarn 或 npm 镜像改为淘宝镜像
项目创建方式
方式一:electron-vue
electron-vue 提供了 electron 和 vue 相结合的模板,优点:便于快速搭建项目,在一定程度上减少了配置的繁琐。缺点:版本过于稳定(过于老旧)进行升级可能带来其他问题。
项目创建
- 使用
vue init simulatedgreg/electron-vue my-project
命令创建项目 - cd 到项目根目录执行
yarn
或者npm install
进行安装 node_modules 依赖 - 启动调试项目
yarn dev
或npm dev
- 打包执行
yarn build
或者npm build
配置 CSS 预处理
- 安装 yarn add sass@1.25.0 和 yarn add sass-loader@8.0.2
- 在.electron-vue 文件夹下 webpack.renderer.config.js 文件中进行配置
module: {
rules: [
{
test: /\.scss$/,
use: ['vue-style-loader', 'css-loader', 'sass-loader']
}
]
}
方式二:vue add electron-builder
项目创建
- 使用
vue create my-project
命令创建 vue 项目 - 使用
vue add electron-builder
添加 electron 到 vue 项目中 - 启动调试项目
yarn electron:serve
或npm electron:serve
- 打包执行
yarn electron:build
或者npm electron:build
electron 知识点总结
注意事项
- electron 进程分为主进程和渲染进程两部分
- electron 版本不同对其写法有稍微的差别,具体以使用版本官方文档为准
去除原生导航栏
在主进程代码中配置frame:false
关闭原生导航栏
import { BrowserWindow } from 'electron'
function createWindow() {
mainWindow = new BrowserWindow({
height: 888,
width: 1608,
minHeight: 888,
minWidth: 1608,
frame: false //去除原生导航栏
})
}
自定义菜单栏
import { BrowserWindow, Menu } from 'electron'
let mainWindow
function createWindow() {
// 创建浏览器窗口
mainWindow = new BrowserWindow({
width: 800,
height: 600,
enableRemoteModule: true, // 允许弹框
resizable: true, // 是否允许缩放
webPreferences: {
webSecurity: false, // 设置允许跨域
defaultFontSize: 20, //默认字体大小
nodeIntegration: true, // 是否启用节点集成。默认为false
contextIsolation: false
// nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION, // 是否启用节点集成。默认为false
// contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION
}
})
// 准备模板菜单
let template = [
{
label: '文件',
submenu: [{ label: '选择文件' }]
},
{
label: '用户',
submenu: [{ label: '切换用户' }]
},
{
label: '帮助',
submenu: [
{
label: '控制台',
click: () => {
mainWindow.webContents.openDevTools({ mode: 'bottom' })
}
},
{ label: '关于' }
]
}
]
// 加载菜单
let m = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(m)
}
自定义窗口事件
1. 去除原生顶部菜单栏
import { BrowserWindow, ipcMain } from 'electron'
function createWindow() {
mainWindow = new BrowserWindow({
height: 888,
width: 1608,
minHeight: 888,
minWidth: 1608,
frame: false
})
// 去除原生顶部菜单栏
mainWindow.setMenu(null)
}
2. 在主进程中 ipcMain 进行定义通讯
/* *******************************IPC通讯******************************* */
// 窗口最小化
ipcMain.on('min', (e) => mainWindow.minimize())
// 窗口最大化
ipcMain.on('max', (e) => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
})
// 关闭窗口
ipcMain.on('close', (e) => mainWindow.close())
3. 在渲染进程中进行使用
3.1:electron-vue 创建项目渲染进程使用 ipc 引入方式
const { ipcRenderer: ipc } = require('electron')
// 使用主进程定义ipc通讯事件
methods: {// 最小化
clickMinHandle() {
ipc.send('min')
},
// 最大化
clickMaxHandle() {
ipc.send('max')
},
// 关闭
clickCloseHandle() {
ipc.send('close')
}
}
3.2:vue add electron-builder 创建项目渲染进程使用 ipc 引入方式
const { ipcRenderer: ipc } = window.require('electron')
// 使用主进程定义ipc通讯事件
methods: {// 最小化
clickMinHandle() {
ipc.send('min')
},
// 最大化
clickMaxHandle() {
ipc.send('max')
},
// 关闭
clickCloseHandle() {
ipc.send('close')
}
}
打开指定路径文件夹
1:electron-vue 创建项目渲染进程中打开指定路径文件夹
// shell 模块提供了集成其他桌面客户端的关联功能
const { shell } = require('electron').remote
// 以默认打开方式打开文件
let path = record.filePath
shell.openItem(path)
2:vue add electron-builder 创建项目渲染进程中打开指定路径文件夹
// shell 模块提供了集成其他桌面客户端的关联功能
const { shell } = window.require('electron')
// 以默认打开方式打开文件
let path = 'D:/learningSpace/vue-electron'
shell.openPath(path)
读取本机取网卡物理地址
- 安装
yarn add getmac
- 使用
const getMac = require('getmac').default
var clientId = getMac()
console.log(clientId)
嵌入式数据库使用
1. 嵌入式数据库选型
LocalStorage 存储的数据保存在浏览器中。存储容量很小,大概不会超过 10M,它是以键值对形式保存数据的,没有关联查询、条件查询的机制。
SessionStorage 跟
LocalStorage
很相似,区别是每次关闭会话,其中的内容会被清空。在窗口中打开页面会复制顶级浏览会话的上下文作为新会话的上下文。相同url
的不同tabs
页面,其中的值是不同的。有过期时间设置,想持久化存储数据,它是做不到的。Web SQL 数据库
API
并不是HTML5
规范的一部分,但是它是一个独立的规范。WebSQL
是在浏览器上模拟数据库,使用js
来操作SQL
完成对数据的读写。Cookies 存储容量太小,只能存 4kb 的内容,而且每次与服务端交互,同域下的
Cookie
还会被携带到服务端,也没有关联查询、条件查询的机制。数据以Json
格式保存在本地文件中,以这种方式存储一些用户的配置信息是完全没问题的。但要用这种方式存储大量结构化数据,就很不合理了。主要原因是:用这种方案操作数据是需要把文件中的所有数据都加载到客户端电脑的内存中去的。由于没有索引机制,关联查询、条件查询等操作效率不高,更新了某项数据之后,要持久化更新操作,又要重写整个文件。IndexedDB
是一种底层API
,用于在客户端存储大量的结构化数据。该API
使用索引实现对数据的高性能搜索。IndexedDB
是一个事务型数据库系统,类似于基于SQL
的RDBMS
。 然而,不像RDBMS
使用固定列表IndexedDB
是一个基于js
的面向对象数据库。IndexedDB
可以存储和检索用键索引的对象。只需要指定数据库模式,打开与数据库的连接,然后检索和更新一系列事务。SQLite 官网地址:https://sqlite.org/index.html
SQLite
是一个进程内的库,实现了自给自足的、无服务器的、零配置的、事务性的SQL
数据库引擎。它是一个零配置的数据库,这意味着与其他数据库不一样,我们不需要在系统中配置。就像其他数据库,SQLite
引擎不是一个独立的进程,可以按应用程序需求进行静态或动态连接。SQLite
直接访问其存储文件。
2. 数据库安装和编译
- 安装 sqlite
yarn add sqlite3@4.1.1
- 安装 electron-rebuild
yarn add electron-rebuild@3.2.7
- 在 package.json 中 scripts 添加
"rebuild": "electron-rebuild -f -w sqlite3",
- 使用
yarn rebuild
运行对 sqlite3 进行编译。编译依赖于 C++环境和 Pyhton 环境,编译成功标志如下。如果遇到下载node_sqlite3
编译失败报错可参考 https://blog.qianxiaoduan.com/archives/1604编译报错下载文件放在 node_modules 下的 sqlite3 文件夹编译成功标志
3. 数据库配置
- 数据库配置
const path = require('path')
var sqlite3 = require('sqlite3').verbose()
let dbPath = path.join(__dirname, 'sorting.db')
var db = new sqlite3.Database(dbPath)
- 封装 logger.js 抛出报错
const log = (msg) => {
if (process.env.NODE_ENV === 'development') {
console.error(msg)
}
}
export default log
- 挂在到 vue,在 main.js 中添加
import db from './db/index'
import logger from './db/logger'
Vue.prototype.$db = db
Vue.prototype.$logger = logger
4. 数据库使用
- 创建表
/*
CREATE TABLE TABLE_NAME 方式每次都会创建表,控制台会出现 Error: SQLITE_ERROR: table UPLOAD_RECORD already exists
所以采用 CREATE TABLE IF NOT EXISTS TABLE_NAME 方式创建表,解决以上报错问题
*/
db.serialize(() => {
/**
* 测试 TEXT_TABLE
* workCode 单号
* planDate 计划时间
* name 名称
* relationId 关联id
*/
db.run(
`CREATE TABLE IF NOT EXISTS TEXT_TABLE(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
workCode VARCHAR(255) NOT NULL,
planDate VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
relationId VARCHAR(255) NOT NULL
)`,
(err) => {
logger(err)
}
)
})
export default db
- 新增
const SQL = `INSERT INTO TEXT_TABLE (workCode,planDte,name,relationId)
VALUES ('${order.workCode}','${order.planDte}','${order.name}','${order.relationId}')`
this.$db.run(SQL, (err) => {
if (err) {
this.$logger(err)
} else {
console.log('新增成功')
}
})
- 删除
const SQL = `DELETE FROM TEXT_TABLE WHERE id=1`
this.$db.run(SQL, (err) => {
if (err) {
this.$logger(err)
console.log('根据ID删除失败')
} else {
console.log('根据ID删除成功')
}
})
- 编辑
const SQL = `UPDATE TEXT_TABLE SET workCode='5787',planDte='2017878',name='WG55656',relationId='1212' WHERE id = 1`
this.$db.run(SQL, (err) => {
if (err) {
this.$logger(err)
this.$Notice.error('编辑失败')
} else {
this.$message.success('编辑成功')
}
})
- 查询
this.$db.all(`SELECT * FROM TEXT_TABLE`, (err, res) => {
if (err) throw err
console.log(res, '数据库查询视频分类')
})
打包配置
1:electron-vue 创建项目打包配置通过 package.json 进行
{
"name": "tick-sorting",
"version": "0.0.1",
"author": "",
"description": "An electron-vue project",
"license": null,
"main": "./dist/electron/main.js",
"scripts": {
"build": "node .electron-vue/build.js && electron-builder",
"build:dir": "node .electron-vue/build.js && electron-builder --dir",
"build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js",
"build:web": "cross-env BUILD_TARGET=web node .electron-vue/build.js",
"dev": "node .electron-vue/dev-runner.js --disable-gpu",
"pack": "npm run pack:main && npm run pack:renderer",
"pack:main": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.main.config.js",
"pack:renderer": "cross-env NODE_ENV=production webpack --progress --colors --config .electron-vue/webpack.renderer.config.js",
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-rebuild -f -w sqlite3",
"ev ": "electron --version"
},
"build": {
"productName": "xxxx系统名称", // 系统名称
"appId": "com.example.yourapp",
"buildDependenciesFromSource": true,
"nodeGypRebuild": false,
"npmRebuild": false,
"directories": {
"output": "build"
},
"asar": false, // 解决打包后数据库找不到数据文件,无法进行读写问题
"files": ["dist/electron/**/*"],
"dmg": {
"contents": [
{
"x": 410,
"y": 150,
"type": "link",
"path": "/Applications"
},
{
"x": 130,
"y": 150,
"type": "file"
}
]
},
"mac": {
"icon": "build/icons/icon.icns"
},
"win": {
"icon": "build/icons/icon.ico"
},
"linux": {
"icon": "build/icons"
},
"nsis": {
"oneClick": false, // 是否一键安装,用户不可选择安装目录
"allowToChangeInstallationDirectory": true, // 是否允许用户自定义安装目录
"perMachine": true, // 是否开启安装时权限限制(此电脑或当前用户)
"allowElevation": true, // 允许请求提升,如果为fasle,则用户必须使用提升权限重新启动
"installerIcon": "build/icons/icon.ico", // 安装图标
"uninstallerIcon": "build/icons/icon.ico", // 卸载图标
"installerHeaderIcon": "build/icons/icon.ico", // 安装框图标
"createDesktopShortcut": true, // 创建桌面快捷方式
"createStartMenuShortcut": true // 创建开始菜单图标
}
},
"dependencies": {
"ant-design-vue": "^1.7.8",
"axios": "^0.18.0",
"electron-settings": "^4.0.2",
"electron-store": "6.0.1",
"electron-updater": "^4.6.5",
"fs": "^0.0.1-security",
"fs-extra": "^10.0.1",
"getmac": "^5.20.0",
"is-electron": "^2.2.1",
"jquery": "^3.6.0",
"less": "2.7.3",
"less-loader": "4.1.0",
"multispinner": "^0.2.1",
"node-gyp": "^9.0.0",
"simple-uploader.js": "^0.6.0",
"spark-md5": "^3.0.2",
"sqlite3": "^4.1.1",
"vue": "^2.5.16",
"vue-devtools": "^5.1.4",
"vue-electron": "^1.0.6",
"vue-router": "^3.0.1",
"vuex": "^3.0.1",
"vuex-electron": "^1.0.0",
"webuploader": "0.1.8"
},
"devDependencies": {
"ajv": "^6.5.0",
"babel-core": "^6.26.3",
"babel-loader": "^7.1.4",
"babel-minify-webpack-plugin": "^0.3.1",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.7.0",
"babel-preset-stage-0": "^6.24.1",
"babel-register": "^6.26.0",
"cfonts": "^2.1.2",
"chalk": "^2.4.1",
"copy-webpack-plugin": "^4.5.1",
"cross-env": "^5.1.6",
"css-loader": "^0.28.11",
"del": "^3.0.0",
"devtron": "^1.4.0",
"electron": "^2.0.4",
"electron-builder": "^20.19.2",
"electron-debug": "^1.5.0",
"electron-devtools-installer": "^2.2.4",
"electron-rebuild": "^3.2.7",
"file-loader": "^1.1.11",
"html-webpack-plugin": "^3.2.0",
"listr": "^0.14.3",
"mini-css-extract-plugin": "0.4.0",
"node-loader": "^0.6.0",
"sass": "^1.25.0",
"sass-loader": "^8.0.2",
"sass-resources-loader": "^2.2.4",
"style-loader": "^0.21.0",
"url-loader": "^1.0.1",
"vue-html-loader": "^1.2.4",
"vue-loader": "^15.2.4",
"vue-style-loader": "^4.1.0",
"vue-template-compiler": "^2.5.16",
"webpack": "^4.15.1",
"webpack-cli": "^3.0.8",
"webpack-dev-server": "^3.1.4",
"webpack-hot-middleware": "^2.22.2",
"webpack-merge": "^4.1.3"
}
}
2:vue add electron-builder 创建项目打包配置通过 vue.config.js 进行
const path = require('path')
const resolve = (dir) => path.join(__dirname, dir)
const BASE_URL = process.env.NODE_EVN === 'production' ? './' : './'
module.exports = {
publicPath: BASE_URL,
outputDir: 'mine-exe',
assetsDir: 'static',
lintOnSave: process.env.NODE_ENV === 'development',
pluginOptions: {
electronBuilder: {
removeElectronJunk: false,
builderOptions: {
appId: 'com.example.app',
productName: 'xxxx项目名称', //项目名,也是生成的安装文件名,即 xxxx.exe
copyright: 'Copyright © 2022', //版权信息
directories: {
output: 'build' //输出文件路径
},
win: {
//win相关配置
icon: './src/assets/icon.ico', //图标,当前图标在根目录下,注意这里有两个坑
target: [
{
target: 'nsis', //利用nsis制作安装程序
arch: [
'x64', //64位
'ia32' //32位
]
}
]
},
nsis: {
oneClick: false, // 是否一键安装
allowElevation: true, // 允许请求提升。 如果为false,则用户必须使用提升的权限重新启动安装程序。
allowToChangeInstallationDirectory: true, // 允许修改安装目录
installerIcon: './src/assets/icon.ico', // 安装图标
uninstallerIcon: './src/assets/icon.ico', //卸载图标
installerHeaderIcon: './src/assets/icon.ico', // 安装时头部图标
createDesktopShortcut: true, // 创建桌面图标
createStartMenuShortcut: true, // 创建开始菜单图标
shortcutName: '测试项目' // 桌面图标名称
},
publish: [
{
provider: 'generic', // 服务器提供商 也可以是GitHub等等
url: 'http://xxxxx/' // 服务器地址
}
]
}
}
},
chainWebpack: (config) => {
config.resolve.alias
.set('@', resolve('src'))
.set('@api', resolve('src/api'))
.set('@assets', resolve('src/assets'))
},
// 配置CSS处理
css: {
loaderOptions: {
sass: {
prependData: `
@import "@/scss/variables.scss";
`
}
}
},
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建
productionSourceMap: true,
configureWebpack: (config) => {
//生产环境取消 console.log
if (process.env.NODE_ENV === 'production') {
config.optimization.minimizer[0].options.terserOptions.compress.drop_console = true
}
},
// 服务代理
devServer: {
port: 3078,
proxy: {
'/servers-api': {
target: 'http://127.0.0.1:8090',
ws: false,
changeOrigin: true
}
}
}
}
![image.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/68b360fcd3614713a7721e2a9af32a91~tplv-k3u1fbpfcp-watermark.image?)
项目技术点
遇到问题
由于老项目大文件上传组件,采用了 webuploader,作为上传组件,新项目直接调用老项目上传接口,对接老项目数据。所以新项目也使用webuploader
作为上传组件,简单说一下我遇到的问题:
- 由于
webuploader
上传组件,是从本地进行选择文件或者文件夹,对文件进行分片上传的,也就是webuploader
选择文件后,会将本地文件的数据格式包装成 webuploader 组件所需要的文件上传分片数据,并且生成Filemd5
用来记录文件分片,从而达到断点续传。 - 现有业务场景是直接读取本地文件夹路径进行获取文件,读出来的文件格式无法直接通过
webuploader
上传组件的addFiles()
方法将文件添加到上传队列
解决方案
方案一:fs.createReadStream() 读取文件
通过node.js
的fs
模块的fs.createReadStream()
方法读取文件,获取一个可读文件流数据,再通过new Blob()
将文件流转换为Blob
对象,再通过 new File()
对 Blob
对象进行包装,从而获取文件名称、文件大小、和文件类型等信息。再将file
对象通过 webuploader
的 addFiles()
方法添加到上传队列
var readStream = fs.createReadStream('文件全路径')
var blobParts
readStream.on('open', function () {
blobParts = new Array()
})
readStream.on('data', function (data) {
var blob = new Blob([data], { type: 'mp4', path: '文件全路径' })
blobParts.push(blob)
})
readStream.on('end', function () {
var file = new File(blobParts, keyArr[0])
files.push(file)
//end
})
readStream.on('close', function () {})
readStream.on('error', function (err) {
// 读取过程中出错了,清空数据
blobParts.splice(0, blobParts.length)
})
方案二:fs.readFile() 读取文件
通过node.js
的fs
模块的fs.readFile()
方法读取文件,获取一个可读文件流数据,再通过new Blob()
将文件流转换为Blob
对象,再通过 new File()
对 Blob
对象进行包装,从而获取文件名称、文件大小、和文件类型等信息。再将file
对象通过 webuploader
的 addFiles()
方法添加到上传队列
fs.readFile('文件全路径', (err, data) => {
var blob = new Blob([data], { type: 'mp4', path: '文件全路径' })
var file = new File([blob], key)
})
总结
- 读取文件的两种方案对比: 第一种方案执行效率较慢,第二种方案执行效率较高,在具体和
webuploader
文件上传组件相结合时,第一种上传较为稳定,不会出现分片上传断开请求连接。第二种方案,执行上传过程中出现文件传输中断,传输连接被取消(对比两次转换出来的文件流和文件数据没有不同,未找到具体原因)。由于此项目不需要特别高的执行效率,所以采用第一种文件读取方案。 - 上传组件选型: 如果没有老项目的约束,建议选型较新的上传组件库,webuploader 上传组件库已经停止维护,组件库选型应该做到以下几点
- 目前还在维护
- 有较好的生态或者社区
遇到报错
下载运行依赖失败
- 报错截图(原因是网络不好,导致运行编译需的包文件下载失败)
解决办法:下载对应包文件,放到 C:\Users\xxx\AppData\Local\electron\Cache 目录下
对应编译打包报错:下载对应包文件放到 C:\Users\xxx\AppData\Local\electron-builder\Cache 目录下
运行白屏问题
- 可能是语法错误导致主进程无法渲染,解决办法根据报错信息修改代码
- 开发环境下无报错,但是白屏可以尝试清除本地应用缓存,缓存目录 C:\Users\xxx\AppData\Roaming\Electron\Cache
参考
- electron 官网
- electron-vue 官网
- node.js fs 模块
- weuploader weuploader API
- sqlite3 启动、打包编译报错: https://blog.qianxiaoduan.com/archives/1604
- Electron 和其他的桌面开发技术栈对比: https://www.zhihu.com/question/264999651