造你自己的桌面应用

Posted on Dec 28, 2024

Node

本来想用 jdx/mise 丝滑安装和管理 Node.js 版本,可似乎没搭配 npm,于是参考了 Install Node.js on Windows 推荐的 nvm-windows ,在 Windows 上安装长期支持的 Node 版本。

> nvm install 20
Downloading node.js version 20.18.1 (64-bit)...
Extracting node and npm...
Complete
npm v10.8.2 installed successfully.


Installation complete. If you want to use this version, type

nvm use 20.18.1
> nvm use 20.18.1
Now using node v20.18.1 (64-bit)
> npm --version
10.8.2

遇到问题直接 nvm debug 很有帮助。

Solution 1: Electron + Vue + daisyUI

在 Web 开发中,我们通常使用 HTML 和 CSS 以及 JavaScript 来开发网站前端,这些技术也可以用来开发桌面应用程序。例如 Electron 是最流行的跨平台桌面应用框架之一,Visual Studio Code、Postman、Discord 等知名桌面应用都在展示柜的前列。

方便了解核心,除了阅读官方文档,也看看大语言模型怎么说:

Electron 内嵌 Chromium(浏览器内核)与 Node.js(服务器端 JavaScript 运行时),提供两种进程:

  • 主进程(Main Process):管理窗口、系统菜单等桌面级功能。
    • 负责应用的生命周期管理和系统交互(如文件操作、窗口创建等)。
    • 执行的文件定义在 Electron 的 package.jsonmain 字段中(通常是 main.jsmain.ts)。
    • 可以使用 Node.js 的全部功能,比如文件读写、启动子进程等。
  • 渲染进程(Renderer Process):运行 Web 界面,类似浏览器页面。
    • 每个窗口都有自己的渲染进程,负责展示和管理用户界面。
    • 类似于网页中的浏览器环境,通过 Chromium 渲染 HTML/CSS/JS 内容。
    • 可以通过 preload 脚本安全地向渲染进程暴露一些 Node.js 功能。

两者之间可以通过 ipcMainipcRenderer 通信。

作为后端开发小子,面对琳琅满目的 Web 前端技术栈,结合本土市场情况,决定选择认知负荷较小的 Vue.js 来控制倒计时组件状态,并且安装最流行、免费、开源的 daisyUI 作为 Tailwind CSS 插件来美化界面。

生成样板代码

通过关键词 starter/scaffold/boilerplate/template 搜了一圈,发现 electron-vite-vue 看起来不错,Vite 常用于 Vue 项目的前端构建工具(frontend build tool),于是通过项目模板 electron-vite 创建项目。

npm create electron-vite countdown-desktop

生成的目录结构如下所示:

│   .gitignore
│   electron-builder.json5
│   index.html
│   package.json
│   README.md
│   tsconfig.json
│   tsconfig.node.json
│   vite.config.ts
├───.vscode
│       extensions.json
├───electron
│       electron-env.d.ts
│       main.ts
│       preload.ts
├───public
│       electron-vite.animate.svg
│       electron-vite.svg
│       vite.svg
└───src
    │   App.vue
    │   main.ts
    │   style.css
    │   vite-env.d.ts
    ├───assets
    │       vue.svg
    └───components
            HelloWorld.vue

其中 electron 目录下的 main.ts 是 Electron 主进程入口,preload.ts 是预加载脚本,src 目录下的 main.ts 是渲染进程入口,App.vue 是 Vue 根组件,HelloWorld.vue 是示例组件,index.html 是入口 HTML 文件;package.json 是项目配置文件,vite.config.ts 是 Vite 配置文件,tsconfig.json 是 TypeScript 配置文件。

npm install
npm run dev

上面的命令会安装依赖并运行示例,若要在当前平台构建桌面应用,则可直接运行 npm run build;在 package.json 中可以看到脚本命令的定义。

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build && electron-builder",
    "preview": "vite preview"
  }
}

构建产生的软件包可以在目录 release 下找到。

并非从零开始

站在巨人的肩膀上。

npm install vue@3 @chenfengyuan/vue-countdown@2
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
npm i -D daisyui@latest

感谢 vue-countdown 的作者隐藏了倒计时 Timing Events 的复杂性,也经过了 Vue 的入门,参考了 countdown 后,定制组件模板如下所示:

<template>
  <!-- Reference to the countdown component -->
  <vue-countdown v-if="isReady" ref="countdown" :auto-start="false" :time="time" @progress="onProgress" @end="onEnd">
    <div class="flex justify-center items-center flex-col h-screen">
      <!-- Countdown Display -->
      <div class="grid grid-flow-col gap-5 text-center auto-cols-max" v-show="state.running || state.paused">
        <!-- Div for Days, Hours, Minutes, Seconds -->
      </div>
      <!-- Input Fields -->
      <div class="flex mt-5 gap-1" v-show="!(state.running || state.paused)">
        <!-- Input Fields for Days, Hours, Minutes, Seconds -->
      </div>
      <!-- Control Buttons -->
      <div class="flex mt-5 gap-5">
        <button class="btn btn-wide" @click="onCancel" :disabled="state.ended">Cancel</button>
        <button class="btn btn-wide" @click="onSwitch">{{ state.switch }}</button>
      </div>
    </div>
  </vue-countdown>
</template>

同时在 script 中管理状态,包括初始化“槽位”的状态,UI 响应状态变化,以及监听事件的处理等。

import { computed, onMounted, reactive, ref, nextTick } from 'vue';

const countdown = ref();
const time = ref(1);
const isReady = ref(false);

const initSlot = () => {
  return {
    seconds: 0,
    minutes: 0,
    hours: 0,
    days: 0,
  }
}
const slot = reactive(initSlot());

