electron(vue)桌面端

背景

因具体业务需要,技术栈要能够对电脑硬盘文件进行读写。最开始使用技术栈为 javaFx 构建,使用过程中发现构建页面,自定义 UI 页面困难,而且整个生态环境非常不好,与 Winforms 一样,自定义一些控件相对比较困难,研究起来比较耗费事件,项目周期又紧急,所以决定放弃 javaFx 技术栈,寻找新的技术栈。在一轮新的技术调研后,考虑到代码的可维护性和自定义 UI 页面以及结合项目业务。方案还剩两个技术方案。

  • 方案一:C++Qt 相结合
  • 方案二: electronvue 全家桶 最终采用采用方案二,方案二便于自定义页面,通过 node.jsfs 模块可以实现对硬盘的读写。再加上scssless 和现有前端框架的支撑。能够满足自定义 UI 页面。再加上 electron 本身的生态环境较好。而且此业务场景下不需要桌面应用太高的执行效率。再加上便于前后端分离开发,提高了效率。简单说一下方案一,具有很好的跨平台性,Qt还有可视化编辑器,可以用来自定义 UI,而且执行效率更好,可以说是桌面端开发利器了,这里不用的原因是学习成本有点高,项目周期紧,没时间研究

项目环境

  • 环境:node.js v14.18.1python3c/c++编译环境vue-cliopen in new windowyarn
  • 注意事项:yarn 或 npm 镜像改为淘宝镜像

项目创建方式

方式一:electron-vue

electron-vueopen in new window 提供了 electron 和 vue 相结合的模板,优点:便于快速搭建项目,在一定程度上减少了配置的繁琐。缺点:版本过于稳定(过于老旧)进行升级可能带来其他问题。

项目创建

  1. 使用vue init simulatedgreg/electron-vue my-project 命令创建项目
  2. cd 到项目根目录执行 yarn 或者 npm install 进行安装 node_modules 依赖
  3. 启动调试项目 yarn devnpm dev
  4. 打包执行 yarn build 或者 npm build

配置 CSS 预处理

  1. 安装 yarn add sass@1.25.0 和 yarn add sass-loader@8.0.2
  2. 在.electron-vue 文件夹下 webpack.renderer.config.js 文件中进行配置
module: {
  rules: [
    {
      test: /\.scss$/,
      use: ['vue-style-loader', 'css-loader', 'sass-loader']
    }
  ]
}

方式二:vue add electron-builder

项目创建

  1. 使用 vue create my-project 命令创建 vue 项目
  2. 使用 vue add electron-builder 添加 electron 到 vue 项目中
  3. 启动调试项目 yarn electron:servenpm electron:serve
  4. 打包执行 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)

读取本机取网卡物理地址

  1. 安装 yarn add getmac
  2. 使用
const getMac = require('getmac').default
var clientId = getMac()
console.log(clientId)

嵌入式数据库使用

1. 嵌入式数据库选型

  1. LocalStorage 存储的数据保存在浏览器中。存储容量很小,大概不会超过 10M,它是以键值对形式保存数据的,没有关联查询、条件查询的机制。

  2. SessionStorage  跟  LocalStorage  很相似,区别是每次关闭会话,其中的内容会被清空。在窗口中打开页面会复制顶级浏览会话的上下文作为新会话的上下文。相同  url  的不同  tabs  页面,其中的值是不同的。有过期时间设置,想持久化存储数据,它是做不到的。

  3. Web SQL 数据库  API  并不是  HTML5  规范的一部分,但是它是一个独立的规范。WebSQL  是在浏览器上模拟数据库,使用  js  来操作  SQL  完成对数据的读写。

  4. Cookies 存储容量太小,只能存  4kb 的内容,而且每次与服务端交互,同域下的  Cookie  还会被携带到服务端,也没有关联查询、条件查询的机制。数据以  Json  格式保存在本地文件中,以这种方式存储一些用户的配置信息是完全没问题的。但要用这种方式存储大量结构化数据,就很不合理了。主要原因是:用这种方案操作数据是需要把文件中的所有数据都加载到客户端电脑的内存中去的。由于没有索引机制,关联查询、条件查询等操作效率不高,更新了某项数据之后,要持久化更新操作,又要重写整个文件。

  5. IndexedDB  是一种底层  API,用于在客户端存储大量的结构化数据。该  API  使用索引实现对数据的高性能搜索。IndexedDB  是一个事务型数据库系统,类似于基于  SQL  的  RDBMS。 然而,不像  RDBMS  使用固定列表IndexedDB  是一个基于  js  的面向对象数据库。IndexedDB  可以存储和检索用键索引的对象。只需要指定数据库模式,打开与数据库的连接,然后检索和更新一系列事务。

  6. SQLite 官网地址:https://sqlite.org/index.htmlopen in new window SQLite  是一个进程内的库,实现了自给自足的、无服务器的、零配置的、事务性的  SQL  数据库引擎。它是一个零配置的数据库,这意味着与其他数据库不一样,我们不需要在系统中配置。就像其他数据库,SQLite  引擎不是一个独立的进程,可以按应用程序需求进行静态或动态连接。SQLite  直接访问其存储文件。

