什么是Electron

Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 ChromiumNode.js 到应用中,最终构建出在Windows、macOS和Linux上运行的跨平台应用——不需要native开发经验。

Electron = Chromium + Node.js + Native APIs

image-20230425220411938

本质就是将Web应用连同运行环境和NodeJS打包到了一起,Chromium负责渲染页面,NodeJS 负责控制程序运行。

我们平时开发使用的VSCode就是Electron制作的,当然它内部做了很多的优化来平衡Electron在性能上与原生应用的差距。

Electron的优点显而易见,它非常的容易上手,基本上有过前端开发经验的开发者都能很快上手,因为不论是他的主进程还是渲染进程,都是基于JavaScript的;此外使用Electron进行开发不需要考虑平台的兼容性(因为Chromium和NodeJS都是跨平台的),还能够享受JavaScript丰富的生态。

也正是因为它的上手难度低,导致了它在性能上的短板,基本上所有性能较好的开发方式,上手难度都不会太低;因为要将Chromium和NodeJS打包进应用,所以他的应用体积会非常大(当然现在存储的成本并不很高)。

初始化一个Electron项目

开换环境配置部分参考资料——掘金小册《Electron + Vue3 桌面应用开发》刘晓伦

我们给予Vite来进行开发(因为它很快),所以我们需要先初始化一个Vite工程

$ pnpm create vite@latest

注意:Vite默认的package.json文件中可能会标识"type": "module",因为Electron内部是基于CommonJS来实现的。

然后安装Electron的依赖

$ pnpm install Electron -D

我们使用Vite插件的形式来运行Electron,最原始的开发方式是启动两个进程,一个进程负责跑devServer来提供静态资源,也就是我们普通的前端开发服务器,另一个进程用于启动窗口实例,然后通过URL来访问我们的devServer内容,进行渲染。

现在我们可以使用一种更为高级的方式来开发,我们只需要启动一个进程,就可以同时启动devSever和Electron主进程,原理是,利用Vite的插件机制,在启动devServer的时候,同时编译Electron的主进程代码。

插件代码实例如下

import { ViteDevServer } from 'vite';

export let devPlugin = () => {
  return {
    name: 'dev-plugin',
    configureServer(server: ViteDevServer) {
      require('esbuild').buildSync({
        entryPoints: ['src/main/mainEntry.ts'],
        bundle: true,
        platform: 'node',
        outfile: 'dist/mainEntry.ts',
        external: ['electron'],
      })
      server.httpServer?.once('listening', () => {
        let { spawn } = require('child_process');
        let addressInfo = server.httpServer?.address() as any;
        let httpAddress = `http://${addressInfo?.address}:${addressInfo?.port}`;
        let electronProcess = spawn(
          require('electron').toString(),
          ['dist/mainEntry.ts', httpAddress],
          {
            cwd: process.cwd(),
            stdio: 'inherit',
          }
        )
        electronProcess.on('close', () => {
          server.close();
          process.exit();
        })
        return server;
      })
    }
  };
}

这段代码的作用是一个Vite的插件,首先使用esbuild将Electron的CommonJS代码转为ESM,然后来监听vite的启动,当触发vite启动钩子的时候同时启动electron,并传递两个参数一个是编译后的主进程路径,另一个是Vite启动的http地址,最后当electron窗口关闭的时候关闭vite的进程。

这里启动Electron的方式是通过加载electron模块来实现,这个在不同平台是有差异的,比如我是Mac OS环境,我运行脚本的时候会从node_modules/electron/dist目录下加载Electron.app,如果是Windows平台可能是node_modules/electron/dist/electron.exe。

image-20230426221900764在vite.config.ts中引入插件

import { devPlugin } from './src/plugins/devPlugin';
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [devPlugin(), vue()],
})

最后编写主进程的代码

// src/main/ainEntry.ts
import { app, BrowserWindow } from 'electron';

let mainWindow: BrowserWindow | null;

app.whenReady().then(() => {
  mainWindow = new BrowserWindow({});
  mainWindow.loadURL(process.argv[2]);
})

可以看到这里使用了process.argv[2]来加载内容,这个参数就是我们在插件中传递的vite启动的http地址。

