likes
comments
collection
share

Electron + Vue 3 桌面应用开发

作者站长头像
站长
· 阅读数 70

Electron + Vue 3 桌面应用开发

Vite插件实现启动vite时启动electron

export const devPlugin = () => {
  /**
   * 该vite插件是为了监听vite启动/插件配置更新时候去触发electron启动mainEntry
   * 并且传入mainEntry.ts中监听的从vite传入的启动地址
   */
  const mainEntryOutputPath = path.resolve(__dirname, "../dist/mainEntry.js");
  const mainEntryInputPath = path.resolve(
    __dirname,
    "../src/main/mainEntry.ts"
  );
  return {
    name: "dev-plugin",
    configureServer(server: ViteDevServer) {
      /**
       * electron的内置模块都是通过CJS Module的形式导出的,这里之所以可以用ES Module
       * 完全是因为使用esbuild进行了转换
       */
      esbuild.buildSync({
        entryPoints: [mainEntryInputPath], //转换文件
        bundle: true,
        platform: "node", //平台
        outfile: mainEntryOutputPath, //转换后输出文件
        external: ["electron"], //排除electron依赖,原样输出
      });
      /**
       * 开始监听vite的启动,如果vite启动了,则触发callback
       */
      server.httpServer.once("listening", () => {
        let addressInfo = server.httpServer.address();
        let httpAddress = `http://${addressInfo.address}:${addressInfo.port}`;
        /**
         * spawn是为了启动一个子进程去执行命令,类似于通过node脚本执行命令
         * spawn的第一个参数是要运行的命令的地址,第二个参数是命令的字符串参数列表,第三个参数是配置项
         */
        let electronProcess = spawn(
          electron.toString(),
          [mainEntryOutputPath, httpAddress],
          {
            cwd: process.cwd(), //当前项目的根目录
            stdio: "inherit", //让子进程继承主进程的stdin,stdout,stderr
          }
        );
        /**
         * 当electron子进程退出的时候,我们需要关闭Vite的http服务,并且控制父进程退出
         */
        electronProcess.on("close", () => {
          server.close();
          process.exit();
        });
      });
    },
  };
};

在渲染进程里支持electron和node模块的引入

因为渲染进程不支持直接引入electron和node的内置模块

首先需要通过修改mainEntry.ts,通过主进程开启渲染进程的一些开关,从而允许渲染进程使用node的内置模块

// src\main\mainEntry.ts
import { app, BrowserWindow } from "electron";
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
let mainWindow: BrowserWindow;

app.whenReady().then(() => {
  let config = {
    webPreferences: {
      nodeIntegration: true,
      webSecurity: false,
      allowRunningInsecureContent: true,
      contextIsolation: false,
      webviewTag: true,
      spellcheck: false,
      disableHtmlFullscreenWindowResize: true,
    },
  };
  mainWindow = new BrowserWindow(config);
  mainWindow.webContents.openDevTools({ mode: "undocked" });
  mainWindow.loadURL(process.argv[2]);
});

在这段代码中,有以下几点需要注意:

  1. ELECTRON_DISABLE_SECURITY_WARNINGS 用于设置渲染进程开发者调试工具的警告,这里设置为 true 就不会再显示任何警告了。

如果渲染进程的代码可以访问 Node.js 的内置模块,而且渲染进程加载的页面(或脚本)是第三方开发的,那么恶意第三方就有可能使用 Node.js 的内置模块伤害最终用户 。这就是为什么这里要有这些警告的原因。如果你的应用不会加载任何第三方的页面或脚本。那么就不用担心这些安全问题啦。

  1. nodeIntegration配置项的作用是把 Node.js 环境集成到渲染进程中,contextIsolation配置项的作用是在同一个 JavaScript 上下文中使用 Electron API。其他配置项与本文主旨无关,大家感兴趣的话可以自己翻阅官方文档。
  2. webContentsopenDevTools方法用于打开开发者调试工具

完成这些工作后我们就可以在开发者调试工具中访问 Node.js 和 Electron 的内置模块了。

设置 Vite 模块别名与模块解析钩子

虽然我们可以在开发者调试工具中使用 Node.js 和 Electron 的内置模块,但现在还不能在 Vue 的页面内使用这些模块。