2. 数据库安装和编译

  1. 安装 sqlite yarn add sqlite3@4.1.1
  2. 安装 electron-rebuild yarn add electron-rebuild@3.2.7
  3. 在 package.json 中 scripts 添加 "rebuild": "electron-rebuild -f -w sqlite3",
  4. 使用 yarn rebuild 运行对 sqlite3 进行编译。编译依赖于 C++环境和 Pyhton 环境,编译成功标志如下。如果遇到下载 node_sqlite3 编译失败报错可参考 https://blog.qianxiaoduan.com/archives/1604 编译报错下载文件放在 node_modules 下的 sqlite3 文件夹image-20220710113240185编译成功标志image-20220710113310035

3. 数据库配置

  1. 数据库配置
const path = require('path')
var sqlite3 = require('sqlite3').verbose()
let dbPath = path.join(__dirname, 'sorting.db')
var db = new sqlite3.Database(dbPath)
  1. 封装 logger.js 抛出报错
const log = (msg) => {
  if (process.env.NODE_ENV === 'development') {
    console.error(msg)
  }
}
export default log
  1. 挂在到 vue,在 main.js 中添加
import db from './db/index'
import logger from './db/logger'
Vue.prototype.$db = db
Vue.prototype.$logger = logger

4. 数据库使用

  1. 创建表
/* 
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
  1. 新增
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('新增成功')
  }
})
  1. 删除
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删除成功')
  }
})
  1. 编辑
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('编辑成功')
  }
})
  1. 查询
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?)

项目技术点

遇到问题

由于老项目大文件上传组件,采用了 webuploaderopen in new window,作为上传组件,新项目直接调用老项目上传接口,对接老项目数据。所以新项目也使用webuploader作为上传组件,简单说一下我遇到的问题:

  • 由于webuploader上传组件,是从本地进行选择文件或者文件夹,对文件进行分片上传的,也就是webuploader选择文件后,会将本地文件的数据格式包装成 webuploader 组件所需要的文件上传分片数据,并且生成Filemd5用来记录文件分片,从而达到断点续传。
  • 现有业务场景是直接读取本地文件夹路径进行获取文件,读出来的文件格式无法直接通过webuploader上传组件的 addFiles() 方法将文件添加到上传队列

解决方案

方案一:fs.createReadStream() 读取文件

通过node.jsfs模块的fs.createReadStream() 方法读取文件,获取一个可读文件流数据,再通过new Blob() 将文件流转换为Blob对象,再通过 new File()Blob 对象进行包装,从而获取文件名称、文件大小、和文件类型等信息。再将file对象通过 webuploaderaddFiles() 方法添加到上传队列

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.jsfs模块的fs.readFile() 方法读取文件,获取一个可读文件流数据,再通过new Blob() 将文件流转换为Blob对象,再通过 new File()Blob 对象进行包装,从而获取文件名称、文件大小、和文件类型等信息。再将file对象通过 webuploaderaddFiles() 方法添加到上传队列

fs.readFile('文件全路径', (err, data) => {
  var blob = new Blob([data], { type: 'mp4', path: '文件全路径' })
  var file = new File([blob], key)
})

总结

  • 读取文件的两种方案对比: 第一种方案执行效率较慢,第二种方案执行效率较高,在具体和 webuploader 文件上传组件相结合时,第一种上传较为稳定,不会出现分片上传断开请求连接。第二种方案,执行上传过程中出现文件传输中断,传输连接被取消(对比两次转换出来的文件流和文件数据没有不同,未找到具体原因)。由于此项目不需要特别高的执行效率,所以采用第一种文件读取方案。
  • 上传组件选型: 如果没有老项目的约束,建议选型较新的上传组件库,webuploader 上传组件库已经停止维护,组件库选型应该做到以下几点
    1. 目前还在维护
    2. 有较好的生态或者社区

遇到报错

下载运行依赖失败

  1. 报错截图(原因是网络不好,导致运行编译需的包文件下载失败)

image-20220710113352363.png

  1. 解决办法:下载对应包文件,放到 C:\Users\xxx\AppData\Local\electron\Cache 目录下

  2. 对应编译打包报错:下载对应包文件放到 C:\Users\xxx\AppData\Local\electron-builder\Cache 目录下

运行白屏问题

  1. 可能是语法错误导致主进程无法渲染,解决办法根据报错信息修改代码
  2. 开发环境下无报错,但是白屏可以尝试清除本地应用缓存,缓存目录 C:\Users\xxx\AppData\Roaming\Electron\Cache

参考