Vue+Electron开发跨平台桌面应用实践

背景

公司去年对CDN资源服务器进行了迁移,由原来的通过FTP方式的文件存储改为了使用S3协议上传的对象存储,部门内 @柴俊堃 同学开发了一个命令行脚本工具RapidTrans(睿传),使用睿传可以很方便将本地目录下的资源上传到S3中。

睿传运行时接收两个主要参数,一个为待上传的本地路径,一个为上传到CDN后的路径,我们可以在项目的package.json中去配置scripts执行上传。

npm run rapid-trans -- -s "/home/demo/work/mall2016/release/列表页" -p "2016/m/list"

用了一段时间后觉得如果选择本地路径的时候可以通过可视化的文件选择器的方式选择就太好了,团队一直在做客户端方向技术的储备,所以为了更方便团队的使用产生了将睿传封装成GUI的跨平台客户端的想法。

客户端界面

Vue+Electron开发跨平台桌面应用实践

功能分析

技术选型

Electron 简介

Electron 是由 Github 开发,基于 Chromium 和 Node.js, 让你可以使用 HTML, CSS 和 JavaScript 构建跨平台桌面应用的开源框架。

Vue+Electron开发跨平台桌面应用实践

Electron 可以让你使用纯 JavaScript 调用丰富的原生(操作系统) APIs 来创造桌面应用。 你可以把它看作一个专注于桌面应用的 Node. js 的变体,而不是 Web 服务器。

简单点说,用 Electron 可以让我们在网页中使用 Node.js 的 API 和调用系统 API。

Vue + Electron 环境搭建

使用vue-cli脚手架和electron-vue模板进行搭建,此处需要注意,由于 electron-vue 模板不支持 vue-cli@3.0,所以要使用 2.0 版本。

# 安装 vue-cli@2.0,若已安装则无需重复安装
npm install -g vue-cli
vue init simulatedgreg/electron-vue s3_upload_tool
# 安装依赖并运行
cd s3_upload_tool
npm install
npm run dev

目录结构

├─ .electron-vue
│  ├─ webpack.main.config.js
│  ├─ webpack.renderer.config.js
│  └─ webpack.web.config.js
├─ build
│  └─ icons/
├─ dist
│  ├─ electron/
│  └─ web/
├─ node_modules/
├─ src
│  ├─ main
│  │  ├─ index.dev.js
│  │  └─ index.js
│  ├─ renderer
│  │  ├─ components/
│  │  ├─ router/
│  │  ├─ store/
│  │  ├─ App.vue
│  │  └─ main.js
│  └─ index.ejs
├─ static/
├─ .babelrc
├─ .eslintignore
├─ .eslintrc.js
├─ .gitignore
├─ package.json
└─ README.md

应用的目录结构和平常我们用 Vue 做 WEB 端时生成的结构基本差异不大,所以本文我只介绍下与 Web 不同的几个目录。

.electron-vue

该目录下包含 3 个独立的 Webpack 配置文件

src/main

主进程代码存放位置,涉及到调取 Node API 、调用原生系统功能的代码。

src/renderer

渲染进程代码存放位置,和平常的 Vue 项目基本一样。

主进程与渲染进程

在 Electron 中有两个进程,分别为主进程渲染进程,主进程负责 GUI 部分,渲染进程负责页面的展示。

主进程

main.js

const { app, BrowserWindow } = require('electron')
function createWindow () {
// 创建浏览器窗口
let win = new BrowserWindow({ width: 800, height: 600 })
// 然后加载 app 的 index.html.
win.loadFile('index.html')
}
app.on('ready', createWindow)

渲染进程

index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>

主进程使用BrowserWindow实例创建页面。 每个BrowserWindow实例都在自己的渲染进程里运行页面。 当一个BrowserWindow实例被销毁后,相应的渲染进程也会被终止。

主进程和渲染进程通讯

进程间通信(IPC,Interprocess communication)是一组编程接口,让开发者能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。

Electron 使用 IPC 的机制,由主进程来创建应用,渲染进程来负责绘制页面,而两个进程之间是无法直接通信的。