这是因为 Vite 主动屏蔽了这些内置的模块,如果开发者强行引入它们,那么大概率会得到如下报错:


Module "xxxx" has been externalizedforbrowser compatibility and cannot be accessedin client code.

接下去我们就介绍如何让 Vite 加载 Electron 的内置模块和 Node.js 的内置模块。


npmi vite-plugin-optimizer -D

然后修改 vite.config.ts 的代码,让 Vite 加载这个插件,如下代码所示:


// vite.config.tsimport { defineConfig }from "vite";
import vuefrom "@vitejs/plugin-vue";
import { devPlugin, getReplacer }from "./plugins/devPlugin";
import optimizerfrom "vite-plugin-optimizer";

exportdefaultdefineConfig({
  plugins: [optimizer(getReplacer()),devPlugin(),vue()],
});

vite-plugin-optimizer 插件会为你创建一个临时目录:node_modules.vite-plugin-optimizer

然后把类似 const fs = require('fs'); export { fs as default } 这样的代码写入这个目录下的 fs.js 文件中。

渲染进程执行到:import fs from "fs" 时,就会请求这个目录下的 fs.js 文件,这样就达到了在渲染进程中引入 Node 内置模块的目的。

getReplacer 方法是我们为 vite-plugin-optimizer 插件提供的内置模块列表。代码如下所示:


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

我们在这个方法中把一些常用的 Node 模块和 electron 的内置模块提供给了 vite-plugin-optimizer 插件,以后想要增加新的内置模块只要修改这个方法即可。而且 vite-plugin-optimizer 插件不仅用于开发环境,编译 Vue 项目时,它也会参与工作 。

再次运行你的应用,看看现在渲染进程是否可以正确加载内置模块了呢?你可以通过如下代码在 Vue 组件中做这项测试:


//src\App.vueimport fsfrom "fs";
import { ipcRenderer }from "electron";
import { onMounted }from "vue";
onMounted(() => {
  console.log(fs.writeFileSync);
  console.log(ipcRenderer);
});

编译结束钩子函数

首先我们为 vite.config.ts 增加一个新的配置节,如下代码所示:


//vite.config.ts//import { buildPlugin } from "./plugins/buildPlugin";build: {
    rollupOptions: {
        plugins: [buildPlugin()],
    },
},

其中,buildPlugin 方法的代码如下:


//plugins\buildPlugin.tsexportletbuildPlugin = () => {
return {
    name: "build-plugin",
    closeBundle: () => {
let buildObj =newBuildObj();
      buildObj.buildMain();
      buildObj.preparePackageJson();
      buildObj.buildInstaller();
    },
  };
};

这是一个标准的 Rollup 插件(Vite 底层就是 Rollup,所以 Vite 兼容 Rollup 的插件),我们在这个插件中注册了 closeBundle 钩子

在 Vite 编译完代码之后(也就是我们执行 npm run build 指令,而且这个指令的工作完成之后),这个钩子会被调用。我们在这个钩子中完成了安装包的制作过程。

制作应用安装包

Vite 编译完成之后,将在项目dist目录内会生成一系列的文件(如下图所示),此时closeBundle钩子被调用,我们在这个钩子中把上述生成的文件打包成一个应用程序安装包。

这些工作是通过一个名为buildObj的对象完成的,它的代码如下所示:

这个对象通过三个方法提供了三个功能,按照这三个方法的执行顺序我们一一介绍它们的功能。

  1. buildMain。由于 Vite 在编译之前会清空 dist 目录,所以我们在上一节中生成的 mainEntry.js 文件也被删除了,此处我们通过buildMain方法再次编译主进程的代码。不过由于此处是在为生产环境编译代码,所以我们增加了minify: true 配置,生成压缩后的代码。如果你希望与开发环境复用编译主进程的代码,也可以把这部分代码抽象成一个独立的方法。
  2. preparePackageJson。用户安装我们的产品后,在启动我们的应用程序时,实际上是通过 Electron 启动一个 Node.js 的项目,所以我们要为这个项目准备一个 package.json 文件,这个文件是以当前项目的 package.json 文件为蓝本制作而成的。里面注明了主进程的入口文件,移除了一些对最终用户没用的配置节。

