父母都是做出纳相关的工作,希望我能给他们做个简单的进销存,在上班的时候使用。开发一个不需要花钱买服务器,不需要依赖网络(更新除外),单机版的程序,对于前端出身的我来说,那么electron或nwjs是最好的选择。
electron官网对electron与nwjs的比较
这里我选择了electron,因为很熟悉vue,就使用国人集成的electron-vue进行快速开发。本地数据库采用轻量嵌入型数据库sqlite3,不二之选。UI组件为iview。
目录
安装python2.7 和 Visual Studio 2015
$ npm install -g vue-cli
$ vue init simulatedgreg/electron-vue easy-invoices
打包选择electron-builder。builder可以打包成具体文件,也可以是exe安装程序,而packager只能打包具体文件。下面会具体说明打包。
该命令会生成一个easy-invoices文件夹,大致目录如下(有细微变动)
nodejs中使用c++模块会涉及到编译问题,该编译常常会导致一些问题发生。
详细的操作请见我的另外一篇文章《electron项目中使用sqlite3的编译问题(windows)》
在使用electron开发之前,我们需要注意以下几点
// vue入口文件
// src/renderer/main.js
if (!process.env.IS_WEB) Vue.use(require('vue-electron'));
…
主进程向渲染进程发送消息:
// src/main/index.js
import { BrowserWindow } from 'electron';
const mainWindow = new BrowserWindow();
mainWindow.webContents.send('messageOne', 'haha');
// 某vue组件
<script>
export default {
created(){
this.$electron.ipcRenderer.on('messageOne', (event, msg) =>{
console.log(msg); // 'haha'
}
}
}
<script>
渲染进程向主进程发送消息:
// src/main/index.js
import { ipcMain } from 'electron';
ipcMain.on('messageTwo', (event,msg) => {
console.log(msg) // 'haha'
});
// 某vue组件
<script>
export default {
created(){
this.$electron.ipcRenderer.send('messageTwo', 'haha');
}
}
<script>
也可以用once,代表只监听一次。通讯的方法还有多种,比如remote模块等。
程序刚启动的时候会在根路径下,我们需要进行根路径的路由开发,或者将根路径重定向至开发的路由上。否则会一片白不显示
封装一个在开发环境下(环境变量:NODE_ENV=development)打印的函数,在关键的节点进行调用方便调试,比如sql语句等。我仅仅是使用console.log,也有其他的第三方浏览器日志插件可以使用。
本项目里因为没有服务器可上报,所以没有做程序日志的收集,必要时可以去做一些本地日志存储,并且上报,比如错误信息、一些有意义的数据等。
程序启动的时候执行建表的sql并捕获错误,如果表存在会抛出错误,这里我们不用管。暴露出去db对象挂载在Vue.prototype上,即可全局调用,接下来就是在业务中各种拼接编(e)写(xin)sql语句了。
这里我并没有封装数据模型或者使用sequelize等orm库,有兴趣的同学可以尝试。
网上SQL教程与sqlite3教程也比较多,这么不一一描述,下面是代码片段:
// src/renderer/utils/db.js
// 建表脚本,导出db对象供之后使用
import fse from 'fs-extra';
import path from 'path';
import sq3 from 'sqlite3';
import logger from './logger';
import { docDir } from './settings';
// 将数据存至系统用户目录,防止用户误删程序
export const dbPath = path.join(docDir, 'data.sqlite3');
fse.ensureFileSync(dbPath);
const sqlite3 = sq3.verbose();
const db = new sqlite3.Database(dbPath);
db.serialize(() => {
/**
* 物品表 GOODS
* name 品名
* standard_buy_unit_price 标准进价
* standard_sell_unit_price 标准售价
* total_amount 总金额
* total_count 总数量
* remark 备注
* create_time 创建时间
* update_time 修改时间
*/
db.run(`CREATE TABLE GOODS(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name VARCHAR(255) NOT NULL,
standard_buy_unit_price DECIMAL(15,2) NOT NULL,
standard_sell_unit_price DECIMAL(15,2) NOT NULL,
total_amount DECIMAL(15,2) NOT NULL,
total_count DECIMAL(15,3) NOT NULL,
remark VARCHAR(255) NOT NULL,
create_time INTEGER NOT NULL,
update_time INTEGER NOT NULL
)`, err => {
logger(err);
});
/**
* 进出明细表 GOODS_DETAIL_LIST
* goods_id 物品id
* count 计数(+加 -减)
* actual_buy_unit_price 实际进价
* actual_sell_unit_price 实际售价
* amount 实际金额
* remark 备注
* latest 是否某物品最新一条记录(不是最新操作无法删除)(1是 0不是)
* create_time 创建时间
* update_time 修改时间
*/
db.run(`CREATE TABLE GOODS_DETAIL_LIST(
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
goods_id INTEGER NOT NULL,
count DECIMAL(15,3) NOT NULL,
actual_sell_unit_price DECIMAL(15,2) NOT NULL,
actual_buy_unit_price DECIMAL(15,2) NOT NULL,
amount DECIMAL(15,2) NOT NULL,
remark VARCHAR(255) NOT NULL,
latest INTEGER NOT NULL,
create_time INTEGER NOT NULL,
update_time INTEGER NOT NULL,
FOREIGN KEY (goods_id) REFERENCES GOODS(id)
)`, err => {
logger(err);
});
});
export default db;
考虑到用户手误卸载或者删除程序安装目录,将数据文件和用户配置存放在C:\Users${username}\easy-invoices路径下。这样如果不小心删了,重新安装还是可以和之前一样。做得更好一些可以在卸载的时候询问是否删除数据和配置(还没尝试过,不知道electron-builder是否支持)
不同于B/S架构,C/S架构必须要做好自己的升级方案,否则用户装好了程序就无法再进行更新了。
主进程使用electron-updater来控制自动更新,渲染进程来做更新的逻辑,每个程序更新的流程都不一样,我的程序是每次启动检测更新,如果有更新就自动下载,下载完成后提示用户是否需要重启更新,用户选择取消则每次开启的时候都会提示一下,用户选择升级那么就重启升级。
因为我的程序是托管在github上,所以不需要设置feedurl(feedurl有默认值,和打包设置有关,我的项目中默认会去github的release api上检测)。如果放在其他服务器上,需要编写检测接口并设置url。electron-updater官方文档
下面是代码片段
$ npm i electron-updater
主进程中
// src/main/index.js
import { autoUpdater } from 'electron-updater';
app.on('ready', () => {
if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdatesAndNotify();
});
function sendUpdateMessage(message, data) {
//往渲染进程发送消息,mainWindow来自new BrowserWindow
mainWindow.webContents.send('update-message', { message, data });
}
// 阻止程序关闭自动安装升级
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on('error', data => {
sendUpdateMessage('error', data);
});
/* // 检查更新
autoUpdater.on('checking-for-update', data => {
sendUpdateMessage('checking-for-update', data);
});*/
// 有可用更新
autoUpdater.on('update-available', data => {
sendUpdateMessage('update-available', data);
});
// 已经最新
autoUpdater.on('update-not-available', data => {
sendUpdateMessage('update-not-available', data);
});
// 更新下载进度事件
autoUpdater.on('download-progress', data => {
sendUpdateMessage('download-progress', data);
});
// 更新下载完成事件
autoUpdater.on('update-downloaded', () => {
sendUpdateMessage('update-downloaded', {});
ipcMain.once('update-now', () => {
autoUpdater.quitAndInstall();
});
});
注意:在升级中可能会有改表结构的操作,我在settings.json里存有版本信息,启动的时候将程序的版本号与settings里面的版本号对比,进行升级,升级完成之后将settings里的版本设置为程序版本
// src/renderer/utils/upgrade.js
import settings from './settings';
import packageJson from '../../../package.json';
// 程序当前版本
const appCurrentVersion = packageJson.version;
import db from './db';
// 罗列增量升级脚本
const incrementalUpgrade = {
'1.0.1':()=>{
db.run(
//修改表数据、结构的脚本等
);
},
'1.0.2':()=>{
db.run(
//修改表数据、结构的脚本等
);
},
}
// 升级前版本
const beforeUpgradeVersion = settings.get('version');
// 用户可能有很多个版本没有升级,寻找执行的脚本 增量执行。
// 遍历incrementalUpgrade对象,大于beforeUpgradeVersion的脚本都要依次执行。(比较时可以把点去掉转为数字类型比较)
...
// 脚本执行完毕
settings.set('version', appCurrentVersion);
下载前可以拿到更新日志、时间、版本号和包大小,下载时可以拿到速度。部分效果展示:
前文提到,我采用的是electron-builder进行打包。electron-builder官方文档
打包的主要配置在package.json里:
{
"scripts":{
"build": "node .electron-vue/build.js && electron-builder",
"build:dir": "node .electron-vue/build.js && electron-builder --dir"
},
"build": {
"productName": "easy-invoices",
"copyright": "caandoll",
"appId": "org.caandoll.easy-invoices",
"directories": {
"output": "build"
},
"files": [
"dist/electron/**/*"
],
"dmg": {
"contents": [
{
"x": 410,
"y": 150,
"type": "link",
"path": "/Applications"
},
{
"x": 130,
"y": 150,
"type": "file"
}
]
},
"mac": {
"icon": "build/icons/icon.png"
},
"win": {
"icon": "build/icons/icon.png"
},
"linux": {
"icon": "build/icons/icon.png"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}
scripts:
travis和appveyor是开源的两个自动化构建平台,免费服务于github等开源项目(不开源项目貌似要给钱)。如果你是在其他这两个CI平台不支持的仓库,可使用其他构建工具,原理相同。
{
"repository": {
"type": "git",
"url": "https://github.com/CaanDoll/easy-invoices.git"
},
"scripts":{
"build:ci": "node .electron-vue/build.js && electron-builder --publish always"
},
}
version: 0.0.{build}
branches:
only:
- master
image: Visual Studio 2017
platform:
- x64
cache:
- node_modules
- '%APPDATA%\npm-cache'
- '%USERPROFILE%\.electron'
- '%USERPROFILE%\AppData\Local\Yarn\cache'
init:
- git config --global core.autocrlf input
install:
- ps: Install-Product node 8 x64
- yarn
build_script:
- yarn build:ci
test: off
接下来提交在github master分支或者merge到master分支(申请merge之后也会触发)就可以触发构建了,在appveyor平台上可以看到。
如果使用一般的a标签,会直接将程序的界面跳转至这个链接,因为本身就是浏览器内核。加上target:_blank的话更会没有反应了。这个时候需要调用electron.shell。上面的**openExternal(url)**方法就是打开浏览器,**openItem(path)**打开文件目录。
// vue入口文件
// src/renderer/main.js
if (!process.env.IS_WEB) Vue.use(require('vue-electron'));
// 某页面组件xxx.vue
<script>
export default {
methods: {
openUrl(url) {
this.$electron.shell.openExternal(url);
},
openPath(path) {
this.$electron.shell.openItem(path);
},
}
};
</script>
如果在服务端进行导出,有两个步骤,第一步是将数据填充并生成excel,第二步是将文件发送出去。使用electron本地进行导出也不例外,但因为不是调用http接口,会有一些差异。
nodejs生成excel在这里就不多描述,以后我会补充相应的文章。在这里先推荐这两个库,如果生成的excel比较简单,横行数列并没有任何样式的,可以使用node-xlsx。如果需要生成较为复杂的excel,比如有样式要求,有合并单元格的需求,可以使用ejsExcel。
假设我们已经导出了一个名为test.xlsx的excel在系统临时目录(os.tmpdir()):C:\Users\username\AppData\Local\Temp\appname\test.xlsx
// src/main/index.js
import { ipcMain } from 'electron';
// mainWindow来自new BrowserWindow
ipcMain.on('download', (event, downloadPath) => {
mainWindow.webContents.downloadURL(downloadPath);// 这个时候会弹出让用户选择下载目录
mainWindow.webContents.session.once('will-download', (event, item) => {
item.once('done', (event, state) => {
// 成功的话 state为completed 取消的话 state为cancelled
mainWindow.webContents.send('downstate', state);
});
});
});
// 渲染进程
ipcRenderer.send('download', 'C:\Users\username\AppData\Local\Temp\appname\test.xlsx');
ipcRenderer.once('downstate', (event, arg) => {
if (arg === 'completed') {
console.log('下载成功');
} else if (arg === 'cancelled'){
console.log('下载取消');
} else {
console.log('下载失败')
}
原生的窗口栏不是那么美观,我们可以去掉原生窗口栏,自己写一个。
主进程
// src/main/index.js
import { BrowserWindow、ipcMain } from 'electron';
// 创建窗口时配置
const mainWindow = new BrowserWindow({
frame: false, // 去掉原生窗口栏
...
});
// 主进程监听事件进行窗口最小化、最大化、关闭
// 窗口最小化
ipcMain.on('min-window', () => {
mainWindow.minimize();
});
// 窗口最大化
ipcMain.on('max-window', () => {
if (mainWindow.isMaximized()) {
mainWindow.restore();
} else {
mainWindow.maximize();
}
});
// 关闭
ipcMain.on('close-window', () => {
mainWindow.close();
});
头部组件或其他组件,这样就可以在自己定义的元素上去执行窗口操作了
<script>
export default {
methods: {
minWindows() {
this.$electron.ipcRenderer.send('min-window');
},
maxWindows() {
this.$electron.ipcRenderer.send('max-window');
},
closeWindows() {
this.$electron.ipcRenderer.send('close-window');
},
};
</script>
css设置拖拽区域,拖拽区域会自动有双击最大化的功能,注意:拖拽区域内的点击、移入移出等事件将无效,需要将拖拽区域内的按钮等元素设为非拖拽区域即可
header {
-webkit-app-region: drag; // 拖拽区域
.version {
.ivu-tooltip {
-webkit-app-region: no-drag; // 非拖拽区域
}
}
.right {
a {
-webkit-app-region: no-drag; // 非拖拽区域
}
}
}
程序启动时,界面渲染需要一定时间,导致白屏一下,体验不好。解决方案一种是将程序的背景色设为html的背景色,另外一种就是等界面加载完毕之后再显示窗口,代码如下:
主进程中
// src/main/index.js
import { BrowserWindow} from 'electron';
const mainWindow = new BrowserWindow({
show: false,
...
});
// 加载好html再呈现window,避免白屏
mainWindow.on('ready-to-show', () => {
mainWindow.show();
mainWindow.focus();
});
electron非常好玩,它解放了我们在浏览器中开发界面的束缚。C/S架构也有很多不同于功能点需要多多考虑。第一次写比较长的文章,个中可能会有手误或者知识错误,顺序也不是最理想的。欢迎讨论,也请各路大牛多多指教,指出不正!