如何用Electron+vue+vite构建桌面端应用(三)

一期我们介绍了完整的 Vue3 项目的搭建及配套工具的使用如何用 Electron+vue+vite 构建桌面端应用(二)。今天主要介绍我们的重点,electron 实战开发中常用到的工具及常用功能介绍

打包工具 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 增量更新

常用的增量更新有两种方案:

  1. 设置 asar:false
  2. 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 应用模板基本搭建完成,包含基础功能如下:

  1. 可开启多个窗口
  2. 可使用 vue devtools 方便调试
  3. 能生成本地运行日志文件
  4. 包含托盘和托盘菜单
  5. 一键打包成 exe
  6. 支持全量更新和增量更新
  7. 本地化 sqlite 存储

后面的 electron 项目都可以快速创建拥有以上功能

源码 Github 仓库:electron-vue-vite-template,如果觉得写得不错还希望给个小星星哦。

参考文章:

  1. Electron~增量更新和全量更新