生成完 package.json 文件之后,还创建了一个 node_modules 目录。此举是为了阻止 electron-builder 的一些默认行为(这一点我们后续章节还会介绍,目前来说它会阻止electron-builder为我们创建一些没用的目录或文件)。

这段脚本还明确指定了 Electron 的版本号,如果 Electron 的版本号前面有"^"符号的话,需把它删掉。这是 electron-builder 的一个 Bug,这个 bug 导致 electron-builder 无法识别带 ^ 或 ~ 符号的版本号。

在真正创建安装包之前,你应该已经成功通过npm install electron-builder -D安装了 electron-builder 库。

做好这些配置之后,执行npm run build就可以制作安装包了,最终生成的安装文件会被放置到 release 目录下。

你如果不了解 electron-builder 原理的话,可能会觉得奇怪:为什么执行require("electron-builder").build(options)就会为我们生成应用程序的安装包呢?

所以,接下来我们得介绍一下 electron-builder 背后为我们做了什么工作。

首先 electron-builder 会收集应用程序的配置信息。比如应用图标、应用名称、应用 id、附加资源等信息。有些配置信息可能开发者并没有提供,这时 electron-builder 会使用默认的值,总之,这一步工作完成后,会生成一个全量的配置信息对象用于接下来的打包工作。

接着 electron-builder 会检查我们在输出目录下准备的 package.json 文件,查看其内部是否存在 dependencies 依赖,如果存在,electron-builder 会帮我们在输出目录下安装这些依赖

然后 electron-builder 会根据用户配置信息:asar 的值为 true 或 false,来判断是否需要把输出目录下的文件合并成一个 asar 文件

然后 electron-builder 会把 Electron 可执行程序及其依赖的动态链接库及二进制资源拷贝到安装包生成目录下的 win-ia32-unpacked 子目录内。

然后 electron-builder 还会检查用户是否在配置信息中指定了 extraResources 配置项,如果有,则把相应的文件按照配置的规则,拷贝到对应的目录中。

然后 electron-builder 会根据配置信息使用一个二进制资源修改器修改 electron.exe 的文件名和属性信息(版本号、版权信息、应用程序的图标等)。

如果开发者在配置信息中指定了签名信息,那么接下来 electron-builder 会使用一个应用程序签名工具来为可执行文件签名

接着 electron-builder 会使用 7z 压缩工具,把子目录 win-ia32-unpacked 下的内容压缩成一个名为 yourProductName-1.3.6-ia32.nsis.7z 的压缩包。

接下来 electron-builder 会使用 NSIS 工具生成卸载程序的可执行文件,这个卸载程序记录了 win-ia32-unpacked 目录下所有文件的相对路径,当用户卸载我们的应用时,卸载程序会根据这些相对路径删除我们的文件,同时它也会记录一些安装时使用的注册表信息,在卸载时清除这些注册表信息。

最后 electron-builder 会使用 NSIS 工具生成安装程序的可执行文件,然后把压缩包和卸载程序当作资源写入这个安装程序的可执行文件中。当用户执行安装程序时,这个可执行文件会读取自身的资源,并把这些资源释放到用户指定的安装目录下。

如果开发者配置了签名逻辑,则 electron-builder 也会为安装程序的可执行文件和卸载程序的可执行文件进行签名。

至此,一个应用程序的安装包就制作完成了。这就是 electron-builder 在背后为我们做的工作。

主进程生产环境加载本地文件

虽然我们成功制作了安装包,而且这个安装包可以正确安装我们的应用程序,但是这个应用程序无法正常启动,这是因为应用程序的主进程还在通过 process.argv[2] 加载首页。显然用户通过安装包安装的应用程序没有这个参数。

所以,接下来我们就要让应用程序在没有这个参数的时候,也能加载我们的静态页面

首先创建一个新的代码文件:src\main\CustomScheme.ts,为其创建如下代码:


//src\main\CustomScheme.ts
import { protocol }from "electron";
import fsfrom "fs";
import pathfrom "path";

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