然后运行pnpm run dev看一下效果

image-20230426213349575

但是到这里还没结束,Vite默认屏蔽了NodeJS和Electron的内置模块,我们需要让Vite去加载这些被忽略的模块,安装插件vite-plugin-optimizer,安装完成之后再我们的插件文件中添加以下内容

export let getReplacer = () => {
  let externalModels = ["os", "fs", "path", "events", "child_process", "crypto", "http", "buffer", "url", "better-sqlite3", "knex"];
  let result = {};
  for (let item of externalModels) {
    result[item] = () => ({
      find: new RegExp(`^${item}$`),
      code: `const ${item} = require('${item}');export { ${item} as default }`,
    });
  }
  result["electron"] = () => {
    let electronModules = ["clipboard", "ipcRenderer", "nativeImage", "shell", "webFrame"].join(",");
    return {
      find: new RegExp(`^electron$`),
      code: `const {${electronModules}} = require('electron');export {${electronModules}}`,
    };
  };
  return result;
};

但是这里官方并不推荐将整个ipcRenderer暴露出去,因为会增加被攻击的几率。

在vite.config.ts添加配置

export default defineConfig({
  plugins: [optimizer(getReplacer()), devPlugin(), vue()],
})

现在我们已经出初始化了一个Electron的项目,下面我们就可以基于这个环境进行开发了。

打包应用

单是在开发环境成功运行还是不够,不能看到打包的效果还是不放心,万一写了半天最后不能打包那就尴尬了,所以紧接着我们就来看下如何配置对应的打包。

这里的打包不仅包含了前端代码从框架到【三驾马车】的编译,还要将最终的产物(包括页面代码和主进程代码)打包进app内。

渲染层面的代码打包不需要我们操心,Vite会帮我们处理,我们只需要将Electron相关的代码进行处理即可。这里我们还是使用一个自定义的插件来完成。

import path from "path";
import fs from "fs";

class BuildObj {
  //编译主进程代码
  buildMain() {
    require("esbuild").buildSync({
      entryPoints: ["./src/main/mainEntry.ts"],
      bundle: true,
      platform: "node",
      minify: true,
      outfile: "./dist/mainEntry.js",
      external: ["electron"],
    });
  }
  //为生产环境准备package.json
  preparePackageJson() {
    let pkgJsonPath = path.join(process.cwd(), "package.json");
    let localPkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
    let electronConfig = localPkgJson.devDependencies.electron.replace("^", "");
    localPkgJson.main = "mainEntry.js";
    delete localPkgJson.scripts;
    delete localPkgJson.devDependencies;
    localPkgJson.devDependencies = { electron: electronConfig };
    let tarJsonPath = path.join(process.cwd(), "dist", "package.json");
    fs.writeFileSync(tarJsonPath, JSON.stringify(localPkgJson));
    fs.mkdirSync(path.join(process.cwd(), "dist/node_modules"));
  }
  //使用electron-builder制成安装包
  buildInstaller() {
    let options = {
      config: {
        directories: {
          output: path.join(process.cwd(), "release"),
          app: path.join(process.cwd(), "dist"),
        },
        files: ["**"],
        extraResources: ['public'],
        extends: null,
        productName: "feng",
        appId: "com.feng.desktop",
        asar: true,
        nsis: {
          oneClick: true,
          perMachine: true,
          allowToChangeInstallationDirectory: false,
          createDesktopShortcut: true,
          createStartMenuShortcut: true,
          shortcutName: "fengDesktop",
        },
        publish: [{ provider: "generic", url: "http://localhost:5500/" }],
      },
      project: process.cwd(),
    };
    return require("electron-builder").build(options);
  }
}

export let buildPlugin = () => {
  return {
    name: "build-plugin",
    closeBundle: () => {
      let buildObj = new BuildObj();
      buildObj.buildMain();
      buildObj.preparePackageJson();
      buildObj.buildInstaller();
    },
  };
};

此外,还需要为应用注册一个scheme,通过以下方式来注册

import { protocol } from "electron";
import fs from "fs";
import path from "path";

