记录使用 electron 的一次经历

前言

记录使用 electron 的一次经历。

已经快一个月没有写帖子了,时间过的好快,转眼就又到年末了。

一号才从老家回到广州,歇了差不多一个月,这段时间也是用 electron + vue 开发了一个桌面软件。

项目 github 地址:jm-desktop

这篇帖子就记录下这一个月的过程。

正文

前期准备

首先,在编写之前,分析了接口加密以及图片编码的原理,这部分在下面两篇帖子中。

技术选型

Web 菜鸡,什么 Qt 、 winui 都不会,只能用 electron 来做套壳应用,这对我来说上手最快,并且可以快速实现需要的功能。

最开始的时候选择了 electron + react + ant-design ,写到一半发现 hold 不住 react ,写的太别扭了,果断换成了 electron + vue + ant-design-vue ,再写到一半发现 ant-design-vue 写出来像一个后台管理系统,并且社区维护不积极,果断转到 vuetify 上。

所以最后的技术栈就是 electron + vue ,组件库选择了 vuetify 。以及一些杂七杂八的库。

需求

对于一个第三方的漫画 APP ,核心的功能我觉得就两个,一个是搜索、一个是浏览,细分下来就是:

  • 搜索页
  • 漫画详情页
  • 漫画阅读页

其他都是甜头,比如什么首页,每周推荐,评论,甚至登录(除非需要登录才能查看)等等。

所以当时基本上是先实现这些核心的模块。

相关资源

项目模板

使用 electron + vue ,自己搭环境就太慢了(并不是我不会),直接使用下面这个模板。

项目地址: electron-vite-vue

electron-vite 这个组织开源了多个项目,包括和 vue 或者 react 集成的模板,以及开箱即用的 vite 插件,省去了自己搭建目录,对于我这个体量的项目完全够用了。

使用 electron-vite-vue 还有一个好处就是 electron 主进程的代码直接可以使用 TypeScript ,太棒了。

组件库

这里选用了 vuetify ,它是一个基于 material 的组件库,组件全面(不过 material 感觉和国内的审美水土不服,看多了国内的 app ,再看谷歌系的总感觉到了另一个次元)。

配合 unplugin-vue-components 插件完成自动导入,省略很多 import 语句 。

1
2
3
4
5
6
7
8
9
10
11
12
13
// vite.config.ts
import { Vuetify3Resolver } from 'unplugin-vue-components/resolvers'
import component from 'unplugin-vue-components/vite'

export default defineConfig({
// ...
plugins: [
// ...
component({
resolvers: [Vuetify3Resolver()],
}),
],
})

启动之后会生成 components.d.ts 文件,注意要将它加入到 TypeScript 中,这样可以得到模板中的类型提示。

1
2
3
4
5
6
7
8
9
10
11
12
// tsconfig.json
{
// ...
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"electron",
"auto-imports.d.ts",
"components.d.ts"
]
}

PS:之前在使用 ant-design-vue 的时候,发现了一个 bug ,但是提交到了 issue 上还没修(虽然我也不确定到底是 bug 还是特性),果然还是得有官方的团队的组件库才比较可信…

这里贴上 issue 地址:the args of notification.useNotification does not work 。

也不知道要多久才能处理这个 issue …

原子 css

这里使用了 unocss 来生成 css 类,主要是使用一些工具类,使用较多的是 flex 相关的。

首先要在 vite 中添加 unocss 插件。

1
2
3
4
5
6
7
8
9
10
// vite.config.ts
import unocss from 'unocss/vite'

export default defineConfig({
// ...
plugins: [
// ...
unocss(),
],
})

然后额外创建一个 uno.config.ts 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// uno.config.ts
import { defineConfig, presetWind } from 'unocss'

export default defineConfig({
presets: [presetWind()],
rules: [
[
'app-region-drag',
{
'-webkit-app-region': 'drag',
},
],
[
'app-region-nodrag',
{
'-webkit-app-region': 'no-drag',
},
],
],
})

这里我们配置了 tailwind 规则,以及多生成了两个类,主要是来快速处理头部部分是否能够拖动的情况。

最后在 main.ts 中导入 uno.css 即可

1
2
3
4
// src/main.ts
import 'virtual:uno.css'

// ...

接口请求

以往基本上都是用 axios 的,这次用了 alova ,它附带的功能更多,而且内置了 useRequest 、 usePagination 的组合式 api ,大大减少了很多样板代码,很棒,以及提供了接口缓存,这样子在某些接口上开启缓存,可以减少接口调用次数,切换页面的体验上升很多。