Vue+Electron开发跨平台桌面应用实践

渲染进程通过ipcRenderer向主进程发送消息,主进程通过ipcMain监听事件,当事件响应时对消息进行处理。

主进程监听事件的回调函数中会存在event对象及arg对象。arg对象为渲染进程传递过来的参数。

如果主进程执行的是同步方法,回复同步信息时,需要设置event.returnValue,如果执行的是异步方法回复时需要使用event.sender.send向渲染进程发送消息。

下面代码为渲染进程主动向主进程发送消息,在主进程接收后回复渲染进程。

// 主进程
const { ipcMain } = require('electron')
ipcMain.on('asynchronous-message', (event, arg) => {
console.log(arg) // prints "ping"
event.sender.send('asynchronous-reply', 'pong')
})
ipcMain.on('synchronous-message', (event, arg) => {
console.log(arg) // prints "ping"
event.returnValue = 'pong'
})
// 渲染器进程
const { ipcRenderer } = require('electron')
console.log(ipcRenderer.sendSync('synchronous-message', 'ping')) // prints "pong"
ipcRenderer.on('asynchronous-reply', (event, arg) => {
console.log(arg) // prints "pong"
})
ipcRenderer.send('asynchronous-message', 'ping')

有时候我们也需要由主进程主动向渲染进程发送消息,面对这种情况我们可以在主进程中通过BrowserWindow对象的webContets.send方法向渲染进程发送消息。

// 主进程
const { app, BrowserWindow } = require('electron')
function createWindow () {
let win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
// 向渲染进程发送消息
win.webContents.send('main-process-message', 'ping')
}
app.on('ready', createWindow)
// 渲染器进程
const { ipcRenderer } = require('electron')
// 监听主进程发送的消息
ipcRenderer.on('main-process-message', (event, arg) => {
console.log(arg) // prints "ping"
})

持久化存储

在桌面端应用中一些用户设置通常需要进行存持久化存储,方便以后使用的时候获取。 我们做 Web 时候通常是使用像MySQLMongodb等数据库进行持久化存储, 但是当用户安装桌面软件时候不可能让用户在本地安装这类数据库,所以我们需要一个轻量级的本地化数据库。

lowdb是一个基于LodashAPI 的轻量级本地JSON数据库,支持Node.jsbrowserElectron

在我们要开发的工具中,用户的S3配置,已上传文件的CDN目录等信息是需要进行持久化存储的,所有我们采用的lowdb进行数据的存储。

Vue+Electron开发跨平台桌面应用实践

使用也是非常的简单,数据的读写和平常使用Lodash差不多。

安装

npm install lowdb -save

数据存储路径

Electron 提供了获取系统目录的方法,可以很方便的进行一些系统目录的获取。

const { app, remote } = require('electron')
app.getPath('home'); // 获取用户的 home 文件夹(主目录)路径
app.getPath('userData'); // 获取当前用户的应用数据文件夹路径
app.getPath('appData'); // 获取应用程序设置文件的文件夹路径,默认是 appData 文件夹附加应用的名称
app.getPath('temp'); // 获取临时文件夹路径
app.getPath('documents'); // 获取用户文档目录的路径
app.getPath('downloads'); // 获取用户下载目录的路径
app.getPath('music'); // 获取用户音乐目录的路径
app.getPath('pictures'); // 获取用户图片目录的路径
app.getPath('videos'); // 获取用户视频目录的路径
app.getPath('logs'); // 获取应用程序的日志文件夹路径
app.getPath('desktop'); // 获取系统桌面路径

数据库配置

'use strict'
const DataStore = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const path = require('path')
const fs = require('fs-extra')
const { app, remote } = require('electron')
const APP = process.type === 'renderer' ? remote.app : app
const STORE_PATH = APP.getPath('userData') // 将数据库存放在当前用户的应用数据文件夹
if (process.type !== 'renderer') {
if (!fs.pathExistsSync(STORE_PATH)) {
fs.mkdirpSync(STORE_PATH)
}
}
const adapter = new FileSync(path.join(STORE_PATH, '/data.json'))
const db = DataStore(adapter)
// 初始化默认数据
db.defaults({
project: [], // 存储已上传项目的 CDN 配置信息
settings: {
ftp: '', // ftp 用户配置
s3: '', // s3 用户配置
}
}).write()
module.exports = db