//为自定义的app协议提供特权
let schemeConfig = { standard: true, supportFetchAPI: true, bypassCSP: true, corsEnabled: true, stream: true };
protocol.registerSchemesAsPrivileged([{ scheme: "feng", privileges: schemeConfig }]);

export class CustomScheme {
  //根据文件扩展名获取mime-type
  private static getMimeType(extension: string) {
    let mimeType = "";
    if (extension === ".js") {
      mimeType = "text/javascript";
    } else if (extension === ".html") {
      mimeType = "text/html";
    } else if (extension === ".css") {
      mimeType = "text/css";
    } else if (extension === ".svg") {
      mimeType = "image/svg+xml";
    } else if (extension === ".json") {
      mimeType = "application/json";
    }
    return mimeType;
  }
  //注册自定义app协议
  static registerScheme() {
    protocol.registerStreamProtocol("feng", (request, callback) => {
      let pathName = new URL(request.url).pathname;
      let extension = path.extname(pathName).toLowerCase();
      if (extension == "") {
        pathName = "index.html";
        extension = ".html";
      }
      let tarFile = path.join(__dirname, pathName);
      callback({
        statusCode: 200,
        headers: { "content-type": this.getMimeType(extension) },
        data: fs.createReadStream(tarFile),
      });
    });
  }
}

开发环境我们使用参数的形式来加载页面,打包之后我们需要用scheme来加载,所以需要对mainEntry做出改造

import { app, BrowserWindow, ipcMain } from 'electron';
import { openServer } from './httpServer';
import { CustomScheme } from './customScheme';
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';

let mainWindow: BrowserWindow | null;

app.whenReady().then(() => {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true,
      webSecurity: false,
      allowRunningInsecureContent: true,
      contextIsolation: false,
      webviewTag: true,
      spellcheck: false,
      disableHtmlFullscreenWindowResize: true,
    },
  });
  ipcMain.handle('ping', () => 'pong')

  if (process.argv[2]) {
    mainWindow.loadURL(process.argv[2]);
  } else {
    CustomScheme.registerScheme();
    mainWindow.loadURL(`feng://index.html`);
  }
  
  mainWindow.webContents.openDevTools({mode: 'undocked'});
  openServer()
})

最后将我们的构建插件放到vite配置文件中即可

export default defineConfig({
  plugins: [optimizer(getReplacer()), devPlugin(), vue()],
  build: {
    rollupOptions: {
      plugins: [buildPlugin()],
    },
  },
})

因为vite构建时底层依赖是rollup,所以可以用rollup插件的形式来编写。

这时候运行一下打包命令,看一下效果

image-20230429174304132

和开发环境是一样的效果。

进程间通讯

之前我们已经说过了,Electron是使用Chromium作为渲染进程,NodeJS作为主进程,他们之间如何进行通信呢——进程间IPC。

可以使用 Electron 的 ipcMain 模块和 ipcRenderer 模块来进行进程间通信。

进程间的通讯大概有三种模式:渲染进程到主进程(单向)、渲染进程到主进程(双向)和主进程到渲染进程。

将单向 IPC 消息从渲染器进程发送到主进程,可以使用 ipcRenderer.send API 发送消息,然后使用 ipcMain.on API 接收。

双向 IPC 的一个常见应用是从渲染器进程代码调用主进程模块并等待结果。 这可以通过将 ipcRenderer.invokeipcMain.handle 搭配使用来完成。

关于ipcRenderer.sendipcRenderer.invoke 的区别:

ipcRenderer.sendipcRenderer.invoke 都是在Electron中用于进程间通信的API,但是它们的作用不同。

ipcRenderer.send(channel, ...args)方法是用于向主进程发送异步消息的。它接收两个参数,第一个参数是消息的通道(channel),第二个参数是消息的数据。当发送消息时,可以通过设置 ipcMain.on(channel, (event, ...args) => { ... }) 方法来监听主进程接收到的消息,从而进行相应的处理。使用 ipcRenderer.send 可以在主进程和渲染进程之间进行简单的通信。