alova 和 axios 在某种情况下还是很像的,都是创建一个请求实例,用这个实例来发起 Get 、Post 等请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/apis/http.ts
import { xhrRequestAdapter } from '@alova/adapter-xhr'
import { createAlova } from 'alova'
import vueHook from 'alova/vue'

const http = createAlova({
statesHook: vueHook,
requestAdapter: xhrRequestAdapter(),
baseURL: '/api',
beforeRequest(method) {
// ...
},
responded: {
async onSuccess(response, method) {
// ...
},
},
})

这里 onSuccess 就和 axios 中的拦截器差不多, beforeRequest 在请求前添加一些全局的请求头,注意这里使用了 xhrRequestAdapter ,即基于 XMLHttpRequest 封装的适配器,由于 fetch 适配器不支持获取上传下载进度,而功能里面有下载漫画,所以需要使用 xhrRequestAdapter 。 vueHook 则让 alova 能够在 setup 中使用组合式的 api ,这里用的最多的就是 useRequest 和 usePagination ,特别是 usePagination 相当的方便,很多的列表页都只要一个 usePagination 就能拿到大部分的状态。

接着我们只需要编写相应的业务请求即可,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// src/apis/ajax.ts
export const loginApi = (username: string, password: string) => {
const formData = new FormData()
formData.append('username', username)
formData.append('password', password)
return http.Post<
RespWrapper<{
uid: number
username: string
email: string
avatar: string
jCoin: number
level: [number, string]
currentExp: number
nextLevelExp: number
collectCount: number
maxCollectCount: number
}>,
RespWrapper<{
uid: string
username: string
email: string
emailverified: string
photo: string
fname: string
gender: string
message: string
coin: number
album_favorites: number
s: string
level_name: string
level: number
nextLevelExp: number
exp: string
expPercent: number
badges: unknown[]
album_favorites_max: number
ad_free: boolean
ad_free_before: unknown
charge: string
jar: string
}>
>('login', formData, {
transform(res) {
return {
code: res.code,
data: {
uid: Number.parseInt(res.data.uid),
username: res.data.username,
email: res.data.email,
avatar: res.data.photo,
jCoin: res.data.coin,
level: [res.data.level, res.data.level_name],
currentExp: Number.parseInt(res.data.exp),
nextLevelExp: res.data.nextLevelExp,
collectCount: res.data.album_favorites,
maxCollectCount: res.data.album_favorites_max,
},
}
},
})
}

这里有两个注意的点

一个是封装的时候不要添加 async ,即 loginApi 不要加 async ,不然 setup 内传入 useRequest 和 usePagination 会提示类型错误,原因是 http.Post 返回的是一个 Method 类型,它是一个类 Promise 而不是 Promise ,如果我们直接调用 loginApi('username', 'password') 是不会发起请求的,只有 loginApi('username', 'password').then() 才会。

另一个是 http.Post 的两个泛型分别是转换后类型以及转换前类型,如果接口需要进行数据转化的话,填写这两个类型可以在 transform 内获得良好的类型提示,如果不需要转化则直接编写第一个泛型即可。

在 setup 中,使用 usePagination 可以获取绝大部分的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<script setup lang="ts">
import { usePagination } from 'alova/client'
import { getComicListApi } from '@/apis'

const {
total, // 总条数
page, // 当前页码
pageSize, // 每页大小
pageCount, // 有多少页
data, // 列表数据
send, // 发起请求函数
loading, // 加载状态
} = usePagination(
(page) =>
getComicListApi({
page,
content: formState.content,
order: formState.order,
}),
{
// 默认起始页和页大小
initialPage: 1,
initialPageSize: 80,
// 这里 data 和 total 就是返回 data 和 total 所拿到的值
data: (res) => res.data.content,
total: (res) => res.data.total,
// 这里可以设置额外的参数,这样 formState.order 变化时会直接重新发起请求
watchingStates: [() => formState.order],
},
)
</script>

主进程的 ioc

既然是自己写的项目,那就得玩玩一些平时用不上的东西。

所以这次我在项目里面使用了微软的依赖注入库 tsyringe 。

这是一个轻量的 IOC 库,用法也很简单,和 spring 很相似。

首先我们要在 tsconfig.json 内开启装饰器支持。