exportclassCustomScheme {
//根据文件扩展名获取mime-typeprivatestaticgetMimeType(extension: string) {
let mimeType = "";
if (extension === ".js") {
      mimeType = "text/javascript";
    }elseif (extension === ".html") {
      mimeType = "text/html";
    }elseif (extension === ".css") {
      mimeType = "text/css";
    }elseif (extension === ".svg") {
      mimeType = "image/svg+xml";
    }elseif (extension === ".json") {
      mimeType = "application/json";
    }
return mimeType;
  }
//注册自定义app协议staticregisterScheme() {
    protocol.registerStreamProtocol("app", (request, callback) => {
let pathName =newURL(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),
      });
    });
  }
}

这段代码在主进程app ready前,通过 protocol 对象的 registerSchemesAsPrivileged 方法为名为 app 的 scheme 注册了特权(可以使用 FetchAPI、绕过内容安全策略等)。

app ready之后,通过 protocol 对象的 registerStreamProtocol 方法为名为 app 的 scheme 注册了一个回调函数。当我们加载类似app://index.html这样的路径时,这个回调函数将被执行。

这个函数有两个传入参数 request 和 callback,我们可以通过 request.url 获取到请求的文件路径,可以通过 callback 做出响应。

给出响应时,要指定响应的 statusCode 和 content-type,这个 content-type 是通过文件的扩展名得到的。这里我们通过 getMimeType 方法确定了少量文件的 content-type,如果你的应用要支持更多文件类型,那么可以扩展这个方法。

响应的 data 属性为目标文件的可读数据流。这也是为什么我们用 registerStreamProtocol 方法注册自定义协议的原因。当你的静态文件比较大时,不必读出整个文件再给出响应。

接下来在 src\main\mainEntry.ts 中使用这段代码,如下所示:


//src\main\mainEntry.ts
if (process.argv[2]) {
  mainWindow.loadURL(process.argv[2]);
}else {
CustomScheme.registerScheme();
  mainWindow.loadURL(`app://index.html`);
}

这样当存在指定的命令行参数时,我们就认为是开发环境,使用命令行参数加载页面,当不存在命令行参数时,我们就认为是生产环境,通过app:// scheme 加载页面。

再次打包、安装你的应用程序,看这次是不是可以正常运行了呢?

如果你不希望在开发环境中通过命令行参数的形式传递信息,那么你也可以在上一节介绍的代码中,为electronProcess 附加环境变量(使用spawn方法第三个参数的env属性附加环境变量)。

window.open 解决方案

Electron 允许渲染进程通过window.open打开一个新窗口,但这需要做一些额外的设置。

首先需要为主窗口的webContents注册setWindowOpenHandler方法。


//src\main\CommonWindowEvent.ts
mainWindow.webContents.setWindowOpenHandler((param) => {
return { action: "allow", overrideBrowserWindowOptions: yourWindowConfig };
});

我们在上面的代码中使用setWindowOpenHandler方法的回调函数返回一个对象,这个对象中action: "allow"代表允许窗口打开,如果你想阻止窗口打开,那么只要返回{action: "deny"}即可。

返回对象的overrideBrowserWindowOptions属性的值是被打开的新窗口的配置对象。

在渲染进程中打开子窗口的代码如下所示:


//src\renderer\Component\BarLeft.vue
window.open(`/WindowSetting/AccountSetting`);

window.open打开新窗口之所以速度非常快,是因为用这种方式创建的新窗口不会创建新的进程。这也就意味着一个窗口崩溃会拖累其他窗口跟着崩溃(主窗口不受影响)。

使用window.open打开的新窗口还有一个问题,这类窗口在关闭之后虽然会释放掉大部分内存,但有一小部分内存无法释放(无论你打开多少个子窗口,全部关闭之后总会有那么固定的一小块内存无法释放),这与窗口池方案的内存损耗相当。

这个问题可能与 Electron 的这个 Issue 有关:window.open with nativeWindowOpen option causes memory leak。

同样使用这个方案也无法优化应用的第一个窗口的创建速度。而且<webview>BrowserView慢的问题无法使用这个方案解决(这类需求还是应该考虑“池”方案)。

但是通过window.open打开的新窗口更容易控制,这是这个方案最大的优点。接下来我们就介绍如何使用这个方案控制子窗口。

子窗口的标题栏消息