ipcRenderer.invoke(channel, ...args) 方法则是用于向主进程发送同步消息的。与 ipcRenderer.send 不同的是,它会等待主进程返回一个 Promise 对象,并且这个 Promise 对象的解决值就是主进程处理后的结果。当发送消息时,可以通过设置 ipcMain.handle(channel, async (event, ...args) => { ... }) 方法来监听主进程接收到的消息并返回处理结果。使用 ipcRenderer.invoke 可以在主进程和渲染进程之间进行同步通信。

ipcRenderer.send 用于异步通信,不会等待主进程的处理结果;ipcRenderer.invoke 用于同步通信,会等待主进程的处理结果并返回 Promise 对象。

将消息从主进程发送到渲染器进程时,需要指定是哪一个渲染器接收消息。 消息需要通过其 WebContents 实例发送到渲染器进程。 此 WebContents 实例包含一个 send 方法,其使用方式与 ipcRenderer.send 相同。

此外还有渲染进程之间的通讯,有两种可用的方案:

  1. 通过主进程作为中转,实现渲染进程之间的通信;
  2. 事用MessagePort直接进行渲染进程的通信。

生命周期

学习任何的框架,生命周期都是很重要的知识,Electron也不例外,理解了生命周期可以让开发者更清楚应用的运行过程。

img

(图片来源:CSDN

这张图我感觉已经总结得非常到位了,Electron的生命周期重要时靠app这个模块来控制,常用的app事件如下:

  • window-finish-lanuching:应用程序完成基础的启动的时候被触发(在 Windows 和 Linux 中, will-finish-launching 事件与 ready 事件是相同的);

  • ready:完成初始化时触发,不过最常用的方法是使用app.whenReady().then()在完成初始化之后进行一些处理;

  • window-all-closed:所有的窗口都被关闭时触发,如果没有监听这个事件,当所有的窗口关闭时会默认关闭应用,如果用户按下了 Cmd + Q,或者开发者调用了 app.quit(),Electron 会首先关闭所有的窗口然后触发 will-quit 事件;

  • will-quit:所有窗口被关闭后触发,同时应用程序将退出;

  • before-quit:在程序关闭窗口前发信号。 调用 event.preventDefault() 将阻止终止应用程序的默认行为;

  • quit:应用程序退出时触发;

  • activate:当应用被激活时触发(仅支持MacOS平台)

  • browser-window-focus/blur:窗口聚焦/失去焦点时触发。

更多生命周期相关可以参考Electron文档

实用技巧

更换应用图标

这应该是一个最基本的需求了,我们不可能所有的应用都使用Electron的默认图标,那样太low了。

如果是在Mac平台,需要对应的ICNS文件格式,要求必须为1024*1024分辨率,否则无法使用,可以使用这个在线网站(cloudconvert)将制作好的PNG图标转为ICNS。

再我们的build插件中添加图标相关的配置

buildInstaller() {
    let options = {
      config: {
        directories: {
          output: path.join(process.cwd(), "release"),
          app: path.join(process.cwd(), "dist"),
        },
        files: ["**"],
        extends: null,
        productName: "feng",
        appId: "com.feng.desktop",
+       mac: {
+         icon: path.join(process.cwd(), "public/icon"),
+       },
        asar: true,
        nsis: {
          oneClick: true,
          perMachine: true,
          allowToChangeInstallationDirectory: false,
          createDesktopShortcut: true,
          createStartMenuShortcut: true,
          shortcutName: "fengDesktop",
        },
        publish: [{ provider: "generic", url: "http://localhost:5500/" }],
      },
      project: process.cwd(),
    };
    return require("electron-builder").build(options);
  }

这里不需要加图片后缀,再打包时会自动添加后缀

最终打包后的效果如下

image-20230501114822110

此外,还可以在Dock栏图标上显示徽标,常用来显示未读消息数量,这个API在Electron中也有提供。

ipcMain.handle('ping', () => {
  const budge = app.dock.getBadge()
  app.dock.setBadge((Number(budge) + 1).toString())
})

通过getBudge来获取当前心显示的徽标,用setBudge来设置新的徽标,效果如下(我在渲染进程中通过IPC通信触发了主进程中定义的事件)。

image-20230501191852398

应用不退出不关闭应用

我们常见的很多Mac OS上的应用,在关闭窗口的时候并不会退出应用,在Dock栏中看到应用还是启动状态,只有右键点击选择关闭或者使用快捷键cmd+q来强制退出时才会真正的关闭应用。

这点我们在Electron上也可以实现,我们需要监听window-all-closed事件,当检测到平台时Mac OS是不退出应用即可。

(最好先理解一下Electron的生命周期,否则可能有的地方会转不过弯)

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    // 不是苹果就退出, 是苹果就保留
    app.quit()
  }
})