后台执行命令行程序

由于睿传是一个命令行工具,并没有对外提供Node.jsAPI,所以用户点击上传按钮时候需要通过Electron在后台运行命令行程序,并且将命令行运行的日志实时渲染到应用的日志界面中,所以在这里利用Node.jschild_process子进程的方式来处理。

'use strict'
import { ipcMain } from 'electron'
import { exec } from 'child_process'
import path from 'path'
import fixPath from 'fix-path'
import { logError, logInfo, logExit } from './log'
const cmdPath = path.resolve(__static, 'lib/rapid_trans') // 睿传路径
let workerProcess
ipcMain.on('upload', (e, {dirPath, cdnPath, isCover}) => {
runUpload(dirPath, cdnPath, isCover)
})
function runUpload (dirPath, cdnPath, isCover) {
let cmdStr = `node src/rapid-trans.js -s "${dirPath}" -p "${cdnPath}" -q`
if (isCover) {
cmdStr += ' -f'
}
fixPath()
logInfo('================== 开始上传 ================== \n')
workerProcess = exec(cmdStr, {
cwd: cmdPath
})
workerProcess.stdout.on('data', function (data) {
logInfo(data)
})
workerProcess.stderr.on('data', function (data) {
logError(data)
})
workerProcess.on('close', function (code) {
logExit(code)
logInfo('================== 上传结束 ================== \n')
})
}
// log.js
'use strict'
const win = global.mainWindow
export function logInfo (msg) {
win.webContents.send('logInfo', msg)
}
export function logError (msg) {
win.webContents.send('logError', msg)
}
export function logExit (msg) {
win.webContents.send('logExit', msg)
}
export default {
logError,
logExit,
logInfo
}

应用打包

应用开发完成后需要进行打包,我们可以使用electron-builder将应用打包成 Windows、Mac 平台的应用。

在执行npm run build之前需要在package.json进行打包配置的编辑。

{
"build": {
"productName": "S3上传工具",  // 应用名称,最终生成的可执行文件的名称
"appId": "com.autohome.s3", // 应用 APP.ID
"directories": {
"output": "build" // 打包后的输出目录
},
"asar": false, // 关闭 asar 格式
"publish": [
{
"provider": "generic", // 服务器提供商
"url": ""
},
"linux": {
"icon": "build/icons"
}
}
}

应用更新提示

由于软件不进行 App Store 的上架,只在团队内部使用没有配置证书,不配置证书的话 Mac 中无法进行自动更新安装,所以我们在检测到用户的当前版本不是最新版本的时候是采用的弹层提示的方式让用户自己下载。

使用electron-updater打包的应用自动更新非常方便,将打包后 build 目录下的latest-mac.yml文件上传至package.json中配置的publish.url目录下,并且在主进程文件中监听update-availabl事件。

// 主进程 main.js
import { autoUpdater } from 'electron-updater'
// 关闭自动下载
autoUpdater.autoDownload = false
// 应用可更新
autoUpdater.on('update-available', (info) => {
// 通知渲染进程应用需要更新
mainWindow.webContents.send('updater', info)
})
app.on('ready', () => {
if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdates()
})
// 渲染进程 updater.js
import { ipcRenderer, shell } from 'electron'
import { MessageBox } from 'element-ui'
ipcRenderer.on('updater', (e, info) => {
MessageBox.alert(info.releaseNotes, `请升级${info.version}版本`, {
confirmButtonText: '立即升级',
showClose: false,
closeOnClickModal: false,
dangerouslyUseHTMLString: true,
callback (action) {
if (action === 'confirm') {
// 在用户的默认浏览器中打开存放应用安装包的网络地址
shell.openExternal('http://10.168.0.49/songjinda/s3_tool/download/')
return false
}
}
})
})

作者|宋金达

发表回复