在上一节中,我们自定义了主窗口的标题栏BarTop.vue,我们知道标题栏组件需要监听主进程发来的windowMaximized消息和windowUnmaximized消息,子窗口当然也希望复用这个组件,然而子窗口的窗口对象是在 Electron 内部创建的,不是我们开发者创建的,没有子窗口的窗口对象,我们该如何使用上一节介绍的regWinEvent方法为子窗口注册最大化和还原事件呢?

这就需要用到 app 对象的browser-window-created事件,代码如下:


//src\main\mainEntry.ts
app.on("browser-window-created", (e, win) => {
CommonWindowEvent.regWinEvent(win);
});

每当有一个窗口被创建成功后,这个事件就会被触发,主窗口和使用window.open创建的子窗口都一样,所以之前我们为主窗口注册事件的代码CommonWindowEvent.regWinEvent(mainWindow)也可以删掉了。这个事件的第二个参数就是窗口对象。

动态设置子窗口的配置

虽然我们可以在渲染进程中用window.open方法打开一个子窗口,但这个子窗口的配置信息目前还是在主进程中设置的(overrideBrowserWindowOptions),大部分时候我们要根据渲染进程的要求来改变子窗口的配置,所以最好的办法是由渲染进程来设置这些配置信息。

我们为上一节介绍的CommonWindowEvent类的regWinEvent方法增加一段逻辑,代码如下:


//src\main\CommonWindowEvent.ts
//注册打开子窗口的回调函数
win.webContents.setWindowOpenHandler((param) => {
//基础窗口配置对象let config = {
    frame: false,
    show: true,
    webPreferences: {
      nodeIntegration: true,
      webSecurity: false,
      allowRunningInsecureContent: true,
      contextIsolation: false,
      webviewTag: true,
      spellcheck: false,
      disableHtmlFullscreenWindowResize: true,
      nativeWindowOpen: true,
    },
  };
//开发者自定义窗口配置对象let features =JSON.parse(param.features);
for (let p in features) {
if (p === "webPreferences") {
for (let p2in features.webPreferences) {
        config.webPreferences[p2] = features.webPreferences[p2];
      }
    }else {
      config[p] = features[p];
    }
  }
if (config["modal"] === true) config.parent = win;
//允许打开窗口,并传递窗口配置对象
return { action: "allow", overrideBrowserWindowOptions: config };
});

在这段代码中,config对象和主窗口的config对象基本上是一样的,所以最好把它抽象出来,我们这里为了演示方便没做这个工作。

param参数的features属性是由渲染进程传过来的,是一个字符串,这里我们把它当作一个 JSON 字符串使用,这个字符串里包含着渲染进程提供的窗口配置项,这些配置项与 config 对象提供的基础配置项结合,最终形成了子窗口的配置项。

如果配置项中modal属性的值为true的话,说明渲染进程希望子窗口为一个模态窗口,这时我们要为子窗口提供父窗口配置项:parent,这个配置项的值就是当前窗口。

之所以把这段逻辑放置在CommonWindowEvent类的regWinEvent方法中,就是希望更方便地为应用内的所有窗口提供这项能力,如果你不希望这么做,也可以把这段逻辑放置在一个独立的方法中。

现在渲染进程中打开子窗口的代码可以变成这样了:


//src\renderer\Component\BarLeft.vue
let openSettingWindow = () => {
let config = { modal: true, width: 2002, webPreferences: { webviewTag: false } };
  window.open(`/WindowSetting/AccountSetting`, "_blank",JSON.stringify(config));
};

window.open方法的第三个参数官方定义为一个逗号分割的 key-value 列表,但这里我们把它变成了一个 JSON 字符串,这样做主要是为了方便地控制子窗口的配置对象。

使用window.open打开新窗口速度非常快,所以这里我们直接让新窗口显示出来了config.show = true。如果你需要在新窗口初始化时完成复杂耗时的业务逻辑,那么你也应该手动控制新窗口的显示时机。就像我们控制主窗口一样。

封装子窗口加载成功的事件

现在我们遇到了一个问题:不知道子窗口何时加载成功了,注意这里不能单纯地使用window对象的onload事件或者 document 对象的DOMContentLoaded事件来判断子窗口是否加载成功了。因为这个时候你的业务代码(比如从数据库异步读取数据的逻辑)可能尚未执行完成。

