二十五.Electron 初体验与进阶

8 阅读7分钟

前言

又是将近快一年没有写作了,主要是回武汉后每天忙到不停,而且涉及到的技能点之前了解比较少,现在终于有时间抽出空来总结下,最近做的比较多的是Electorn桌面端应用,在Electron中,你可以实现更多浏览器网页端无法实现的操作,经过这段时间的学习和使用,也算是对这块有个大概的了解,这里记录下学习使用过程中出现的一些问题和实现思路,希望能对你有帮助。

Electron基础介绍

Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 Chromium 和 Node.js 到 二进制的 Electron 允许您保持一个 JavaScript 代码代码库并创建 在Windows上运行的跨平台应用 macOS和Linux——不需要本地开发 经验。

个人理解Electron就是将node环境以及chromium浏览器打包进应用里面,然后浏览器打开对应页面,然后通过nsis等工具构建对应平台需要的安装包。

优点是一套代码能同时打包MacLinuxWindows,开发成本低,社区轮子多,稍微学习下就能上手

缺点是打包体积大,性能相对没那么好,加载相对比较慢,运行占用内存比较大

从模块上,electorn分为 主进程(main)、渲染进程(renderer)、预加载脚本(preload),其中主进程和渲染进程是独立的,本身做了进程隔离。

  • 主进程: 创建应用程序的主入口,可以做:创建窗口、自动更新升级、系统级调用/获取,node操作等;
  • 渲染进程:也就是我们正常的页面,跟常规项目差不多,正常实现即可;
  • 预加载脚本:由于主进程和渲染进程是隔离开的,为了进程之间通信,所以需要预加载在中间充当桥梁;

所以一个electron项目,基础的项目结构应该是这样的:

├── build                            ---安装进程文件
├── src
│  ├── main                           ---主进程
│  ├── preload                        ---预加载进程
│  ├── render                         ---渲染进程
├──  package.json

推荐使用electron-vite去构建项目,内置基础自动更新和热更新等,开发更加方便。

主进程基础介绍

主进程是整个程序的入口,下面将提供一个基础示例:

具体窗口参数可以看:BrowserWindow | Electron

const { app, BrowserWindow } = require('electron')  
  
const createWindow = () => {  
    const win = new BrowserWindow({  
        width: 800,  
        height: 600  
    })  
  
    win.loadFile('index.html')  //加载渲染进程文件,可选loadFile、loadUrl,等价于在浏览器打开html文件
}  
  
app.whenReady().then(() => {  
    createWindow()  
})

一般主进程内需要实现:

  • 创建窗口
  • 日志记录
  • 托盘
  • 快捷键注册
  • 自动更新升级
  • 和渲染进程通信
  • ...其他node或系统级操作,例如文件保存、读取等

生命周期

electron也有对应的生命周期,常见的如下:

  1. ready: 当electron初始化完成触发,一般在这里开始创建窗口并挂载渲染进程文件,等价于app.whenReady
app.on('ready', ()=>{

})
 // 等价于=
app.whenReady().then(() => {

})
  1. activate: 仅Mac上使用,各种操作都可以触发此事件, 例如首次启动应用程序、尝试在应用程序已运行时或单击应用程序的坞站或任务栏图标时重新激活它,一般在这里判断是否已创建窗口,如果已创建就显示,未创建则创建。
  app.on("activate", function () {
    if (BrowserWindow.getAllWindows().length === 0) {
      windowManager.createWindow();
    } else {
      const mainWindow = windowManager.getMainWindow();
      if (mainWindow) {
        mainWindow.show();
        mainWindow.focus();
      }
    }
  });
});
  1. window-all-closed: 所有窗口都关闭时触发
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit();
  }
});

4.before-quit:应用退出开始关闭窗口之前触发,可通过 event.preventDefault()进行阻止。

app.on("before-quit", (event) => {
    event.preventDefault();
    app.quit();
});

5.will-quit:应用退出关闭窗口后,退出应用之前触发,可通过 event.preventDefault()进行阻止。

app.on("will-quit", (event) => {
    event.preventDefault();
    app.quit();
});

6.quit:应用窗口关闭后,应用退出时触发,不可阻止。

app.on("quit", (event) => {
   console.log("应用已退出")
});

常用方法

electron中有一些常用方法,这里单独说明和记录下:

app级方法:

  1. app.quit():退出应用,会先触发before-quit -> will-quit -> quit
  2. app.exit(): 立即退出应用,不会触发before-quit
  3. app.relaunch(): 重启应用
  4. app.whenReady(): 应用初始化完成,等价于app.on('ready')
  5. app.hide(): 应用隐藏
  6. app.show(): 应用显示
  7. app.getAppPath(): 应用所在目录

窗口级方法:

  1. loadUrl: 加载html url地址
  2. loadFile: 加载html 路径
  3. show/hide:显示隐藏窗口
  4. maximize/minimize/restore: 窗口最大化 / 最小化 / 还原

日志记录

一个完整的项目肯定是需要日志记录,我们可以通过 electron-log 这个插件进行日志记录,这里我封装了一个基础的日志记录工具,可以贴出来给大家参考下,主要是存储在userData用户数据目录,按小时进行轮转。

import * as path from "path";
import { app } from "electron";
import log from "electron-log";
import { ensureDir } from "./util";
import moment from "moment";

class DailyLogger {
  private logDirectory: string;
  private currentHour: string;

  constructor(options: { logDirectory?: string; maxSize?: number } = {}) {
    this.logDirectory = options.logDirectory || path.join(app.getPath("userData"), "logs");
    this.currentHour = this.getFormattedHour();

    // 禁用默认轮转行为
    log.transports.file.maxSize = 0;

    // 初始化日志配置
    this.setupLog();
  }

  // 时间格式改为 YYYYMMDDHH
  private getFormattedHour(): string {
    return moment().format("YYYYMMDDHH");
  }

  // 设置日志文件路径和配置
  private setupLog(): void {
    ensureDir(this.logDirectory);

    // this.checkLogFile();
    log.transports.file.resolvePathFn = () => this.getLogFilePath();
    log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}\n";
  }

  // 获取当前日志文件路径
  private getLogFilePath(): string {
    return path.join(this.logDirectory, `${this.currentHour}.log`);
  }

  // 写入日志
  public info(message: string): void {
    this.ensureCorrectHour();

    log.info(message);
  }

  public warn(message: string): void {
    this.ensureCorrectHour();
    log.warn(message);
  }

  public error(message: string): void {
    this.ensureCorrectHour();
    log.error(message);
  }

  private ensureCorrectHour(): void {
    const nowHour = this.getFormattedHour();
    if (nowHour !== this.currentHour) {
      this.currentHour = nowHour;
      this.setupLog(); // 小时变化时立即创建新文件
    }
  }
}

const logger = new DailyLogger();

export default logger;

托盘

electron项目一般都需要托盘,当应用右上角关闭后,都会最小化到托盘,这里我们可以直接用electron自带的Tray进行托盘构建。

import { BrowserWindow, Tray, Menu, nativeImage } from "electron";

function createTray() {
    const trayIconImage = nativeImage
      .createFromPath(this.iconPath)
      .resize({ width: 16, height: 16 });

    this.tray = new Tray(trayIconImage);

    // 设置托盘提示文字
    this.tray.setToolTip("应用");

    // 创建托盘右键菜单
     const contextMenu = Menu.buildFromTemplate([
      {
        label: "退出应用",
        click: () => this._quitApp(),
      },
    ]);

    this.tray.setContextMenu(contextMenu);

    // 托盘图标单击事件 - 切换窗口显示/隐藏
    this.tray.on("click", () => {
      this._toggleWindow();
    });

    // 托盘图标双击事件 - 总是显示窗口
    this.tray.on("double-click", () => {
      this._showWindow();
    });
  }

快捷键注册

我们在应用中,可能会需要用到一些快捷键,例如ctrl+s报错等等,我们可以用electron提供的globalShortcut进行快捷键注册,但是这个快捷键仅支持单个实例窗口,例如我们打开多开应用的时候,这个方法就不行了。

import { globalShortcut } from "electron";

if (!globalShortcut.isRegistered("alt+d")) {
    globalShortcut.register("alt+d", () => {
      console.log("触发")
    });
}

如果没有多开需要,可以直接使用electron自带的快捷键注册,如果有需要多开,可以使用社区提供的插件electron-localshortcut,使用方法也很简单:

import electronLocalshortcut from "electron-localshortcut";
function registerOpenDevTools(win) {
  electronLocalshortcut.register("Alt+D", () => {
     console.log("触发")
  });
}

自动更新升级

一个正常的应用,肯定是需要进行自动更新升级的,而不是需要用户自己每次去下载最新版,这里我们使用electron-updater进行自动更新升级,自动更新分为全量更新增量更新两种方式,我们先用全量更新进行实现,如果有需要增量更新,后续可以单独写一篇来写这个,这里就不过多赘述了。

