今年上半年主要是用 Electron 开发桌面应用,在开发过程中遇到各种问题也只能自己查资料,最近在掘金中看到刘晓伦大佬写的 Electron + Vue 3 桌面应用开发掘金小册,果断买下课程仔细学习下,这里主要记录下自己在 Electron 开发中遇到的知识盲区
Electron 是一个集成项目,它做了如下几个重要的工作。
- 定制 Chromium,并把定制版本的 Chromium 集成在 Electron 内部。
- 定制 Node.js,并把定制版本的 Node.js 集成在 Electron 内部。
- 通过消息轮询机制打通 Node.js 和 Chromium 的消息循环。
- 通过 Electron 的内置模块向开发者提供桌面应用开发必备的 API。
如何开发 Vite3 插件构建 Electron 开发环境?
创建项目
npm create vite@latest electron-jue-jin -- --template vue-ts
接着安装 Electron 开发依赖:
npm install electron -D
**注意:**这里我们把 vue 从 dependencies 配置节移至了 devDependencies 配置节。这是因为在 Vite 编译项目的时候,Vue 库会被编译到输出目录下,输出目录下的内容是完整的,没必要把 Vue 标记为生产依赖;而且在我们将来制作安装包的时候,还要用到这个 package.json 文件,它的生产依赖里不应该有没用的东西,所以我们在这里做了一些调整。有利于减小打包后产物大小
到这里,我们就创建了一个基本的 Vue+TypeScript 的项目,接下来我们就为这个项目引入 Electron 模块。
创建主进程代码
//src\main\mainEntry.ts
import { app, BrowserWindow } from "electron";
let mainWindow: BrowserWindow;
app.whenReady().then(() => {
mainWindow = new BrowserWindow({});
mainWindow.loadURL(process.argv[2]);
});
mainWindow 被设置成一个全局变量,这样可以避免主窗口被 JavaScript 的垃圾回收器回收掉。
app 和 BrowserWindow 都是 Electron 的内置模块,这些内置模块是通过 ES Module 的形式导入进来的,我们知道 Electron 的内置模块都是通过 CJS Module 的形式导出的,这里之所以可以用 ES Module 导入,是因为我们接下来做的主进程编译工作帮我们完成了相关的转化工作。
开发环境 Vite 插件
主进程的代码写好之后,只有编译过之后才能被 Electron 加载,我们是通过 Vite 插件的形式来完成这个编译工作和加载工作的
import type { AddressInfo } from "node:net";
import { ViteDevServer } from "vite";
export let devPlugin = () => ({
name: "dev-plugin",
configureServer(server: ViteDevServer) {
require("esbuild").buildSync({
entryPoints: ["./src/main/mainEntry.ts"],
bundle: true,
platform: "node",
outfile: "./dist/mainEntry.js",
external: ["electron"],
});
server.httpServer?.once("listening", () => {
let { spawn } = require("child_process");
let addressInfo = server.httpServer?.address() as AddressInfo;
let httpAddress = `http://${addressInfo.address}:${addressInfo.port}`;
let electronProcess = spawn(
require("electron").toString(),
["./dist/mainEntry.js", httpAddress],
{
cwd: process.cwd(),
stdio: "inherit",
}
);
electronProcess.on("close", () => {
server.close();
process.exit();
});
});
},
});
当 Vite 为我们启动 Http 服务的时候,configureServer 钩子会被执行。
我们可以通过监听 server.httpServer 的 listening 事件来判断 httpServer 是否已经成功启动。如果已经成功启动了,那么就启动 Electron 应用,并给它传递两个命令行参数,第一个参数是主进程代码编译后的文件路径,第二个参数是 Vue 页面的 http 地址,这里就是 http://127.0.0.1:5173/。
为什么这里传递了两个命令行参数,而主进程的代码接收第三个参数(process.argv[2])当作 http 页面的地址呢?因为默认情况下 electron.exe 的文件路径将作为第一个参数。也就是我们通过 require(“electron”) 获得的字符串。
我们是通过 Node.js child_process 模块的 spawn 方法启动 electron 子进程的,除了两个命令行参数外,还传递了一个配置对象。
这个对象的 cwd 属性用于设置当前的工作目录,process.cwd() 返回的值就是当前项目的根目录。stdio 用于设置 electron 进程的控制台输出,这里设置 inherit 可以让 electron 子进程的控制台输出数据同步到主进程的控制台。这样我们在主进程中 console.log 的内容就可以在 VSCode 的控制台上看到了。
**编译平台 platform 设置为 node,排除的模块 external 设置为 electron,正是这两个设置使我们在主进程代码中可以通过 import 的方式导入 electron 内置的模块。**非但如此,Node 的内置模块也可以通过 import 的方式引入。
我们在 vite 项目中也可以使用 vite-plugin-electron 插件来引入 electron 内置模块
渲染进程集成内置模块
// 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]);
});
设置 Vite 模块别名与模块解析钩子
虽然我们可以在开发者调试工具中使用 Node.js 和 Electron 的内置模块,但现在还不能在 Vue 的页面内使用这些模块。
这是因为 Vite 主动屏蔽了这些内置的模块,如果开发者强行引入它们,那么大概率会得到如下报错:
Module "path" has been externalized for browser compatibility. Cannot access "path.join" in client code.
首先我们为工程安装一个 Vite 组件:vite-plugin-optimizer。
npm i vite-plugin-optimizer -D
然后修改 vite.config.ts 的代码,让 Vite 加载这个插件
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { devPlugin, getReplacer } from "./plugins/devPlugin";
import optimizer from "vite-plugin-optimizer";
export default defineConfig({
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 内置模块的目的。
// plugins\devPlugin.ts
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;
};
再次运行你的应用,现在渲染进程可以正确加载内置模块了
如何开发 Vite 3 插件打包 Electron 应用?
编译结束钩子函数
首先我们为 vite.config.ts 增加一个新的配置节
build: {
rollupOptions: {
plugins: [buildPlugin()],
},
},
//plugins\buildPlugin.ts
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: ["**"],
extends: null,
productName: "JueJin",
appId: "com.juejin.desktop",
asar: true,
nsis: {
oneClick: true,
perMachine: true,
allowToChangeInstallationDirectory: false,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: "juejinDesktop",
},
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();
},
};
};
制作应用安装包
Vite 编译完成之后,将在项目 dist 目录内会生成一系列的文件(如下图所示),此时 closeBundle 钩子被调用,
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 在背后为我们做的工作。
主进程生产环境加载本地文件
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: "app", 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("app", (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),
});
});
}
}
这段代码在主进程 app ready 前,通过 protocol 对象的 registerSchemesAsPrivileged 方法为名为 app 的 scheme 注册了特权(可以使用 FetchAPI、绕过内容安全策略等)。
在 app ready 之后,通过 protocol 对象的 registerStreamProtocol 方法为名为 app 的 scheme 注册了一个回调函数。当我们加载类似 app://index.html 这样的路径时,这个回调函数将被执行。
响应的 data 属性为目标文件的可读数据流。这也是为什么我们用 registerStreamProtocol 方法注册自定义协议的原因。当你的静态文件比较大时,不必读出整个文件再给出响应。
如何引入 vue-router 及控制工程架构?
npm install vue-router@4 -D
安装完成后,为 src/renderer/router.ts 添加路由
import * as VueRouter from "vue-router";
//路由规则描述数组
const routes = [
{ path: "/", redirect: "/WindowMain/Chat" },
{
path: "/WindowMain",
component: () => import("./Window/WindowMain.vue"),
children: [
{ path: "Chat", component: () => import("./Window/WindowMain/Chat.vue") },
{
path: "Contact",
component: () => import("./Window/WindowMain/Contact.vue"),
},
{
path: "Collection",
component: () => import("./Window/WindowMain/Collection.vue"),
},
],
},
{
path: "/WindowSetting",
component: () => import("./Window/WindowSetting.vue"),
children: [
{
path: "AccountSetting",
component: () => import("./Window/WindowSetting/AccountSetting.vue"),
},
],
},
{
path: "/WindowUserInfo",
component: () => import("./Window/WindowUserInfo.vue"),
},
];
//导出路由对象
export let router = VueRouter.createRouter({
history: VueRouter.createWebHistory(),
routes,
});
引入字体图标及避免小文件编译
第一点,引入必要的字体图标文件。
iconfont 默认会为我们生成很多字体图标文件,但这些文件都是为了兼容不同的浏览器准备的,Electron 使用 Chromium 核心,所以没必要把所有这些文件都集成到应用中。一般情况下我只使用 iconfont.css 和 iconfont.ttf 这两个文件
第二点,关闭小文件编译的行为
没必要为了减少请求数量而增加单个文件的解析开销,开发者可以通过在 vite.config.ts 中增加
{
"build": {
"assetsInlineLimit": 0
}
}
build.assetsInlineLimit 配置(值设置为 0 即可)来关闭 Vite 的这个行为。
如何管控应用的窗口(上)?
BrowserWindow 的问题
你就会发现 Electron 创建一个 BrowserWindow 对象,并让它成功渲染一个页面是非常耗时的,在一个普通配置的电脑上,这大概需要 2~5 秒左右的时间
而且不但 BrowserWindow 存在这个问题,
解决这个问题最直接的方法是关闭 Windows 系统的病毒和威胁防护功能,或者为 Windows 系统的病毒和威胁防护功能添加例外
添加例外的 shell 脚本
Add-MpPreference -ExclusionProcess "C:\Users\liuxiaolun\AppData\Roaming\Electron Fiddle\electron-bin\19.0.4\electron.exe"
窗口池解决方案
提前准备 1 个或多个隐藏的窗口,让它们加载一个骨架屏页面,放到一个数组里,当应用程序需要打开一个新窗口时,就从这个数组里取出一个窗口,执行页内跳转,从骨架屏页面跳转到业务页面,然后再把这个窗口显示出来。这就消费掉了一个窗口。
然而这个方案有以下三个缺点。
- 无法优化整个应用的第一个窗口。
- 系统内部始终会有 1 到 2 个隐藏窗口处于待命状态,这无形中增加了用户的内存消耗。
- 虽然这个方案看上去逻辑比较简单,但要控制好所有的细节(比如,窗口间的通信、界面代码如何控制窗口的外观、如何实现模态子窗口等)还是非常繁琐的。
如何管控应用的窗口(下)?
我们知道 Electron 是允许使用 window.open 的方式打开一个子窗口,通过这种方式打开的子窗口不会创建新的进程,效率非常高,可以在几百毫秒内就为用户呈现窗口内容。
虽然这个方案可以应对一般的多窗口应用的需求,但对于一些复杂的需求却需要很多额外的处理才能满足需求,比如:系统设置子窗口,当用户完成某一项设置之后,要通知父窗口做出相应的改变。这是常见的父子窗口通信的需求。
window.open 解决方案
Electron 允许渲染进程通过 window.open 打开一个新窗口,但这需要做一些额外的设置。
首先需要为主窗口的 webContents 注册 setWindowOpenHandler 方法。
//src\main\CommonWindowEvent.ts
mainWindow.webContents.setWindowOpenHandler((param) => {
return { action: "allow", overrideBrowserWindowOptions: yourWindowConfig };
});
在渲染进程中打开子窗口的代码如下所示:
window.open(`/WindowSetting/AccountSetting`);
window.open 打开新窗口之所以速度非常快,是因为用这种方式创建的新窗口不会创建新的进程。这也就意味着一个窗口崩溃会拖累其他窗口跟着崩溃(主窗口不受影响)。
使用 window.open 打开的新窗口还有一个问题,这类窗口在关闭之后虽然会释放掉大部分内存,但有一小部分内存无法释放(无论你打开多少个子窗口,全部关闭之后总会有那么固定的一小块内存无法释放),这与窗口池方案的内存损耗相当。
同样使用这个方案也无法优化应用的第一个窗口的创建速度。而且
// 父窗口 监听 window 对象的 message 事件
window.addEventListener("message", (e) => {
console.log(e.data);
});
// 子窗口发送消息给父窗口
window.opener.postMessage({ msgName: "hello", value: "I am your son." });
相对于使用 ipcRenderer 和 ipcMain 的方式完成窗口间通信来说,使用这种方式完成跨窗口通信有以下几项优势:
消息传递与接收效率都非常高,均为毫秒级;
开发更加简单,代码逻辑清晰,无需跨进程中转消息。
如何引入客户端数据库及相关工具?
对于简单的数据类型来说,开发者可以直接把它们存储在 localStorage 中,这些数据是持久化在用户磁盘上的,不会因为用户重启应用或者重装应用而丢失。
对于稍微复杂的数据类型来说,开发者有两个选择,其一是把这类数据存储在 IndexedDB 中,与 localStorage 类似,这也是谷歌浏览器核心提供的数据持久化工具,它以 JSON 对象的方式存储数据,数据较多时,复杂的条件查询效率不佳。
第二个选择就是把数据存储在 SQLite 中,这是一个关系型数据库,天生对复杂条件查询支持良好。接下来我们就介绍如何把 SQLite 引入到 Electron 应用中。
引入 SQLite
npm install better-sqlite3 -D
建议开发者使用 Electron 团队提供的 electron-rebuild 工具来完成此工作,因为 electron-rebuild 会帮我们确定 Electron 的版本号、Electron 内置的 Node.js 的版本号、以及 Node.js 使用的 ABI 的版本号,并根据这些版本号下载不同的头文件和类库。
npm install electron-rebuild -D
然后,在你的工程的 package.json 中增加如下配置节(scripts 配置节):
"rebuild": "electron-rebuild -f -w better-sqlite3"
接着,在工程根目录下执行如下指令:
npm run rebuild
当你的工程下出现了这个文件 node_modules\better-sqlite3\build\Release\better_sqlite3.node,才证明 better_sqlite3 模块编译成功了,如果上述指令没有帮你完成这项工作,你可以把指令配置到 node_modules\better-sqlite3 模块内部再执行一次,一般就可以编译成功了。
压缩安装包体积
better-sqlite3 不是一个原生模块吗,但这里我们仍然把它安装成了开发依赖,大家都知道原生模块是无法被 Vite 编译到 JavaScript 的,那我们为什么还要把它安装程开发依赖呢?
把 better-sqlite3 安装成生产依赖,在功能上没有任何问题,electron-builder 在制作安装包时,会自动为安装包附加这个依赖(better-sqlite3 这个库自己的依赖也会被正确附加到安装包内)。
但 electron-builder 会把很多无用的文件(很多编译原生模块时的中间产物)也附加到安装包内。无形中增加了安装包的体积(大概 10M)
//plugins\buildPlugin.ts
//import fs from "fs-extra";
async prepareSqlite() {
//拷贝better-sqlite3
let 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;
else if (src.includes("node_modules\\better-sqlite3\\lib")) return true;
else return 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);
}
引入 Knex.js
Knex.js 允许我们使用 JavaScript 代码来操作数据库里的数据和表结构,它会帮我们把 JavaScript 代码转义成具体的 SQL 语句,再把 SQL 语句交给数据库处理。我们可以把它理解为一种 SQL Builder。
npm install knex -D
打包之前编译这个库
//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);
}
确保开发过程中 node 版本一致
如果你的 Electron 应用使用到原生
Electron 中内置的 node 的版本和开发过程中本机环境
如果你在 Electron 工程内使用原生模块时,碰到如下错误:
Error: The module '/path/to/native/module.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`).
可通过 process.node.version 查看 electron 内置的 node 版本
如何调试 electron 应用?
业务代码解析
开发者可以通过如下命令全局安装 asar 工具(全局安装此工具是非常有必要的,以便你能随时分析生产环境下 Electron 应用的源码):
npm install asar -g
在 resources 子目录下找到 app.asar 文件,通过如下命令列出该文件内部包含的文件信息:
asar list app.asar
如果你希望一次性把 app.asar 内的文件全部释放出来,可以使用如下指令:
asar e app.asar
更多命令可参考@electron/asar
原文链接: https://jesse121.github.io/blog/articles/2022/11/16.html
版权声明: 转载请注明出处.