所以,我们要自己封装一个事件,在我们的业务代码真正执行完成时,手动发射这个事件,告知主窗口:“现在子窗口已经加载成功啦,你可以给我发送消息了!”

在封装这个事件前,我们先来把window.open打开子窗口的逻辑封装到一个Promise对象中,如下代码所示:


//src\renderer\common\Dialog.ts
export let createDialog = (url: string, config: any):Promise<Window> => {
return newPromise((resolve, reject) => {
let windowProxy = window.open(url, "_blank",JSON.stringify(config));
letreadyHandler = (e) => {
let msg = e.data;
if (msg["msgName"] === `__dialogReady`) {
        window.removeEventListener("message", readyHandler);
resolve(windowProxy);
      }
    };
    window.addEventListener("message", readyHandler);
  });
};

在这段代码中,我们把渲染进程的一些工具方法和类放置在src\renderer\common\目录下(注意,有别于src\common\目录)。

当渲染进程的某个组件需要打开子窗口时,可以使用Dialog.ts提供的createDialog方法。

在这段代码中,我们把window.open的逻辑封装到一个Promise对象中, 通过window.open打开子窗口后,当前窗口马上监听message事件,子窗口有消息发送给当前窗口时,这个事件将被触发。

我们在message事件的处理函数中完成了下面三个工作。

  1. e.data 里存放着具体的消息内容,我们把它格式化成一个 JSON 对象。
  2. 如果这个 JSON 对象的msgName属性为__dialogReady字符串,我们就成功resolve
  3. Promise对象成功resolve之前要移除掉message事件的监听函数,避免内存泄漏(如果不这么做,用户每打开一个子窗口,就会注册一次message事件)。

window.open方法返回的是目标窗口的引用,我们可以使用这个引用对象向目标窗口发送消息,或执行其他相关操作。

Dialog.ts并非只导出了createDialog这么一个方法,它还导出了dialogReady方法,代码如下所示:


//src\renderer\common\Dialog.tsexport
let dialogReady = () => {
let msg = { msgName: `__dialogReady` };
  window.opener.postMessage(msg);
};

这个方法是为子窗口服务的,当子窗口完成了必要的业务逻辑之后,就可以执行这个方法,通知父窗口自己已经加载成功。

这个方法通过window.opener对象的postMessage方法向父窗口发送了一个消息,这个消息的内容是一个 JSON 对象,这个 JSON 对象的msgName属性为__dialogReady字符串。

父窗口收到子窗口发来的这个消息后,将触发message事件,也就会执行我们在createDialog方法中撰写的逻辑了。

父子窗口互相通信

我们可以使用 createDialog 方法返回的对象向子窗口发送消息,代码如下所示:


//src\renderer\Component\BarLeft.vue
let config = { modal: true, width: 800, webPreferences: { webviewTag: false } };
let dialog =await createDialog(`/WindowSetting/AccountSetting`, config);
let msg = { msgName: "hello", value: "msg from your parent" };
dialog.postMessage(msg);

想要接收子窗口发来的消息,只要监听 window 对象的 message 事件即可,代码如下所示:


//src\renderer\Component\BarLeft.vue
window.addEventListener("message", (e) => {
  console.log(e.data);
});

子窗口发送消息给父窗口的代码如下所示:


window.opener.postMessage({ msgName: "hello", value: "I am your son." });

子窗口接收父窗口发来的消息的代码,与父窗口接收消息的代码相同,我们就不再赘述了。

相对于使用 ipcRender 和 ipcMain 的方式完成窗口间通信来说,使用这种方式完成跨窗口通信有以下几项优势:

  • 消息传递与接收效率都非常高,均为毫秒级;
  • 开发更加简单,代码逻辑清晰,无需跨进程中转消息。

引入 SQLite

你可以使用如下指令为我们的工程安装better-sqlite3


npm install better-sqlite3 -D

这个模块安装完成后,大概率你是无法使用这个模块的,你可能会碰到如下报错信息:


Error: The module '...node_modules\better-sqlite3\build\Release\better_sqlite3.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION $XYZ. This version of Node.js requires
NODE_MODULE_VERSION $ABC. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).