更新包准备: 我们先要提前准备好增量更新包,以window为例,将我们打开好的exe文件和latest.yml文件放在同一文件夹内放置在更新服务器上。

electron-updater基础介绍:我们主要使用 electron-updater里面的autoUpdater模块,它包含如下方法:

  • update-available: 监听有可用的更新
  • update-not-available: 监听没有可用的更新
  • download-progress: 监听下载进度
  • update-downloaded: 监听下完完成
  • error: 监听失败

所以我们一个基础的代码如下:

import { autoUpdater } from "electron-updater";

//设置请求头
autoUpdater.requestHeaders = {
    Accept: "application/octet-stream",
    "X-Custom-Header": "Custom-Value",
};
//自动开始下载
autoUpdater.autoDownload = true;

autoUpdater.setFeedURL({
  provider: "generic",  //自定义服务器
  url: updateUrl,  //更新包latest.yml地址,通过yml去找更新包地址
});

// 开始检测,他会自动比对yml的版本,如果高度当前版本,就会触发update-available
autoUpdater.checkForUpdates();

autoUpdater.on("error", function (_: any, err: any) {
    console.error(`更新失败:${JSON.stringify(err)}`);
});

//有可用的更新
autoUpdater.on("update-available", function (message: any) {
    console.log("update-available", message);
});

//没有可用的更新
autoUpdater.on("update-not-available", function (message: any) {
    console.log(`【没有可用的更新】${JSON.stringify(message)}`);
});

// 更新下载进度事件
autoUpdater.on("download-progress", function (progressObj: any) {
    console.log(`【更新包下载进度】${JSON.stringify(progressObj)}`);
});

//更新包下载完成
autoUpdater.on("update-downloaded", function (res) {
    //退出并安装
    autoUpdater.quitAndInstall();
});

文件本地化存储

electron应用和浏览器应用的区别是,我们可以充分利用本地存储的优势,将一些变化不是特别大的数据,存储在本地,减少服务器调用,这里我们使用:node-localstorage进行数据本地化存储,使用方法和浏览器的localStorage类似,这里就直接贴示例了。

import { LocalStorage } from "node-localstorage";

const storage = new LocalStorage(storagePath);  //文件本地存储位置,一般放userData

// 设置值
function setItem(key: string, value: any) {
  storage.setItem(key, JSON.stringify(value));
}

// 获取值
function getItem(key: string): T | null {
    const val = storage.getItem(key);
    return val ? JSON.parse(val) : null;
}

//删除单个值
removeItem(key: string): void {
    storage.removeItem(key);
}

//清除所有值
clear(): void {
    storage.clear();
}

当然这里只是一些简单数据存储,例如key等不是很长,且数据不怎么会变化的值,如果需要大量数据存储,比如聊天记录等,可以使用IndexexDb或真正的数据库sqlite,推荐使用nedbbetter-sqlite3,前者是一个Nosql嵌入型数据库,在渲染进程可以直接使用,但是文件最大大小不超过512Mb,后者是sql数据库,必须要在主进程使用,需要建库建表设置字段类型,用法和真正写sql差不多,而且还提供了事务等操作。

进程通信

之前有提到过,主进程和渲染进程之间进程通信,需要通过preload预加载作为桥梁,这里提供下示例。

主进程

import {ipcMain,app} from "electron";

// 监听窗口最小化
ipcMain.on("window-min", function () {
    win && win.minimize();
});

ipcMain.handle("get-version", () => {
  return app.getVersion();
});

preload预加载

import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";

// 用于渲染进程的自定义 API
const api = {
   setWindowMin(){
     ipcRenderer.send("window-min");
   },
   getVersion(){
     reutrn ipcRenderer.invoke("get-version")
   }
};

if (process.contextIsolated) {
  try {
    contextBridge.exposeInMainWorld("electron", electronAPI);
    contextBridge.exposeInMainWorld("api", api);
  } catch (error) {
    console.error(error);
  }
} else {
  window.electron = electronAPI;
  window.api = api;
}

渲染进程

// 通过window.api获取
function getVersion(){
   const version=window.api.getVersion();
   console.log(version)
}

function setWindowMin(){
   window.api.setWindowMin();
}

最后

到这里electron初体验已经完成了,其实和常规的项目区别没太大,可能就需要一部分系统的配置,其实还是挺有意思的,如果这篇文章对你有所帮助,可以点个赞和评论区沟通交流~

其他文章