app.on('activate', () => {
  if (mainWindow === null) {
    createWindow(); // 实例化 BrowserWindow 的函数提取
  }
});

还有一种方式,关闭时仅隐藏窗口,在下一次打开应用时显示窗口,这样的话可以保留应用的状态,而不会重置。

let quitApp = false;

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (mainWindow === null) {
    createWindow();
  } else {
    // 显示窗口
    mainWindow.show();
  }
});

app.on('before-quit', () => {
  quitApp = true;
})

function createWindow() {
  // ……
  mainWindow.on('close', event => {
    if (quitApp) {
      mainWindow = null;
    } else {
      // 阻止默认的窗口关闭操作
      event.preventDefault();
      // 隐藏窗口
      mainWindow.hide();
    }
  });
}

这种方案下,我们需要有一个变量来区分窗口关闭的行为是单纯的关闭窗口还是退出应用,当是退出应用操作时,真正的关闭窗口,如果只是关闭了窗口则执行隐藏窗口的动作。

任务栏图标

最常见的任务栏图标如QQ、微信等,这些应用在后台运行时可以显示新消息的数量用来达到消息提示的效果,除了消息提示,将图标放在任务栏可以方便用户快捷地触发某个功能。

在Electron中,任务栏需要Tray和Menu来搭配工作,Tray提供一个任务栏图标,Menu提供图标的快捷交互。

在win、linux和macos下,托盘图标的设置是不一样的,Mac下需要文件名以Template(16*16)、Template@2x(32*32)等结尾(不包含拓展名);文件格式必须为JPG或PNG,推荐PNG,因为PNG支持透明。

必须满足上述所有条件,否则无法设置生效。

以下是一个代码示例

function createTray() {
  const image = nativeImage.createFromPath(path.resolve(app.getAppPath(), '../public/iconTemplate.png'));
  image.setTemplateImage(true);
  tray = new Tray(image);
  setMenu()
}

function setMenu() {
  if (!tray) return;

  const contextMenu = Menu.buildFromTemplate([
    { label: '菜单项 1', type: 'normal', click: () => { console.log('菜单项 1 被点击'); } },
    { label: '菜单项 2', type: 'normal', click: () => { console.log('菜单项 2 被点击'); } },
    { type: 'separator' },
    { label: '退出', type: 'normal', click: () => { app.quit(); } }
  ]);

  tray.setContextMenu(contextMenu);
}

此外,需要注意图片不能被宝贝工具改名或者Hash,所以我选择放在public目录下。

image-20230502114501701

当我点击任务栏的菜单项时能够看到事件被正确触发了。

image-20230502114744629

更多托盘相关的功能可以看一下Electron官网文档

应用热更新

Electron应用的更新方式有两种全量更新和增量更新。全量更新需要检测到更新之后下载更新文件之后退出重新安装,热更新即增量更新,不需要重新安装应用只需要下载最新资源即可完成版本更新。

全量更新需要借助electron-updater库来完成,Electron本身内置了autoUpdater,但是我们使用的是electron-builder来打包,还是electron-updater更契合一点。

首先需要安装依赖

$ pnpm install electron-updater -D

其次需要确保我们build插件中publish的地址填写正确

{
  // ... 
  publish: [{ provider: "generic", url: "http://localhost:5500/" }]
}

完成开发工作之后,把 release 目录下的[project_name]-[project_version]-mac.zip、[project_name]-[project_version].dmg 和 latest-mac.yml 三个文件上传到指定的服务器地址下

效果如下

image-20230502201555893

增量更新至需要更新 asar 文件即可,因为我们的逻辑都会放在这个文件中

具体的实现方式还在研究,比较复杂,貌似还没有一种比较成熟的方案。如果你知道的话,麻烦教我一下😂。


前端小白