这是因为 Electron 内置的 Node.js 的版本可能与你编译原生模块使用的 Node.js 的版本不同导致的。

Electron 内置的 Node.js 中的一些模块也与 Node.js 发行版不同,比如 Electron 使用了 Chromium 的加密解密库 BoringSL,而 Node.js 发行版使用的是 OpenSSL 加密解密库。

使用如下命令安装electron-rebuild


npm install electron-rebuild -D

然后,在你的工程的 package.json 中增加如下配置节(scripts配置节):


"rebuild": "electron-rebuild -f -w better-sqlite3"

接着,在工程根目录下执行如下指令:

当你的工程下出现了这个文件node_modules\better-sqlite3\build\Release\better_sqlite3.node,才证明better_sqlite3模块编译成功了,如果上述指令没有帮你完成这项工作,你可以把指令配置到node_modules\better-sqlite3模块内部再执行一次,一般就可以编译成功了(如下图所示)。

这样就为 Electron 重新编译了一遍better-sqlite3,我们就可以在 Electron 应用内使用better-sqlite3提供的 API 了。

你可以在应用中试试如下代码(渲染进程和主进程均可,甚至在渲染进程的开发者调试工具中也没问题),看是不是可以正确创建 SQLite 的数据库。


const Database = require("better-sqlite3");
const db = newDatabase("db.db", { verbose: console.log, nativeBinding: "./node_modules/better-sqlite3/build/Release/better_sqlite3.node" });

不出意外的话,你的工程根目录下将会创建一个名为db.db的 SQLite 数据库文件,说明better-sqlite3库已经生效了。

压缩安装包体积

可能有的同学会有疑问:better-sqlite3不是一个原生模块吗,但这里我们仍然把它安装成了开发依赖,大家都知道原生模块是无法被 Vite 编译到 JavaScript,那我们为什么还要把它安装程开发依赖呢?

better-sqlite3安装成生产依赖,在功能上没有任何问题,electron-builder 在制作安装包时,会自动为安装包附加这个依赖(better-sqlite3这个库自己的依赖也会被正确附加到安装包内)。

electron-builder会把很多无用的文件(很多编译原生模块时的中间产物)也附加到安装包内。无形中增加了安装包的体积(大概 10M),如下图所示:

上图中红框内的文件都是无用的文件,还有很多无用的文件没出现在这个截图中,如果你像我一样,无法容忍这一点,那么接下去我们就介绍一种办法来处理这个问题。

无用文件的多少实际上与开发环境和具体原生模块的配置有关,如果你的环境中没有过多的垃圾文件,你不做这些工作问题也不大。


//plugins\buildPlugin.ts//
import fs from "fs-extra";
asyncprepareSqlite() {
//拷贝better-sqlite3let srcDir = path.join(process.cwd(), `node_modules/better-sqlite3`);
let destDir = path.join(process.cwd(), `dist/node_modules/better-sqlite3`);
  fs.ensureDirSync(destDir);
  fs.copySync(srcDir, destDir, {
    filter: (src, dest) => {
if (src.endsWith("better-sqlite3") || src.endsWith("build") || src.endsWith("Release") || src.endsWith("better_sqlite3.node"))return true;
elseif (src.includes("node_modules\\better-sqlite3\\lib"))return true;
elsereturn false;
    },
  });

let pkgJson = `{"name": "better-sqlite3","main": "lib/index.js"}`;
let pkgJsonPath = path.join(process.cwd(), `dist/node_modules/better-sqlite3/package.json`);
  fs.writeFileSync(pkgJsonPath, pkgJson);
//制作bindings模块let bindingPath = path.join(process.cwd(), `dist/node_modules/bindings/index.js`);
  fs.ensureFileSync(bindingPath);
let bindingsContent = `module.exports = () => {let addonPath = require("path").join(__dirname, '../better-sqlite3/build/Release/better_sqlite3.node');
return require(addonPath);
};`;
  fs.writeFileSync(bindingPath, bindingsContent);

  pkgJson = `{"name": "bindings","main": "index.js"}`;
  pkgJsonPath = path.join(process.cwd(), `dist/node_modules/bindings/package.json`);
  fs.writeFileSync(pkgJsonPath, pkgJson);
}