1
2
3
4
5
6
7
8
{
// ...
"compilerOptions": {
// ...
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

接着我们还要安装一个 reflect-metadata ,它是一个对 Reflect 的一个扩展,可以让对象存储元数据,在入口处直接导入即可。

1
2
3
4
// electron/main.ts
import 'reflect-metadata'

// ...

接着我们给模块加上 @singleton 装饰器

1
2
3
4
5
6
import { singleton } from 'tsyringe'

@singleton()
export class PathService {
// ...
}

这里使用 singleton 而不是 injectable 的原因是 injectable 每次注入都会生成一个新的对象,而 singleton 只会有一个对象。

要注意这样只是注册了这个类,如果我们直接运行容器内是不会有这个类的实例的,如果想要让类初始化实例,则需要调用另一个 API 。

1
2
3
4
5
6
// electron/main.ts
import { container } from 'tsyringe'
import { PathService } from './modules/path'

// 初始化
const instance = container.resolve(PathService)

所以整个 electron 侧的逻辑就是在 electron/modules 下编写模块,然后在 electron/main.ts 调用 win 模块来初始化整个应用,不过这里目前还有一些问题,就是某些模块并不会和 win 模块存在依赖关系,通常只会通过 ipcMain 来监听一些事件然后处理,这里用的一个方法是注入但不使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { inject, singleton } from 'tsyringe'
import { DbService } from '../db'

@singleton()
export class WinService {
//...

constructor(
// @ts-expect-error inject dbService
@inject(DbService) private dbService: DbService,
) {
// ...
}
}

这里要加这个注释,不然 TypeScript 会报错。

或许这里改成扫描 electron/modules 下的模块并且直接 container.resolve 会更好?🤔

better-sqlite3

在编写下载功能的时候,需要一个轻量的数据库,这里选用了 sqlite 。

这里提一嘴这个是可能在开发或者打包会报错,提示 node(或其他) 版本不正确问题。

这时需要使用执行 electron-builder install-app-deps 来讲依赖重新编译为当前环境的版本

可以在 package.json 添加 postinstall 来让 pnpm i 完成后触发

1
2
3
4
5
6
7
{
// ...
"scripts": {
// ...
"postinstall": "electron-builder install-app-deps"
}
}

快速引入字体

这里可以使用 Fontsource 。

这个仓库收录了开源可商用的字体,可以在此处搜索相应的字体。

导入也非常简单,安装对应的字体文件,然后导入即可。

1
pnpm install @fontsource/roboto

引入:

1
2
3
// src/main.ts

import '@fontsource/roboto'

然后在需要的地方使用即可。

1
2
3
body {
font-family: 'Roboto', sans-serif;
}

Express 代理接口

在生产环境下,应用会在主进程启动一个 Express 实例,来代理相关的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 核心代码
const app = Express()

app.use(
'/api',
createProxyMiddleware({
target: config.target,
changeOrigin: true,
secure: false,
pathRewrite: {
'^/api': '',
},
}),
)

由于接口在大陆并不总是能使用,所以我们需要为这个代理启用一个“梯子”

做法就是配置它的 agnet 属性。

先安装 https-proxy-agent ,表明我们想代理一个 https 域名(这里要根据需要选择 http-proxy-agent 或者 https-proxy-agent )。

使用很简单,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { HttpsProxyAgent } from "https-proxy-agent";

// 核心代码
const app = Express();

app.use(
"/api",
createProxyMiddleware({
target: config.target,
agent: new HttpsProxyAgent("http://localhost:10809")
changeOrigin: true,
secure: false,
pathRewrite: {
"^/api": "",
},
}),
);

这里的 http://localhost:10809 就是代理服务的域名,由于我们使用的是 v2rayN ,所以这里默认就是 http://localhost:10809 。

至于代理的这些信息,就通过渲染进程的用户填写后拿到。

渲染进程使用代理

除了 Express ,在渲染进程中我们需要访问第三方的图片,在网络不好的时候,我们希望使用代理进行加速,这里需要导入 electron 的 session ,然后使用 setProxy 这个方法。

1
2
3
4
5
6
7
8
9
10
11
import {
// ...
session,
} from 'electron'

session.defaultSession.setProxy({
// 默认就为 fixed_servers
// 需要设置 proxyRules 才能生效
mode: 'fixed_servers',
proxyRules: 'http://localhost:10809',
})

这样设置之后渲染进程的请求就会走这个代理。

缩放

electron 提供给了我们一个 win.webContents.setZoomFactor(zoomFactor) 来设置缩放因子

但是要注意,这个功能默认是关闭的,直接调用这个方法是没法改变的,这里需要先调用 win.webContents.setVisualZoomLevelLimits(1, 3) 后再调用 win.webContents.setZoomFactor(zoomFactor) 才能成功缩放,这里当时卡了很久…

1
2
3
4
// 必须先调用 setVisualZoomLevelLimits 解除缩放限制
win.webContents.setVisualZoomLevelLimits(1, 3).then(() => {
win.webContents.setZoomFactor(zoomFactor)
})

记录用户窗口位置

核心的 API 是 win.getBounds() 可以获取窗口当前的位置和大小,我们只需在退出前将信息保存在本地,然后下次启动前读取这个信息设置位置和大小即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 保存
const rect = win.getBounds()
writeToLocal(rect)

// 读取
const rect = getFromLocal('bounds')
const win = (this.win = new BrowserWindow({
// ...
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
}))

使用工作流

因为仓库是在 github 上开源的,所以可以使用工作流来帮我们打多个环境的包。

在本项目中,有两个工作流,一个是监听 tag 推送然后根据 tag 打包,一个是手动触发的最新打包。

监听 tag 只需要如下配置:

1
2
3
4
on:
push:
tags:
- 'v*.*.*' # 只在版本标签推送时触发

这里我们使用 npm version major|minor|patch 命令来更新版本,这个命令会生成一个提交,在这个提交上面标上一个 tag 。

接着我们 git push && git push origin v1.9.0 来把这个 tag 提交到远程就可以触发上面的工作流了。

手动触发的则需要使用如下配置

1
2
on:
workflow_dispatch:

这样在 Actions 页面就有一个按钮可以启动工作流了。

一般使用工作流的时候,基本上是在 linux 上跑的,但是 electron 的打包是需要在对应系统上打对应系统的包的,即 linux 只能打 linux 的包,而 windows 只能打 windows 的包,所以我们需要配置一下,让工作流跑两个环境,核心就是下面这个配置。

1
2
3
4
5
6
7
8
jobs:
build:
name: Build ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
matrix:
os: [ubuntu-latest, windows-latest]

这样子就会有两个任务在跑。

然后某些步骤如果需要指定只在某个环境跑,可以在步骤上用一个 if 语法,比如如果要缓存 pnpm 的包的话,在 window 上的路径和 linux 上的路径不一样,所以需要两个 step 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
jobs:
build:
name: Build ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
matrix:
os: [ubuntu-latest, windows-latest]

steps:
- name: Cache Linux pnpm
# 只在 linux 上执行这个步骤
if: matrix.os == 'ubuntu-latest'
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-

- name: Cache Windows pnpm
# 只在 windows 上执行这个步骤
if: matrix.os == 'windows-latest'
uses: actions/cache@v4
with:
path: D:\.pnpm-store
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-

最后,如果是基于 tag 的推送的,可以借助 electron-builder 自动提交到 release 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jobs:
build:
name: Build ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
matrix:
os: [ubuntu-latest, windows-latest]

steps:
# ...
- name: Build Electron App
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: pnpm run build

这里记得要注入 GH_TOKEN ,这样才能推送成功,pnpm run build 实际上运行了 vite build && electron-builder --publish always 这里自动推送就是 --publish always 起作用。

而如果是基于最新的打包,则需要指定 --publish 为 never ,然后借助工作流 Artifacts 暂存文件,将打包后的文件上传到这里即可,这里需要使用 actions/upload-artifact 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
jobs:
build:
name: Build ${{ matrix.os }}
runs-on: ${{ matrix.os }}

strategy:
matrix:
os: [ubuntu-latest, windows-latest]

steps:
# ...
- name: Upload Linux Artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
# 通过 github.sha 来区分文件
name: linux-release-assets-${{ github.sha }}
path: release/*/jm-desktop-Linux-*.zip

- name: Upload Windows Artifacts
if: matrix.os == 'windows-latest'
uses: actions/upload-artifact@v4
with:
# 通过 github.sha 来区分文件
name: windows-release-assets-${{ github.sha }}
path: release/*/jm-desktop-Windows-*.zip

后记

虽然启动慢了点,但是功能上还是相当完整的,自己还是非常开心能够完成这个项目。

虽然在不起眼的地方有很多 bug 。

在修了在修了,新建 fix 分支。