背景
前面介绍了包括书签管理,Markdown管理系统等,属于独立的工具。日常工作中也经常会借助一些第三方辅助工具。比如:定时提醒工具,用于提醒自己写周报,开会,沟通交流等等;在线网盘,在线存储一些资料(保证安全的前提下);图床工具,辅助图片插入markdown中;代码生成器,辅助生成固定代码模板;以及包括ico图片生成,json格式化等等
这些工具分散在不同的角落,有些是C/S工具,有些是互联网上在线操作,每次使用都感觉很凌乱。这时候就在琢磨了,我自己能不能写一个工具,把这些工具都集成起来。
需求
我自己个人的习惯,在做事情之前,需要把事情先分析一下,我做这个工具目的是什么,要解决什么问题,能带来什么帮助。最后总结出我想要达到的效果。
- 工具必须是跨平台的,我有开发电脑(Windows),会议、交流电脑(MacOS)的,我想这个工具在所有电脑上都可以使用
- 数据存在互联网云端,这样工具到那都可以正常使用
- 数据传输需要安全,比如网盘功能,存储和传输就必须要安全,除了https通道加密以外,数据本身也要加密
- 工具可以常驻通知区域,这样不用频繁开关(类似QQ)
- 工具支持自定义快捷方式,只要工具挂在那,不用打开,通过快捷方式就能操作某些功能。
- 工具颜值要稍微高点,不能太Low
经过今天的技术选型和分析以后,终于是选定了Electron + Vue作为开发技术。
前面也介绍到过,这个选型不仅仅是因为工具需要,我们公司内部还有很多客户端软件,因为甲方属于政府行业,最近几年一直在搞终端国产化,我们的工具本身也面临转型。用QT这种门槛比较高,维护成本比较大。所以先行研究下这个技术应用的可行性。
技术介绍
Electron 是 GitHub 开发的一个开源框架。它允许使用 Node.js(作为后端)和 Chromium(作为前端)完成桌面 GUI 应用程序的开发。Electron 可以用于构建具有 html、css、JAVAScript 的跨平台桌面应用程序,它通过将 Chromium 和 node.js 合同一个运行的环境中来实现这一点,应用程序可以打包到 mac、windows 和 linux 系统上。
总的来说,就是Electron提供一个底座,用于和操作系统交互(包括打印机、控制台、提醒等等),实际内部都是html页面。
最终是通过 Electron + Vue + Ant Design Vue 来实现整个功能。
Electron主进程
主进程其实就是前面提到的底座,然后就是创建一个底座容器 createWindow
<!--全局入口页面-->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>管理小工具</title>
<% if (htmlWebpackPlugin.options.nodeModules) { %>
<!-- Add `node_modules/` to global paths so `require` works properly in development -->
<script>
require('module').globalPaths.push('<%= htmlWebpackPlugin.options.nodeModules.replace(/\\/g, '\\\\') %>')
</script>
<% } %>
</head>
<body>
<div id="app"></div>
<!-- Set `__static` path to static files in production -->
<% if (!process.browser) { %>
<script>
if (process.env.NODE_ENV !== 'development') window.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\')
</script>
<% } %>
<!-- webpack builds are automatically injected -->
</body>
</html>
//index.js
app.on('ready', () => {
//创建主窗体
createWindow();
//初始化通知区域图标
initTrayIcon();
// 隐藏菜单栏
const {Menu} = require('electron');
Menu.setApplicationMenu(null);
// process.platform就是平台类型, windows,darwin(macos), linux
if (process.platform === 'darwin') {
app.dock.hide();
}
})
//程序退出,关闭等事件处理
app.on('window-all-closed', () => {
app.quit()
})
app.on('will-quit', function () {
globalShortcut.unregisterAll()
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
let mainWindow
//渲染进程(vue页面)地址,调试阶段是http地址,发布阶段就是index.html
const winURL = process.env.NODE_ENV === 'development'
? `http://localhost:9088`
: `file://${__dirname}/index.html`
function createWindow() {
//创建主窗体,可以设置大小,图标等
mainWindow = new BrowserWindow({
height: 700,
useContentSize: true,
width: 1200,
minWidth: 1200,
minHeight: 700,
resizable: true,
icon: __static + '/image/tool.png',
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}// show: false
})
//加载渲染进程地址(vue发布的页面)
mainWindow.loadURL(winURL)
//打开调试对话框,渲染进程其实就是html页面,所以打开调试对话框,其实就是调试前端的时候按F12
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools();
} else {
//隐藏任务栏,我只需要通知区域图标就行了,所以就隐藏了
mainWindow.setSkipTaskbar(true);
}
mainWindow.on('close', (e) => {
if (!canQuit) {
e.preventDefault();
mainWindow.hide();
}
})
//注册快捷方式
const shortArray = db.getShortCutData();
for (let i = 0; i < shortArray.length; i++) {
registerShortCut(shortArray[i]["key"], shortArray[i]["shortcut"])
}
}
通知区域图标
前面提到,我需要像QQ一样,把程序挂在右下角的通知区域(Tray),所以这块也需要处理下。主进程启动的时候,也会调用initTrayIcon 方法来初始化通知区域图标。
/**
* 这里定义let tray = null在外面,也就是全局,是必须要这么去弄的
* 如果定义在方法体内,后续这个tray对象会被自动回收掉,你看到的效果就是发现通知区域图标没了
* 定义在全局的话,在整个生命周期内,它都不会被回收
*/
let tray = null;
function initTrayIcon() {
const tempWindow = mainWindow;
//这里定义图片的时候要区分windows和mac,mac的docker栏图标尺寸只要16x16,超过这个就太大了
if (process.platform === 'darwin') {
tray = new Tray(__static + '/image/tool16.png');
}
else{
tray = new Tray(__static + '/image/tool.png');
}
//图标上面按右键弹出的按钮,一个是打开主页面,一个是退出程序
const trayContextMenu = Menu.buildFromTemplate([
{
label: '打开',
click: () => {
mainWindow.show()
}
}, {
label: '退出',
click: () => {
/**
* 主程序里关闭按钮点击的时候需要处理
* mainWindow.on('close', (e) => {
if (!canQuit) {
e.preventDefault();
mainWindow.hide();
}
})
如果是主程序点击关闭按钮,这时候是操作页面隐藏,通知区域图标存在
如果是通知区域图标上点击退出,才是真正的退出程序
*/
canQuit = true;
app.quit();
}
}
]);
tray.setToolTip('xxxxx管理系统');
tray.on('click', () => {
tempWindow.show();
});
tray.on('right-click', () => {
//右键弹出按钮
tray.popUpContextMenu(trayContextMenu);
});
}
渲染进程
渲染进程和普通的Vue工程一模一样,也是通过main.js, App.vue等作为入口,这个就不多做介绍。
// main.js
import Vue from 'vue'
import App from './App'
import router from './router'
import Antd from 'ant-design-vue';
import axios from "axios";
import 'ant-design-vue/dist/antd.css';
if (!process.env.IS_WEB) { // noinspection JSCheckFunctionSignatures
Vue.use(require('vue-electron'))
}
Vue.config.productionTip = false
Vue.use(Antd);
const { ipcRenderer } = require("electron");
const {PosPrinter} = require('electron').remote.require("electron-pos-printer");
Vue.prototype.$ipcRenderer = ipcRenderer;
Vue.prototype.$printer = PosPrinter;
Vue.prototype.$axios = axios;
import common from "./common";
const initInfo= common.getInitInfo(ipcRenderer);
Vue.prototype.$configInfo = initInfo.configInfo;
Vue.prototype.$systemInfo = initInfo.systemInfo;
Vue.prototype.$baseUrl = initInfo.configInfo.baseUrl
import './assets/iconfont/iconfont.css'
import db from "./util/datasoure"
// noinspection JSUnusedGlobalSymbols
Vue.prototype.$db = db;
import Clipboard from 'vue-clipboard2'
Vue.use(Clipboard)
import colorPicker from "vcolorpicker"
Vue.use(colorPicker)
// noinspection JSUnusedGlobalSymbols
Vue.prototype.$common = common;
process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
import 'viewerjs/dist/viewer.css'
/* eslint-disable no-new */
const vue = new Vue({
components: { App },
router,
template: '<App/>'
}).$mount('#app')
export default vue
主子进程之间通信
主子进程之间的通信是这套框架一个比较重要的核心,Html页面是没有权限操作本地操作系统相关内容的,所以类似打印、剪切板等功能,都是通过渲染进程向主进程发起通信请求,主进程完成任务以后,再回复渲染进程结果。目前
主进程事件注册
//index.js
import {ipcMain} from 'electron'
const { clipboard } = require('electron')
//同步模式回调
ipcMain.on("customEventSync", (evt,data)=>{
/**
* data内容就是渲染进程发送过来的数据
* 主进程操作相关业务逻辑
* 比如 clipboard.writeText("写入剪切板信息")
*/
evt.returnValue = Object; //向渲染进程传递的消息, 同步模式
})
//异步模式回调
ipcMain.on("customEventAsync", (evt,data)=>{
/**
* data内容就是渲染进程发送过来的数据
* 主进程操作相关业务逻辑
* 比如 clipboard.writeText("写入剪切板信息")
*/
event.sender.send('callBack', Object);; //向渲染进程传递的消息, 异步模式
})
渲染进程发送事件
//main.js中全局注册$ipcRenderer,后续渲染进程页面可以直接使用
const { ipcRenderer } = require("electron");
Vue.prototype.$ipcRenderer = ipcRenderer;
<template>
<div>xxxxx</div>
</template>
<script>
export default {
name: "GlobalSearch",
data(){
return{
}
},mounted(){
//处理异步回调的信息
this.$ipcRenderer.on('callBack', (event, arg)=> {
console.log("这是来着异步回调的信息"); // prints "pong"
});
},methods:{
copyAsync(record){
//异步通信,回调信息通过mounted定义的异步事件接受
this.$ipcRenderer.send("customEventAsync",record);
},
copySync(record){
//同步通信,等待结果返回,直接获取
const result = this.$ipcRenderer.sendSync("customEventSync",record);
}
}
}
</script>
客户端认证
也是为了客户端安全,每个客户端使用之前,需要先从服务端分配一个RSA的公钥信息,每个公钥信息都是和客户端Mac地址绑定的,后续所有的请求都会进行数据加密,而且加密就是通过分配的RSA公钥进行加密。
/**
* 获取当前客户端mac地址
* 这里其实有点小问题,就是如果你是多网卡的,比如装了虚拟机软件什么的,可能会出现mac地址和上次不一样的情况
*/
const getMac = require('getmac').default
function ValidateClient(){
//先做处理,如果公钥和用户有问题,则跳转到输入公钥和用户信息界面,成功了就继续,失败则不处理
if (this.$configInfo.publicKey === "" || this.$configInfo.user === "") {
//跳出输入公钥和用户唯一标签页面
this.$refs.ShowLoadingEncrypt.show()
return;
}
const that = this;
//如果本地已有相关信息,则去服务端验证下,是否有效
//用户信息后台自动控制,不需要人为传递
//传递的mac信息是通过公钥加密后的数据,用于服务端验证
activeClient(this.$systemInfo.mac).then(() => {
if (this.product === "development") {
this.activeKey = this.defaultActiveKey;
}
}).catch(() => {
//如果验证失败,也是跳到页面,重新输入公钥和用户信息
that.$nextTick(() => {
that.$refs.ShowLoadingEncrypt.show()
})
});
}
/**
后台验证RSA,MAC地址逻辑
*/
@PostMapping(value = "active", produces = "application/json")
public ResponseBodyVo active(@RequestBody MacRequestInfo macRequestInfo) {
String result = rsaService.activeInfo(macRequestInfo);
if(!StringUtils.isEmpty(result)){
return ResponseBodyVo.activeFailure(result);
}
return ResponseBodyVo.success("");
}
public String activeInfo(MacRequestInfo macRequestInfo) {
//看分配的唯一标签是不是在后台有记录
RsaEntity rsaEntityRequest = new RsaEntity();
rsaEntityRequest.setUser(macRequestInfo.getUser());
RsaEntity rsaEntity = rsaDao.getRsaEntity(rsaEntityRequest);
if (rsaEntity == null) {
return ("找不到有效的加密信息!");
}
String privateKey = rsaEntity.getPrivateKey();
//客户端传过来的mac信息,就是通过客户端公钥加密的,这里要解密一下,看加密数据是不是有效的
String content = macRequestInfo.getContent();
content = content.replace("\"","");
if (StringUtils.isEmpty(rsaEntity.getMac())) {
rsaEntity.setMac(content);
rsaDao.updateMac(rsaEntity);
} else {
if (!StringUtils.equalsIgnoreCase(content, rsaEntity.getMac())) {
return ("当前私钥已绑定MAC地址" + rsaEntity.getMac() + ",请重新检查!");
}
}
return "";
}
如果客户端参数不合法,就不允许打开
数据接口标准
前后端统一数据格式,后台通过ResponseBodyVo统一返回格式,前端通过Axios的响应拦截器处理。
//后端统一
public class ResponseBodyVo {
//错误代码
private int code;
//错误信息
private String msg;
//成功返回实体
private Object data;
//数据是否被加密
private Boolean encrypt = false;
//数量
private int count;
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
@JsonIgnore
private RsaRequestEntity rsaRequestEntity;
public ResponseBodyVo(int code, String msg, Object data){
this.code = code;
this.msg = msg;
this.data =data;
}
public ResponseBodyVo(int code, String msg, Object data, boolean encrypt, RsaRequestEntity rsaRequestEntity){
this.code = code;
this.msg = msg;
this.data = data;
this.encrypt = encrypt;
this.rsaRequestEntity = rsaRequestEntity;
}
public static ResponseBodyVo success(Object data){
return new ResponseBodyVo(200, "", data);
}
public static ResponseBodyVo successDownload(Object data){
return new ResponseBodyVo(205, "", data);
}
public static ResponseBodyVo activeFailure(Object data){
return new ResponseBodyVo(208, "", data);
}
public static ResponseBodyVo success(Object data, boolean encrypt, RsaRequestEntity rsaRequestEntity){
return new ResponseBodyVo(200, "", data, encrypt, rsaRequestEntity);
}
public static ResponseBodyVo failure(String msg){
return new ResponseBodyVo(500, msg, null);
}
public static ResponseBodyVo failureAuth(String msg){
return new ResponseBodyVo(401, msg, null);
}
public Boolean getEncrypt() {
return encrypt;
}
public void setEncrypt(Boolean encrypt) {
this.encrypt = encrypt;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public RsaRequestEntity getRsaRequestEntity() {
return rsaRequestEntity;
}
public void setRsaRequestEntity(RsaRequestEntity rsaRequestEntity) {
this.rsaRequestEntity = rsaRequestEntity;
}
}
//统一返回数据格式
@PostMapping(value = "getDistDeleteList")
public ResponseBodyVo getDistDeleteList(@RequestBody RsaRequestEntity rsaRequestEntity){
DistDeleteEntity distDeleteEntity = RequestParamUtil.getRequestParam(rsaRequestEntity,DistDeleteEntity.class);
//数据自动加密处理
return ResponseBodyVo.success(distDeleteService.getDistDeleteList(distDeleteEntity),true,rsaRequestEntity);
}
//前端拦截器处理
service.interceptors.response.use(res => {
// 未设置状态码则默认成功状态
const code = res.data.code || 200;
// 208特殊处理,代表公私钥校验失败!
if (code === 208) {
return Promise.reject(new Error(res.data.data))
}else if(code===205 || res.data.type==="application/octet-stream"){
//文件流下载
return res.data;
} else if(code!==200){
_this.$message.error(res.data.msg);
return Promise.reject(new Error(res.data.data))
}
else {
//数据进行解密
if(!res.data.data){
return res.data;
}
else if(res.data.data===''){
return "";
}
else{
return res.data.data;
}
}
},
error => {
if (error.response) {
_this.$message.error(error.response.data.msg)
return Promise.reject(error.response.data)
} else {
console.log(error);
}
}
)
数据加密
在上一步数据接口标准的基础上,统一对传输数据内容进行了加密解密。主要是通过JSEncrypt来实现。
yarn add jsencrypt
这里存在问题,jsencrypt原生不支持分段加密解密,也就是内容大的数据,加解密会失败,需要改一下源代码。
我的处理方式,直接在node_modules下的jsencrypt文件夹复制到源代码工程内,然后改一下相关代码,调用的地方直接应用源代码中的内容。
路径:libs\jsencrypt\lib\JSEncrypt.js, 添加两段代码就行。
//分段解密
JSEncrypt.prototype.decryptUnicodeLong = function (string) {
const k = this.getKey();
const maxLength = ((k.n.bitLength()+7)>>3)*2;
try {
const hexString = b64tohex(string);
let decryptedString = "";
const rexStr=".{1," + maxLength + "}";
const rex =new RegExp(rexStr, 'g');
const subStrArray = hexString.match(rex);
if(subStrArray){
subStrArray.forEach(function (entry) {
decryptedString += k.decrypt(entry);
});
return decryptedString;
}
} catch (ex) {
return false;
}
};
//分段加密
JSEncrypt.prototype.encryptUnicodeLong = function (string) {
const k = this.getKey();
//根据key所能编码的最大长度来定分段长度。key size - 11:11字节随机padding使每次加密结果都不同。
const maxLength = ((k.n.bitLength()+7)>>3)-11;
try {
let subStr="", encryptedString = "";
let subStart = 0, subEnd=0;
let bitLen=0, tmpPoint=0;
for(let i = 0, len = string.length; i < len; i++){
//js 是使用 Unicode 编码的,每个字符所占用的字节数不同
const charCode = string.charCodeAt(i);
if(charCode <= 0x007f) {
bitLen += 1;
}else if(charCode <= 0x07ff){
bitLen += 2;
}else if(charCode <= 0xffff){
bitLen += 3;
}else{
bitLen += 4;
}
//字节数到达上限,获取子字符串加密并追加到总字符串后。更新下一个字符串起始位置及字节计算。
if(bitLen>maxLength){
subStr=string.substring(subStart,subEnd)
encryptedString += k.encrypt(subStr);
subStart=subEnd;
bitLen=bitLen-tmpPoint;
}else{
subEnd=i;
tmpPoint=bitLen;
}
}
subStr=string.substring(subStart,len)
encryptedString += k.encrypt(subStr);
return hex2b64(encryptedString);
} catch (ex) {
return false;
}
};
前端统一加密
//在axios请求拦截器里统一处理
import { JSEncrypt } from '../libs/jsencrypt/lib/JSEncrypt'
// request拦截器
service.interceptors.request.use(config => {
//........
const encryptStr = new JSEncrypt({});
encryptStr.setPublicKey(_this.$configInfo.publicKey);
let data = config.data;
if(typeof(data)!=="string"){
data = JSON.stringify(data);
}
try{
const enString = encryptStr.encryptUnicodeLong(data);
//所有返回后端的格式都是一样的,user就是唯一标签,content就是加密后的实体信息
config.data = {
user: _this.$configInfo.user,
content: enString
}
return config
}
catch (e){
_this.$configInfo.publicKey="";
_this.$configInfo.user="";
}
//.......
return config;
}, error => {
Promise.reject(error)
})
前端统一解密
import { JSEncrypt } from '../libs/jsencrypt/lib/JSEncrypt'
// 响应拦截器
service.interceptors.response.use(res => {
//...... 代码略,常规判断,获取到返回的加密数据
const tempDecrypt = new JSEncrypt();
tempDecrypt.setPublicKey(_this.$configInfo.publicKey);
let result = tempDecrypt.decryptUnicodeLong(res.data.data);
//解决中文乱码问题,后台都会encode下,这里decode解析下
result = decodeURIComponent(result);
return JSON.parse(result);
},
error => {
if (error.response) {
_this.$message.error(error.response.data.msg)
return Promise.reject(error.response.data)
} else {
console.log(error);
}
}
)
后端统一加密
public static String encryptResponseData(RsaRequestEntity rsaRequestEntity, Object data){
RsaDao rsaDao = SpringContextHolder.getBean(RsaDao.class);
RsaEntity rsaEntityQuery = new RsaEntity();
rsaEntityQuery.setUser(rsaRequestEntity.getUser());
assert rsaDao != null;
RsaEntity rsaEntity = rsaDao.getRsaEntity(rsaEntityQuery);
if(rsaEntity==null){
throw new GlobalException("数据加密失败!!");
}
try {
String result = URLEncoder.encode(objectMapper.writeValueAsString(data),"UTF-8");
//这里要处理下+号
result = result.replace("+","%20");
return RsaUtils.encryptByPrivateKey(rsaEntity.getPrivateKey(), result);
} catch (JsonProcessingException | UnsupportedEncodingException e) {
throw new GlobalException("数据解析失败!!" + e.getMessage());
}
}
后端统一解密
/**
* 按条件查询文件回收站信息
* @param rsaRequestEntity rsa加密实体
*/
@PostMapping(value = "getDistDeleteList")
public ResponseBodyVo getDistDeleteList(@RequestBody RsaRequestEntity rsaRequestEntity){
//数据解密过程
DistDeleteEntity distDeleteEntity = RequestParamUtil.getRequestParam(rsaRequestEntity,DistDeleteEntity.class);
return ResponseBodyVo.success(distDeleteService.getDistDeleteList(distDeleteEntity),true,rsaRequestEntity);
}
//单个实体
public static <T> T getRequestParam(RsaRequestEntity rsaRequestEntity, Class<?> cls){
try {
init();
String content = decryptString(rsaRequestEntity);
return (T)objectMapper.readValue(content, cls);
} catch (JsonProcessingException e) {
throw new GlobalException("数据解密失败!请联系系统管理员!" + e.getMessage());
}
}
//list+实体
public static <T> List<T> getRequestParamList(RsaRequestEntity rsaRequestEntity, Class<?> cls){
try {
init();
String content = decryptString(rsaRequestEntity);
CollectionType listType = objectMapper.getTypeFactory().constructCollectionType(ArrayList.class, cls);
return objectMapper.readValue(content, listType);
} catch (JsonProcessingException e) {
throw new GlobalException("数据解密失败!请联系系统管理员!" + e.getMessage());
}
}
public static String decryptString(RsaRequestEntity rsaRequestEntity){
RsaEntity rsaEntityRequest = new RsaEntity();
rsaEntityRequest.setUser(rsaRequestEntity.getUser());
RsaDao rsaDao = SpringContextHolder.getBean(RsaDao.class);
assert rsaDao != null;
RsaEntity rsaEntity = rsaDao.getRsaEntity(rsaEntityRequest);
if(rsaEntity==null){
throw new GlobalException("找不到有效的加密信息!");
}
String privateKey = rsaEntity.getPrivateKey();
return RsaUtils.decryptByPrivateKey(privateKey, rsaRequestEntity.getContent());
}
客户端日志管理
//日志util,供外部调用的。
import logger from 'electron-log'
import {app} from 'electron'
Object.assign(console, logger.functions);
logger.transports.file.level = 'debug'
logger.transports.file.maxSize = 1002430 // 10M
logger.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}]{scope} {text}'
let date = new Date()
date = date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate()
//日志存储地址
logger.transports.file.file = app.getPath('userData') + '\\electron_log\\app\\' + date + '.log'
logger.info(logger.transports.file.getFile())
//封装几个日志记录方法
const info =(param)=>{
logger.info(param)
}
const error =(param)=> {
logger.error(param)
}
const warn =(param)=> {
logger.warn(param)
}
export {
info,
error,
warn
}
快捷方式注册
快捷键是我日常比较常用的,这个工具我定义了几个核心的快捷键,比如打开调试页面,重新加载工程等,这个在发布运行的时候很有用,有时候碰到问题了,可以快捷键打开调试页面,看中间出了什么问题。
globalShortcut.register("ctrl+shift+a", function () {
//自定义动作
})
//撤销快捷键
globalShortcut.unregister("ctrl+shift+a");
//撤销所有的快捷键
globalShortcut.unregisterAll("ctrl+shift+a");
快捷键支持修改
打包发布
#核心都在build.js里面,里面定义了主进程,渲染进程,WEB相关等各类打包参数
node .electron-vue/build.js && electron-builder
定义打包信息package.json
"build": {
"productName": "xxxxxxxx",
"appId": "com.xxxxxx",
"directories": {
"output": "build"
},
"asar": true,
"asarUnpack": [
"./node_modules/node-notifier/vendor/**"
],
"extraResources": [
{
"from": "static/ColorPicker",
"to": "./ColorPicker"
}
],
"files": [
"dist/electron/**/*"
],
"dmg": {
"contents": [
{
"x": 410,
"y": 150,
"type": "link",
"path": "/Applications"
},
{
"x": 130,
"y": 150,
"type": "file"
}
]
},
"mac": {
"icon": "build/icons/setting.icns"
},
"win": {
"icon": "build/icons/favicon.ico"
},
"linux": {
"icon": "build/icons"
}
},
builder.js
process.env.NODE_ENV = 'production'
const { say } = require('cfonts')
const chalk = require('chalk')
const del = require('del')
// noinspection JSUnusedLocalSymbols
const { spawn } = require('child_process')
const webpack = require('webpack')
const Listr = require('listr')
//主进程配置信息
const mainConfig = require('./webpack.main.config')
//渲染进程配置信息
const rendererConfig = require('./webpack.renderer.config')
//web相关配置信息
const webConfig = require('./webpack.web.config')
const doneLog = chalk.bgGreen.white(' DONE ') + ' '
const errorLog = chalk.bgRed.white(' ERROR ') + ' '
const okayLog = chalk.bgBlue.white(' OKAY ') + ' '
const isCI = process.env.CI || false
const Multispinner = require('multispinner')
if (process.env.BUILD_TARGET === 'clean') clean()
else if (process.env.BUILD_TARGET === 'web') web()
else build()
function clean () {
del.sync(['build/*', '!build/icons', '!build/icons/icon.*'])
console.log(`\n${doneLog}\n`)
process.exit()
}
async function build () {
greeting()
del.sync(['dist/electron/*', '!.gitkeep'])
const tasksMain = ['main', 'renderer']
new Multispinner(tasksMain, {
preText: 'building',
postText: 'process'
})
let results = ''
const tasks = new Listr(
[
{
title: 'building master process',
task: async () => {
await pack(mainConfig)
.then(result => {
results += result + '\n\n'
})
.catch(err => {
console.log(`\n ${errorLog}failed to build main process`)
console.error(`\n${err}\n`)
})
}
},
{
title: 'building renderer process',
task: async () => {
await pack(rendererConfig)
.then(result => {
results += result + '\n\n'
})
.catch(err => {
console.log(`\n ${errorLog}failed to build renderer process`)
console.error(`\n${err}\n`)
})
}
}
],
{ concurrent: 2 }
)
await tasks
.run()
.then(() => {
process.stdout.write('\x1B[2J\x1B[0f')
console.log(`\n\n${results}`)
console.log(`${okayLog}take it away ${chalk.yellow('`electron-builder`')}\n`)
process.exit()
})
.catch(() => {
process.exit(1)
})
}
function pack (config) {
return new Promise((resolve, reject) => {
config.mode = 'production'
webpack(config, (err, stats) => {
if (err) reject(err.stack || err)
else if (stats.hasErrors()) {
let err = ''
stats.toString({
chunks: false,
colors: true
})
.split(/\r?\n/)
.forEach(line => {
err += ` ${line}\n`
})
reject(err)
} else {
resolve(stats.toString({
chunks: false,
colors: true
}))
}
})
})
}
function web () {
del.sync(['dist/web/*', '!.gitkeep'])
webConfig.mode = 'production'
webpack(webConfig, (err, stats) => {
if (err || stats.hasErrors()) console.log(err)
console.log(stats.toString({
chunks: false,
colors: true
}))
process.exit()
})
}
function greeting () {
const cols = process.stdout.columns
let text
if (cols > 85) text = 'lets-build'
else if (cols > 60) text = 'lets-|build'
else text = false
if (text && !isCI) {
say(text, {
colors: ['yellow'],
font: 'simple3d',
space: false
})
} else console.log(chalk.yellow.bold('\n lets-build'))
console.log()
}
主进程配置信息 webpack.main.config.js
process.env.BABEL_ENV = 'main'
const path = require('path')
const { dependencies } = require('../package.json')
const webpack = require('webpack')
const MinifyPlugin = require("babel-minify-webpack-plugin")
let mainConfig = {
entry: {
main: path.join(__dirname, '../src/main/index.js')
},
externals: [
...Object.keys(dependencies || {})
],
devtool:'inline-source-map',
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.node$/,
use: 'node-loader'
}
]
},
node: {
__dirname: process.env.NODE_ENV !== 'production',
__filename: process.env.NODE_ENV !== 'production'
},
output: {
filename: '[name].js',
libraryTarget: 'commonjs2',
path: path.join(__dirname, '../dist/electron')
},
plugins: [
new webpack.NoEmitOnErrorsPlugin()
],
resolve: {
extensions: ['.js', '.json', '.node']
},
target: 'electron-main'
}
if (process.env.NODE_ENV !== 'production') {
mainConfig.plugins.push(
new webpack.DefinePlugin({
'__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"`
})
)
}
if (process.env.NODE_ENV === 'production') {
mainConfig.plugins.push(
new MinifyPlugin(),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"'
})
)
}
module.exports = mainConfig
渲染进程配置信息 webpack.renderer.config.js
process.env.BABEL_ENV = 'renderer'
const path = require('path')
const { dependencies } = require('../package.json')
const webpack = require('webpack')
const MinifyPlugin = require("babel-minify-webpack-plugin")
const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
let whiteListedModules = ['vue','ant-design-vue']
let rendererConfig = {
devtool: '#cheap-module-eval-source-map',
entry: {
renderer: path.join(__dirname, '../src/renderer/main.js')
},
externals: [
...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d))
],
module: {
rules: [
{
test: /\.less$/,
use: ['vue-style-loader', 'css-loader', 'less-loader']
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
},
{
test: /\.html$/,
use: 'vue-html-loader'
},
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.node$/,
use: 'node-loader'
},
{
test: /\.vue$/,
use: {
loader: 'vue-loader',
options: {
extractCSS: process.env.NODE_ENV === 'production',
loaders: {
sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
scss: 'vue-style-loader!css-loader!sass-loader',
less: 'vue-style-loader!css-loader!less-loader'
}
}
}
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: {
loader: 'url-loader',
query: {
limit: 10000,
name: 'imgs/[name]--[folder].[ext]'
}
}
},
{
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: 'media/[name]--[folder].[ext]'
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: {
loader: 'url-loader',
query: {
limit: 10000,
name: 'fonts/[name]--[folder].[ext]'
}
}
}
]
},
node: {
__dirname: process.env.NODE_ENV !== 'production',
__filename: process.env.NODE_ENV !== 'production'
},
plugins: [
new VueLoaderPlugin(),
new MiniCssExtractPlugin({filename: 'styles.css'}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.resolve(__dirname, '../src/index.ejs'),
templateParameters(compilation, assets, options) {
return {
compilation: compilation,
webpack: compilation.getStats().toJson(),
webpackConfig: compilation.options,
htmlWebpackPlugin: {
files: assets,
options: options,
},
process,
};
},
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true
},
nodeModules: process.env.NODE_ENV !== 'production'
? path.resolve(__dirname, '../node_modules')
: false
}),
new webpack.NoEmitOnErrorsPlugin()
],
output: {
filename: '[name].js',
libraryTarget: 'commonjs2',
path: path.join(__dirname, '../dist/electron')
},
resolve: {
alias: {
'@': path.join(__dirname, '../src/renderer'),
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['.js', '.vue', '.json', '.css', '.node']
},
target: 'electron-renderer'
}
if (process.env.NODE_ENV !== 'production') {
rendererConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.DefinePlugin({
'__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"`,
'process.env.VUE_APP_SERVER_URL':'http://localhost:8188'
})
)
}
if (process.env.NODE_ENV === 'production') {
rendererConfig.devtool = ''
rendererConfig.plugins.push(
new MinifyPlugin(),
new CopyWebpackPlugin([
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/electron/static'),
ignore: ['.*']
}
]),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"',
'process.env.VUE_APP_SERVER_URL':'https://'
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
)
}
module.exports = rendererConfig
web相关配置信息 webpack.web.config.js
process.env.BABEL_ENV = 'web'
const path = require('path')
const webpack = require('webpack')
const MinifyPlugin = require("babel-minify-webpack-plugin")
const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
let webConfig = {
devtool: '#cheap-module-eval-source-map',
entry: {
web: path.join(__dirname, '../src/renderer/main.js')
},
module: {
rules: [
{
test: /\.less$/,
use: ['vue-style-loader', 'css-loader', 'less-loader']
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
},
{
test: /\.html$/,
use: 'vue-html-loader'
},
{
test: /\.js$/,
use: 'babel-loader',
include: [ path.resolve(__dirname, '../src/renderer') ],
exclude: /node_modules/
},
{
test: /\.vue$/,
use: {
loader: 'vue-loader',
options: {
extractCSS: true,
loaders: {
sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
scss: 'vue-style-loader!css-loader!sass-loader',
less: 'vue-style-loader!css-loader!less-loader'
}
}
}
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: {
loader: 'url-loader',
query: {
limit: 10000,
name: 'imgs/[name].[ext]'
}
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: {
loader: 'url-loader',
query: {
limit: 10000,
name: 'fonts/[name].[ext]'
}
}
}
]
},
plugins: [
new VueLoaderPlugin(),
new MiniCssExtractPlugin({filename: 'styles.css'}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.resolve(__dirname, '../src/index.ejs'),
templateParameters(compilation, assets, options) {
return {
compilation: compilation,
webpack: compilation.getStats().toJson(),
webpackConfig: compilation.options,
htmlWebpackPlugin: {
files: assets,
options: options,
},
process,
};
},
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true
},
nodeModules: false
}),
new webpack.DefinePlugin({
'process.env.IS_WEB': 'true'
}),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
],
output: {
filename: '[name].js',
path: path.join(__dirname, '../dist/web')
},
resolve: {
alias: {
'@': path.join(__dirname, '../src/renderer'),
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['.js', '.vue', '.json', '.css']
},
target: 'web'
}
if (process.env.NODE_ENV === 'production') {
webConfig.devtool = ''
webConfig.plugins.push(
new MinifyPlugin(),
new CopyWebpackPlugin([
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/web/static'),
ignore: ['.*']
}
]),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"'
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
)
}
module.exports = webConfig
总结
技术为业务服务,整体框架搭建好以后,就可以逐步实现各项功能了。