这段代码主要做了两个工作。

  • 第一:把开发环境的node_modules\better-sqlite3目录下有用的文件拷贝到dist\node_modules\better-sqlite3目录下,并为这个模块自制了一个简单的package.json
  • 第二:完全自己制作了一个bindings模块,把这个模块放置在dist\node_modules\bindings目录下。

这里bindings模块是better-sqlite3模块依赖的一个模块,它的作用仅仅是确定原生模块文件better_sqlite3.node的路径。

接下来再修改一下BuildObjpreparePackageJson方法,在生成package.json文件之前,为其附加两个生产依赖,代码如下:


//版本号是否正确无关紧要localPkgJson.dependencies["better-sqlite3"] = "*";
localPkgJson.dependencies["bindings"] = "*";

有了这两个配置electron-builder就不会再为我们自动安装这些模块了。

完成这些工作后,在closeBundle钩子函数中调用这个方法:buildObj.prepareSqlite(),你再打包看看,安装包的体积是否变小了呢?

引入 Knex.js

成功引入better-sqlite3并且压缩了better-sqlite3模块在安装包的体积后,我们马上就面临着另一个问题需要解决。

使用better-sqlite3读写数据库中的数据时,要书写 SQL 语句,这种语句是专门为数据库准备的指令,是不太符合现代编程语言的习惯的,下面是为 sqlite 数据库建表和在对应表中完成增删改查的 SQL 语句:


create table admin(username text,ageinteger);
insert into admin values('allen',18);
select * from admin;
update adminset username='allen001',age=88 where username='allen'and age=18;
delete from admin where username='allen001';

我们完全可以使用Knex.js库来完成对应的操作,Knex.js允许我们使用 JavaScript 代码来操作数据库里的数据和表结构,它会帮我们把 JavaScript 代码转义成具体的 SQL 语句,再把 SQL 语句交给数据库处理。我们可以把它理解为一种 SQL Builder

安装Knex.js的指令如下:


npm install knex -D

这个库大部分时候都被用在 Node.js 的服务端,所以设计过程中没有过多地考虑库的体积的问题。但如果你不在意安装包的体积,也可以以生产依赖的形式安装这个库,让 electron-builder 为你安装这个模块和它依赖的那些子模块。

如果你希望做得完美一些,那么我们下面就介绍打包之前编译这个库的方法,代码如下所示:


//plugins\buildPlugin.ts//
import fs from "fs-extra";
prepareKnexjs() {
let pkgJsonPath = path.join(process.cwd(), `dist/node_modules/knex`);
  fs.ensureDirSync(pkgJsonPath);
  require("esbuild").buildSync({
    entryPoints: ["./node_modules/knex/knex.js"],
    bundle: true,
    platform: "node",
    format: "cjs",
    minify: true,
    outfile: "./dist/node_modules/knex/index.js",
    external: ["oracledb", "pg-query-stream", "pg", "sqlite3", "tedious", "mysql", "mysql2", "better-sqlite3"],
  });
let pkgJson = `{"name": "bindings","main": "index.js"}`;
  pkgJsonPath = path.join(process.cwd(), `dist/node_modules/knex/package.json`);
  fs.writeFileSync(pkgJsonPath, pkgJson);
}

这个方法也是放置在buildPlugin.ts文件中的,相对于压缩better-sqlite3的体积来说,压缩Knex.js包的体积就简单很多了,我们仅仅是通过esbuild工具编译了一下这个包的代码就完成了工作。

这段代码有以下几点需要注意。

  • 配置项external是为了避免编译过程中esbuild去寻找这些模块而导致编译失败,也就是说Knex.js中这样的代码会保持原样输出到编译产物中:require('better-sqlite3')
  • 同样,我们要再为 package.json 增加一个生产依赖:localPkgJson.dependencies["knex"] = "*";,以避免 electron-builder 为我们安装Knex.js模块。
  • 别忘记在closeBundle钩子函数中调用这个方法:buildObj.prepareKnexjs()

完成这些工作之后,我们就把 Knex.js 安装到我们的工程内了。我们将在下一节介绍如何使用这个库。

转载自:https://juejin.cn/post/7254067101451665465
评论
请登录