如何用Electron+vue+vite构建桌面端应用(三)
打包工具 electron-builder 常用配置说明
在第一期中我们直接使用了 electron-builder 打包,并没有做详细的介绍,今天介绍下 electron-builder 的配置项及在使用过程中遇到的问题,更加详细的介绍还是要参考官网electron-builder
{
appId: "com.electron.app",
productName: "ElecronApp", //项目名 这也是生成的exe文件的前缀名
copyright: "****", //版权信息
asar: true, // 是否用asar压缩
// 输出文件夹
directories: {
output: "release/${version}",
},
files: ["dist", "resources"], //需要打包的文件
mac: {
icon: "resources/icons/mac/icon.icns",
target: ["dmg"],
artifactName: "${productName}_${version}.${ext}", // 输出包名格式
},
win: {
requestedExecutionLevel: "requireAdministrator", // 管理员权限运行 这里开启管理员权限将会禁止拖拽事件
icon: "resources/icons/win/icon.ico",
target: [
{
target: "nsis",
arch: ["x64"],
},
],
artifactName: "${productName}_${version}.${ext}",
},
nsis: {
oneClick: false, // 是否一键安装
allowElevation: true, // 允许请求提升。 如果为false,则用户必须使用提升的权限重新启动安装程序。
perMachine: false,
allowToChangeInstallationDirectory: true, // 允许修改安装目录
deleteAppDataOnUninstall: false, // 卸载是否删除appData
installerIcon: "", // 安装程序图标
uninstallerIcon: "", //卸载程序图标
createDesktopShortcut: true, // 创建桌面快捷方式
createStartMenuShortcut: true, // 创建开始菜单快捷方式
shortcutName: "electronApp", // 快捷方式名称
include: "./installer.nsh", // 包含的自定义nsis脚本。这里设置了默认安装路劲
},
publish: [
{
provider: "generic",
url: "****", // 更新包发布地址
},
],
}
生成图标 electron-icon-builder
一个图标生成器,用于生成 electron 包所需的所有图标文件
yarn add electron-icon-builder -D
如果 phantomjs 无法下载可以访问phantomjs手动下载,然后放到C:\Users\Administrator\AppData\Local\Temp\phantomjs\phantomjs-2.1.1-windows.zip
先找 UI 要一张 1024*1024
尺寸的 png 图标
执行命令
./node_modules/.bin/electron-icon-builder --input=D:\\study\\electron\\electron-vue-vite-template\\resources\\icon.png --output=./resources/
注意 input 参数是文件绝对路径,output 参数是相对路径
执行成功后即可看到生成的各种尺寸的图标,方便后期修改打包应用图标
在 main 文件夹下新建 utils 文件夹,新建 icon.ts 文件
const resolve = (relativePath: string) => path.resolve(__dirname, relativePath);
export const getIcon = () => {
if (process.platform === "darwin") {
return resolve("../../resources/icons/mac/icon.icns");
} else if (process.platform === "win32") {
return resolve("../../resources/icons/win/icon.ico");
} else {
return resolve("../../resources/icons/png/256x256.png");
}
};
安装调试工具 vue devtools
尽管有 npm 上有 electron-devtools-installer 包,安装后发现并不好用,还是自己来封装下,因为我们的项目使用的是 vue,我们直接封装了 vue devTools
在 utils 文件夹下新建 devtools.ts 文件
import { app, BrowserWindow, session } from "electron";
import fs from "fs";
import { homedir } from "os";
import { join } from "path";
const home = homedir();
const dir = (...paths: string[]) => join(home, ...paths);
/** vue devtool 扩展id */
const vueExtensionId = "nhdogjmejiglipccpnnnanhbledajbpd";
/** Chrome 用户数据基础目录 */
const ChromeUseDataBaseDirMap: Record<string, string> = {
darwin: dir("/Library/Application Support/Google/Chrome"),
win32: dir("/AppData/Local/Google/Chrome/User Data"),
};
const profileDirRegex = /^Default$|^Profile \d+$/;
const chromeUseDataBaseDir = ChromeUseDataBaseDirMap[process.platform];
/**
* 加载 Vue Devtools
*/
export function loadVueDevtools() {
if (app.isPackaged) return;
if (session.defaultSession.getExtension(vueExtensionId)) return;
if (!fs.existsSync(chromeUseDataBaseDir)) return;
const profilePaths: string[] = [];
fs.readdirSync(chromeUseDataBaseDir).forEach((it: string) => {
if (!profileDirRegex.test(it)) return;
const path = join(chromeUseDataBaseDir, it);
const dir = fs.statSync(path);
if (dir.isDirectory()) profilePaths.push(path);
});
const vueDevToolPath = profilePaths
.map((it) => {
const path = join(it, "Extensions", vueExtensionId);
if (!fs.existsSync(path)) return false;
return fs
.readdirSync(path)
.map((it: any) => {
const sp = join(path, it);
const dir = fs.statSync(path);
if (dir.isDirectory() && fs.existsSync(join(sp, "manifest.json")))
return sp;
return;
})
.filter(Boolean)[0];
})
.filter(Boolean)[0];
if (vueDevToolPath) {
console.log(`Vue DevTools Path:>> `, vueDevToolPath);
session.defaultSession.loadExtension(vueDevToolPath);
}
}
/**
* 打开 Devtools
*/
export function openDevTools($win: BrowserWindow) {
if (!$win) return;
if (app.isPackaged) return;
$win.webContents.openDevTools({ mode: "detach" });
}
/**
* 关闭 Devtools
*/
export function closeDevTools($win: BrowserWindow) {
if (!$win) return;
if (app.isPackaged) return;
$win.webContents.closeDevTools();
}
窗口创建完后开始加载 vueDevtool,窗口显示的时候自动打开 Devtool,vue DevTool 加载成功
生成日志 electron-log
electron 应用打包后我们要知道当前安装的应用的运行情况,生成本地日志是非常有必要的,这里采用开源工具包 electron-log 实现 它不仅可以用于 Electron 应用中也可以用在任何 node.js 的应用中。
yarn add electron-log -D
在 utils 文件夹中新建 log.ts
import { app } from "electron";
import log from "electron-log";
/**
* 支持下列日志等级
* error,
* warn,
* info,
* verbose,
* debug,
* silly
*
* 日志文件位置
* on Linux: ~/.config/{app name}/logs/{process type}.log
* on macOS: ~/Library/Logs/{app name}/{process type}.log
* on Windows: %USERPROFILE%\AppData\Roaming\{app name}\logs\{process type}.log
*/
let date = new Date();
const dateStr =
date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate();
// 修改日志文件名
log.transports.file.fileName = dateStr + ".log";
// 修改日志格式
log.transports.file.format =
"[{m}-{d} {h}:{i}:{s}.{ms}] [{level}]{scope} {text}";
// 设置日志文件大小上限, 达到上限后备份文件并重命名未**.old.log,有且仅有一个备份文件
log.transports.file.maxSize = 3 * 1024 * 1024;
// 打包后禁用console输出
if (app?.isPackaged) {
log.transports.console.level = false;
}
export default log;
查询本地日志文件可看到,日志记录成功
[2022-07-23 17:50:05.923] [error] error
[2022-07-23 17:50:05.929] [warn] warn
[2022-07-23 17:50:05.929] [info] info
[2022-07-23 17:50:05.930] [verbose] verbose
[2022-07-23 17:50:05.930] [debug] debug
[2022-07-23 17:50:05.931] [silly] silly
对于 electron-log 日志在 windows 系统控制台中输出中文乱码,可通过添加 chcp 65001 解决
// package.json
{
"scripts": {
"dev": "chcp 65001 && vite"
}
}
在渲染进程中如果想使用 log 输出运行日志,可以将 log API 通过 preload 暴露出来供渲染进程使用
// preload/index.ts
import log from "../main/utils/log";
// ...
contextBridge.exposeInMainWorld("log", log);
electron+vue 项目多窗口配置
在 electron 项目开发中我们的需求一般不会只有一个窗口,那么如何创建多个 electron 窗口呢?我们知道BrowserWindowAPI 可以创建并控制浏览器窗口,在本项目 main/index.ts 中就创建了一个窗口
const win = new BrowserWindow({
width: 800,
height: 600,
icon: getIcon(),
webPreferences: {
preload: path.join(__dirname, "../preload/index.js"),
},
});
if (app.isPackaged) {
win.loadFile(join(__dirname, "../../index.html"));
} else {
const url = `http://${process.env["VITE_DEV_SERVER_HOST"]}:${process.env["VITE_DEV_SERVER_PORT"]}`;
win.loadURL(url);
win.on("show", () => openDevTools(win));
}
这里窗口内容用的是本地 index.html 文件渲染结果,如果要新开一个窗口,可以新建一个 index.html 用同样的方式去渲染。但是我们项目中完全可以以访问其他路由的形式去新开窗口
const createUpdateWin = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
icon: getIcon(),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
},
});
let windowUrl;
if (app.isPackaged) {
windowUrl = "file://" + join(__dirname, "../../index.html");
} else {
windowUrl = `http://${process.env["VITE_DEV_SERVER_HOST"]}:${process.env["VITE_DEV_SERVER_PORT"]}`;
}
win.loadURL(windowUrl + "#/update"); // 以hash路由形式访问
win.on("show", () => openDevTools(win));
win.on("hide", () => closeDevTools(win));
return win;
};
这样就可以新开启一个 update 窗口,访问的是/update 路由
electron 进程间通信
为了确保应用安全,自 Electron 12 以来,默认情况下已启用上下文隔离,并且它是所有应用程序推荐的安全设置。 启用上下文隔离之后,Electron 提供一种专门的模块来无阻地帮助您完成这项工作。 contextBridge 模块可以用来安全地从独立运行、上下文隔离的预加载脚本中暴露 API 给正在运行的渲染进程。 为了在渲染进程中使用 ipcRenderer 需要在 preload 脚本中将 ipcRenderer 暴露出来 编写 preload/index.ts
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("ipcRenderer", ipcRenderer);
渲染进程发送消息给主进程
ipcRenderer.send("login", "loginSuccess");
主进程接收消息
ipcMain.on("login", (event, arg) => {
console.log(arg); // loginSuccess
});
主进程发消息给渲染进程
win?.webContents.send("showNormalIcon");
渲染进程接收消息
ipcRenderer.on("showNormalIcon", () => {
showNormalIcon.value = true;
});
具体还有其他 ipcRenderer 的 api 可以参考官网ipcRenderer
窗口最大化与最小化
根据上面进程间通信方法我们来实现窗口的最大化与最小化
渲染进程中
const minimizeWin = () => {
ipcRendererSend("minimize-win");
};
const showNormalIcon = ref(false);
const maximizeWin = () => {
ipcRendererInvoke("maximize-win").then((res) => {
if (res === "showNormalIcon") {
showNormalIcon.value = true;
} else if (res === "showMaximizeIcon") {
showNormalIcon.value = false;
}
});
};
const closeWin = () => {
ipcRendererSend("close-win");
};
主进程中
/**
* 窗口最小化
*/
ipcMainOn("minimize-win", (event) => {
const win = BrowserWindow.fromId(event.sender.id);
win?.minimize();
});
/**
* 窗口最大化与正常化
*/
ipcMainHandle("maximize-win", (event) => {
const win = BrowserWindow.fromId(event.sender.id);
if (win?.isMaximized()) {
win.unmaximize();
return "showMaximizeIcon";
} else {
win?.maximize();
return "showNormalIcon";
}
});
/**
* 关闭窗口
*/
ipcMainOn("close-win", () => {
electronApp.quit();
});
实现托盘及托盘菜单
桌面端应用一般都会有托盘菜单,这里根据 electron 提供的 Menu 和 Tray 来实现
let $tray: Tray | null = null;
const setMenu = (electronApp: IElectronApp) => {
const menu = [
{
label: "打开ElectronApp",
click: () => electronApp.showMainWin(),
},
{
icon: getLogout(),
label: "退出",
click: () => electronApp.quit(),
},
];
if ($tray) {
// 绑定菜单
$tray.setContextMenu(Menu.buildFromTemplate(menu));
}
};
const createTray = (electronApp: IElectronApp) => {
// 生成托盘图标及其菜单项实例
$tray = new Tray(
path.join(__dirname, "../../../resources/icons/png/16x16.png")
);
// 设置鼠标悬浮时的标题
$tray.setToolTip("ElectronApp");
// 设置菜单
setMenu(electronApp);
};
可以看到托盘菜单已生成
全量更新与增量更新
全量更新
全量更新是利用官方提供的 autoUpdater 来实现的,即将打包后的新版本包下载下来并重新安装 安装 electron-updater
yarn add electron-updater@5.0.1 -D
新建 electron/main/modules/fullUpdate.ts
import { BrowserWindow } from "electron";
import { autoUpdater, UpdateInfo } from "electron-updater";
const fullUpdate = (window: BrowserWindow): void => {
// 检查到新版本
autoUpdater.once("update-available", (info: UpdateInfo) => {
window.webContents.send("update-available", {
message: `版本更新中...`,
});
});
// 已经是新版本
autoUpdater.on("update-not-available", (info: UpdateInfo) => {
window.webContents.send("update-not-available", {
message: `当前版本已经是最新 v ${info.version}`,
});
});
// 更新下载中
autoUpdater.on("download-progress", ({ percent }: { percent: number }) => {
window.setProgressBar(percent / 100);
window.webContents.send("download-progress", {
percent: percent.toFixed(0),
});
});
// 更新下载完毕
autoUpdater.once("update-downloaded", () => {
window.webContents.send("update-downloaded", {
message: "更新完成",
});
autoUpdater.quitAndInstall();
});
// 检查更新出错
autoUpdater.on("error", (error) => {
window.webContents.send(
"update-error",
{
message: "检查更新出错",
},
error
);
});
};
export default fullUpdate;
在 electron-builder.json 中
"publish": [
{
"provider": "generic",
"url": "http://127.0.0.1:3000/" // 把生成的exe文件和latest.yml文件放到这个静态服务器下 方便下载更新包
}
]
// mainWin.ts
// 在主进程中处理检查更新,根据版本号升级判断是否全量更新还是增量更新
ipcMainHandle("checkUpdate", async () => {
if (!app.isPackaged) return;
if (process.platform !== "win32") return;
const [err, res] = await sync(getAppVersion());
if (err) {
console.error(err);
log.error(err);
return;
}
log.info("remoteVersion:", res);
log.info("currentVersion", pkg.version);
const remoteVersionArr = res.split(".");
const currentVersionArr = pkg.version.split(".");
if (Number(remoteVersionArr[0]) > Number(currentVersionArr[0])) {
// 开启全量更新
const [err, res] = await sync(autoUpdater.checkForUpdates());
if (err) {
console.error(err);
log.error(err);
}
if (res) {
win.setMinimumSize(420, 170);
win.setSize(420, 170, false);
win.center();
}
return true;
} else if (
Number(remoteVersionArr[1]) > Number(currentVersionArr[1]) ||
Number(remoteVersionArr[2]) > Number(currentVersionArr[2])
) {
// 开启增量更新
win.setMinimumSize(420, 170);
win.setSize(420, 170, false);
win.center();
const localPath = join(app.getPath("exe"), "../resources/");
log.info("localPath", localPath);
getRemoteZipToLocal(publishUrl + "app.zip", "app.zip", "./", win)
.then((res) => {
if (res) {
const unzip = new AdmZip("app.zip");
win.hide();
unzip.extractAllTo(localPath, true, true);
app.relaunch({ args: process.argv.slice(1).concat(["--relaunch"]) });
electronAppInstance.quit();
}
})
.catch((err) => log.error(err));
return true;
}
return false;
});
根据 autoUpdater 提供的监听将更新状态发送到渲染进程中显示出来
增量更新
有时候我们改动比较小不想全部下载下来重新安装,只想替换修改的部分,这时需要用到 electron 增量更新
常用的增量更新有两种方案:
- 设置 asar:false
- app.asar.unpacked + app.asar
因为我们的项目会经常改动主进程代码且主进程文件只能打包到 app.asar,综合考虑采用方案 1 比较合适。
设置 asar:false 之后我们看到 resources 文件夹中 app 是未打包的,我们只需将更新包的 app 文件夹压缩成 app.zip 放到服务器,渲染进程检测增量更新通知主进程,主进程下载 app.zip,解压替换重启就可以。 这里需要在打包时生成 app.zip,electron-builder 提供了打包完成后钩子函数 afterPack,在钩子函数中处理 app 文件夹打包
添加 afterPack 钩子函数
// electron-builder.json
// ...
"afterPack": "./afterPack.ts",
// ...
编辑 afterPack.ts
const path = require("path");
const AdmZip = require("adm-zip");
const afterPack = (context) => {
let targetPath;
if (context.packager.platform.nodeName === "darwin") {
targetPath = path.join(
context.appOutDir,
`${context.packager.appInfo.productName}.app/Contents/Resources`
);
} else {
targetPath = path.join(context.appOutDir, "./resources");
}
const unpackedApp = path.join(targetPath, "./app");
var zip = new AdmZip();
zip.addLocalFolder(unpackedApp);
// 删除不常更改的包,减小更新包zpp.zip大小
zip.deleteFile("node_modules/axios/");
zip.deleteFile("node_modules/adm-zip/");
zip.deleteFile("node_modules/element-plus/");
zip.deleteFile("node_modules/@element-plus/");
zip.deleteFile("node_modules/normalize.css/");
zip.deleteFile("node_modules/pinia/");
zip.deleteFile("node_modules/sqlite3/");
zip.deleteFile("node_modules/sequelize/");
zip.deleteFile("node_modules/vue/");
zip.deleteFile("node_modules/@vue/");
zip.deleteFile("node_modules/vue-router/");
zip.writeZip(path.join(context.outDir, "app.zip"));
};
module.exports = afterPack;
打包后我们可以看到生成了 app.zip
将这三个文件放到静态服务器上供下载更新
安装 sequelize + sqlite3 用作本地存储(可根据项目需要是否安装)
yarn add sqlite3@5.0.2 sequelize@6.18.0
在 electron/main/modules 下新建 database 文件夹 创建 sequelize.ts,引入 sequelize
import { Sequelize } from "sequelize";
import { getUserDataPath, pathJoin } from "../../utils/common";
import log from "../../utils/log";
const databasePath = pathJoin(getUserDataPath(), "./database.sqlite");
const sequelize = new Sequelize({
dialect: "sqlite",
storage: databasePath,
});
export const connectDB = (): Promise<boolean> =>
new Promise((resolve, reject) => {
sequelize
.authenticate()
.then(() => {
log.info("Connection has been established successfully.");
resolve(true);
})
.catch((err: any) => {
log.error("Unable to connect to the database:", err);
reject(false);
});
});
export default sequelize;
开始创建 model 编辑 model/user.ts
import { INTEGER, Model, STRING } from "sequelize";
import sequelize from "../sequelize";
interface IUserModel {
id: string;
name: string;
sex: number;
mobile: number;
}
const userModel = sequelize.define<Model<IUserModel>>("user", {
id: {
primaryKey: true,
type: STRING(100),
},
name: {
type: STRING(50),
},
sex: {
type: INTEGER,
},
mobile: {
type: INTEGER,
},
});
export default userModel;
现在已经可以操作数据库了
import sync from "../../utils/sync";
import userModel from "./model/user";
import sequelize, { connectDB } from "./sequelize";
// 连接数据库
const [err] = await sync(connectDB());
if (err) return;
const [err1, res1] = await sync(sequelize.sync());
if (err1) return;
// 插入数据
const [err2, res2] = await sync(
userModel.create({
name: "jesse",
sex: 1,
id: "13456",
mobile: 15219498643,
})
);
console.log(res2);
结束
从 0 开始搭建一个 electron 应用到此已结束,输出结果,一个基本的 electron 应用模板基本搭建完成,包含基础功能如下:
- 可开启多个窗口
- 可使用 vue devtools 方便调试
- 能生成本地运行日志文件
- 包含托盘和托盘菜单
- 一键打包成 exe
- 支持全量更新和增量更新
- 本地化 sqlite 存储
后面的 electron 项目都可以快速创建拥有以上功能
源码 Github 仓库:electron-vue-vite-template,如果觉得写得不错还希望给个小星星哦
参考文章:
转载自:https://juejin.cn/post/7128000817458020383