const seconds = computed(() => {
  return { '--value': slot.seconds, }
});
// minutes, hours, days

function onProgress(data: any) {
  slot.seconds = data.seconds;
  // minutes, hours, days
}

enum Switch {
  Play = 'Play',
  Pause = 'Pause',
}
const initSate = () => {
  return {
    running: false,
    paused: false,
    ended: true,
    switch: Switch.Play
  }
}
// countdown state
const state = reactive(initSate());

let last = 0;
const daysInput = ref();
// hoursInput, minutesInput, secondsInput

function getTimeValue() {
  const days = daysInput.value.value;
  // hours, minutes, seconds
  return last === 0 ? (seconds * 1000 + minutes * 60 * 1000 + hours * 60 * 60 * 1000 + days * 24 * 60 * 60 * 1000) + 1000 : last;
}

async function onSwitch() {
  if (state.running) {
    countdown.value.abort();
    last = countdown.value.totalMilliseconds;

    state.running = false;
    state.paused = true;
    state.ended = false;
    state.switch = Switch.Play;
  } else {
    state.running = true;
    state.paused = false;
    state.ended = false;
    state.switch = Switch.Pause;
    // await for DOM updates to complete
    await nextTick();

    time.value = getTimeValue();
    countdown.value.restart();
  }
}

const NOTIFICATION_TITLE = 'countdown-desktop';
const NOTIFICATION_BODY = 'Your countdown has ended!'

function onCancel() {
  reset();
  countdown.value.end();
}

function reset() {
  Object.assign(state, initSate());
  Object.assign(slot, initSlot());
  last = 0;
}

function onEnd() {
  if (!state.ended) {
    reset();
  }
  // show notification
  new window.Notification(NOTIFICATION_TITLE, { body: NOTIFICATION_BODY });
}

onMounted(() => {
  isReady.value = true;
});

Debug 全靠 Chrome DevTools,只需要在 electron/main.ts 创建窗口时区分环境打开开发者工具。

if (VITE_DEV_SERVER_URL) {
    win.loadURL(VITE_DEV_SERVER_URL)
    // Open the DevTools
    win.webContents.openDevTools()
} else {
    win.loadFile(path.join(RENDERER_DIST, 'index.html'))
}

只想起一个关于托盘和计时器的问题:过一段时间打开托盘后倒计时应用中途停止了。在 GitHub 上搜索到了相似的问题 electron/issues/7079 ,人们说禁用后台页面中定时器任务的节流(throttle)可以修复类似的缺陷。

// Disable task throttling of timer tasks from background pages
win.webContents.setBackgroundThrottling(false)

最终效果和完整代码在 countdown-desktop 可以找到

Solution 2: Tauri + Vue + daisyUI

Electron 非常全面,除了未经足够优化的应用体积大、资源占用高,在某些场景下可能会成为用户体验的“痛点”。在学习 Rust 时很难不注意到 Tauri ,目标是创建轻量、快速、安全的跨平台应用程序。

AI 认为它的架构分成三部分:

  • Web 前端
    • 使用你熟悉的前端技术栈,比如 React、Vue、Svelte 或 Angular。前端代码被编译成静态文件(HTML、CSS、JavaScript),并嵌入到 Tauri 应用中,或者通过外部加载。
  • Tauri 核心
    • 使用 Rust 实现,负责处理应用生命周期管理、系统级功能和 IPC(进程间通信)。提供对操作系统功能的访问,例如文件系统、剪贴板、窗口控制等。
  • WebView
    • Tauri 应用运行时使用的 WebView 是操作系统本身自带的(比如 macOS 上的 WKWebView 和 Windows 的 WebView2)。

搭建一个新项目

npm create tauri-app@latest

为了方便“移植”,提示前端风味(flavor)选择 TypeScript,遇到 UI 模板还得是 Vue。

继续站在巨人的肩膀上

安装完上文的依赖包和拷贝前端文件之后,使用 Rust 在 src-tauri/src/main.rs 重写窗口和托盘的逻辑。

fn main() {
    let quit = CustomMenuItem::new("quit".to_string(), "Quit");
    let tray_menu = SystemTrayMenu::new().add_item(quit);

    tauri::Builder::default()
        .system_tray(tauri::SystemTray::new().with_menu(tray_menu))
        .on_window_event(|event| match event.event() {
            tauri::WindowEvent::CloseRequested { api, .. } => {
                let window = event.window();
                window.hide().unwrap();
                api.prevent_close();
            }
            _ => {}
        })
        .on_system_tray_event(|app, event| match event {
            SystemTrayEvent::LeftClick { .. } => {
                let window = app.get_window("main").unwrap();
                window.show().unwrap();
            }
            SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
                "quit" => {
                    std::process::exit(0);
                }
                _ => {}
            },
            _ => {}
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

看起来相对简洁,但是首次运行 npm run dev 还得等待编译 Rust 代码的漫长过程,不过运行 npm run tauri build 后的应用体积相较于 Electron 方案小得惊人,后者达到 168 MB,而前者只有 6.47 MB。但作为新的挑战者,在开发者工具、跨平台一致性、Web API 支持等方面还不完备。

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
    "tauri": "tauri"
  }
}

构建结果可以在目录 src-tauri/target/release 下找到,最终效果和完整代码已更新到 cddt

总结一下

  • 介绍了使用 Electron 和 Tauri 框架开发桌面应用的基本流程。
  • 以倒计时桌面应用为例,展示了如何使用 Vue.js 和 daisyUI 创建界面,以及如何使用 TypeScript 和 Rust 控制应用逻辑。
  • 分析了 Electron 和 Tauri 的优缺点,以及适用场景。

阅读更多