前言:为什么写这本书

这本书要帮你做成什么事

这本书只有一个目标:让你从零起步,在大约 3–6 个月内,成长为能胜任 Tauri 高级岗位的工程师。判断标准不是「看完了几章」,而是:

  • 你能独立设计并实现一个中型桌面应用的架构,包括前端、IPC、状态、持久化、打包、签名、自动更新。
  • 面试时被问到 Tauri 2.x 的 Capabilities、ACL、Isolation、自定义 URI Scheme、进程模型、WebView 差异,你能答得清清楚楚。
  • 面对真实项目里的坑——WebView 兼容、音视频权限、跨平台路径、签名证书、沙箱、性能瓶颈,你知道该从哪里入手排查。

为了把这件事做实,我们不是做玩具 demo,而是贯穿始终一个生产级项目:一款仿「网易云音乐」风格的跨平台桌面音乐播放器,我给它起名叫 CloudTone(云音)。

CloudTone 到底有多「生产级」

CloudTone 并不是一个「能响一下就算完事」的小播放器。它会包含下面这些真实项目里才会遇到的模块:

  • 音频引擎:基于 Rust symphonia 解码 + cpal 音频输出,支持 MP3/FLAC/AAC/OGG 等格式,支持 seek、淡入淡出、无缝切歌、ReplayGain。
  • 音乐库管理:递归扫描本地目录,读取 ID3/FLAC/Vorbis 元数据,抽取封面并入库。
  • 数据库:SQLite + sqlx,包含歌曲、专辑、艺人、歌单、播放历史、收藏、设置等七八张表,带数据库迁移。
  • 歌词引擎:LRC 解析、逐行/逐字高亮、滚动同步,并支持桌面歌词悬浮窗。
  • 搜索:本地全文搜索(FTS5)+ 在线音源搜索(可插拔 Provider)。
  • 播放队列:支持随机、循环、单曲循环、列表循环、心动模式、记忆上次播放位置。
  • 系统集成:全局媒体键、OS 原生媒体控制(macOS Now Playing、Windows SMTC、Linux MPRIS)、系统托盘、迷你播放器。
  • 下载管理:并发下载、断点续传、磁盘缓存策略。
  • 均衡器:10 段 EQ、预设保存。
  • 在线服务:抽象的 Provider 接口,可以接入第三方音源 API,也可以写插件扩展。
  • 插件系统:通过 JSON manifest + 动态权限申请,让第三方可以写扩展(这是很多公司音乐类产品的面试题)。
  • 基础设施:日志分级 + 滚动写入、错误上报、自动更新、多平台打包签名、CI/CD。

做完这个项目,你写过的代码量大概在 2–3 万行之间——和真实工作项目量级相当。

我假设你是谁

  • 你不会 HTML/CSS/JS,一点都不会。没关系,第 3、4、5、6 章就是专门为你准备的,够用不多讲。
  • 你对 Rust 比较熟练,所以我不会从 Ownership 从头讲起;但第 7 章会重点回顾 async、Send、Sync、Pin、trait object 等在 Tauri 开发中踩坑最多的地方。
  • 你已经安装了一台现代电脑,macOS / Windows / Linux 三系统里任意一个都能跑完本书全部代码。

关于版本

  • Tauri:2.x(v1 的 API、权限模型都变了很多,本书不兼顾 v1,但在关键处提醒差异)。
  • Rust:edition 2021,稳定版(1.77+)。
  • Node.js:20 LTS 起步。
  • React:18 / 19,使用函数组件 + Hooks。
  • 打包器:Vite 5 / 6。

截止 2026 年,这套组合是社区主流,也是招聘端最看重的。

如何读这本书

  • 不要只看。每章末尾会有「动手时刻」,必须跟着敲。Tauri 这类工程化极强的技术栈,看十遍不如自己跑一遍来得深刻。
  • 不要跳章。尤其是第 13 章 Capabilities、第 16 章并发、第 32 章 custom:// 协议,是初学者最爱跳过也最容易踩坑的地方。
  • 遇到报错先查「常见陷阱」。附录 A 的 FAQ 汇总了 CloudTone 开发过程中反复出现的问题。

翻页,我们开始。

学习路线图与如何使用本书

三个阶段,三种心态

这本书一共 50 章,按难度和交付节奏分为三段:

阶段章节心态最终产出
预备(Ch 1–7)环境、HTML/CSS/JS、React、Tailwind、Rust 回顾沉得住气能看懂、能写简单前端组件
Tauri 核心(Ch 8–20)Commands、Events、Capabilities、State、FS、DB把每个 API 跑一遍能搭建任何一个中小型 Tauri 工具
CloudTone 实战(Ch 21–47)从产品设计到上线、从音频引擎到自动更新工程师思维一个可上架的跨平台桌面音乐应用
迈向高级(Ch 48–50)源码、安全、面试看透本质能通过 Tauri 高级岗位面试

每章的结构

为了让你读得舒服也学得扎实,每一章尽量遵循下面的结构:

  1. 本章目标:3–5 句话,告诉你读完能做到什么。
  2. 背景/原理:讲清楚「为什么是这样」而不只是「怎么做」。
  3. 动手代码:完整、可运行的片段。关键代码会有中文注释。
  4. 常见陷阱:我和许多开发者踩过的坑,提前告诉你。
  5. CloudTone 本章增量(从第 21 章起):你这一章要往项目里加/改什么,用 diff 思维描述。
  6. 练习:自检题 + 可选拓展题。

你需要投入多少时间

  • 每天 1–2 小时:大约 4–6 个月读完,并跟着把项目写一遍。
  • 每天 3–4 小时:大约 2–3 个月读完。
  • 推荐节奏:读 1 章 + 动手实现;每 3 章回顾一次,每 10 章做一次总结笔记。

关于答案

本书没有「参考答案」章节——因为 CloudTone 的每一章都会把正确的代码呈现出来。你如果卡住了,跳到下一章前半段,往往就是你要的答案。这是我有意的安排:强迫你先想,再对照。

如果你急着上手

如果你已经有前端基础(哪怕是微信小程序开发),可以:

  • 跳过第 3–6 章(HTML/CSS/JS/React/Tailwind 速成)。
  • 从第 7 章 Rust 速览开始。
  • 如果 Rust 足够熟,跳到第 8 章,直接进入 Tauri。

但第 13 章(Capabilities) 不能跳——它是 Tauri 2.x 最大的变化,初学者 80% 的报错都来自这里。

我们开始吧

下一页是第 1 章。现在,先打开你的电脑,准备一杯咖啡或茶,关掉微信、关掉消息——开写。

第 1 章 Tauri 是什么,和 Electron 有什么区别

本章目标

  • 用一句话、一段话、一整章分别回答「什么是 Tauri」。
  • 说清 Tauri 与 Electron、NW.js、Wails、Neutralino、React Native for Desktop 的本质差异。
  • 理解 Tauri 「系统 WebView + Rust 后端」这个核心设计带来的好处和代价。

一句话、一段话

一句话:Tauri 是一个用 Rust 写后端、系统自带的 WebView 渲染前端的跨平台桌面应用开发框架。

一段话:Tauri 把桌面应用拆成两部分——一个原生二进制(Rust 编译出来的主程序,负责窗口、文件、系统 API、IPC),以及一个在系统 WebView 里运行的网页(用任何你喜欢的前端框架写 UI)。它用操作系统自带的 WebView(macOS 的 WKWebView、Windows 的 WebView2、Linux 的 WebKitGTK)取代 Electron 那样打包一整个 Chromium,从而把安装包体积从 150MB 砍到 5–10MB,把内存从 200MB 砍到 40–80MB。

为什么不直接用 Electron

Electron 把整个 Chromium + Node.js 打包进了你的应用。一个「Hello World」就是 150MB 起步,运行时占 200MB+ 内存。对于大公司的 IM、编辑器类应用,这个成本可以接受;但对越来越多的创业公司、小型桌面工具、以及面向低配机器的产品,这个代价就很重。

Tauri 的取舍:

  • 不打包 Chromium:复用操作系统自带的 WebView 组件。
  • 不打包 Node.js:后端用 Rust,编译成一个静态二进制。
  • 默认安全:所有前端能调用的系统 API 必须通过显式声明的 Capabilities。

代价是什么?

  • WebView 不一致:Windows 是 Chromium 内核(WebView2)、macOS 是 Safari 内核(WebKit)、Linux 是 WebKitGTK。你会遇到三个系统行为不一样的情况,比如 macOS 对某些 CSS 属性支持滞后。
  • 不能在前端跑 Node.js:没有 require('fs'),没有 npm 里那些依赖 Node runtime 的包。所有「原生」能力要走 Rust。

与其他方案的横向对比

下面这张表对几家主流跨平台桌面方案做了对比(2026 年初的生态情况):

方案后端前端渲染安装包大小内存占用成熟度适合谁
ElectronNode.js打包 Chromium80–200MB150–400MB★★★★★预算充足的大型应用
Tauri 2.xRust系统 WebView3–10MB40–100MB★★★★性能敏感、预算有限、追求安全
WailsGo系统 WebView5–15MB40–100MB★★★Go 工程师团队
NW.jsNode.js打包 Chromium80–200MB150–400MB★★★老项目,新项目少用
NeutralinoC++系统 WebView1–3MB20–60MB★★纯轻量小工具
Flutter DesktopDart自绘 Skia30–60MB60–150MB★★★已有 Flutter 团队
React Native for DesktopJS 桥接原生控件40–80MB80–200MB★★★Windows 10/11 原生风

结论:在 2026 年,如果你要做一款新的跨平台桌面应用,且对性能和安装体积敏感,Tauri 和 Flutter Desktop 是并列的两个主流选择。Tauri 胜在 Web 生态的易用,Flutter 胜在一次绘制跨平台一致。招聘端看,Tauri 岗位需求从 2024 年起增速最快。

Tauri 的三大核心技术点

贯穿本书的学习主线,实质上就是下面三件事:

  1. IPC(Inter-Process Communication):前端和 Rust 后端怎么通信?答案是 invoke(前→后调命令)和 emit/listen(事件总线)。第 11、12 章会讲透。
  2. Capabilities / ACL:前端不能为所欲为地调用 Rust——必须在 capabilities/*.json 里声明哪些窗口、哪些命令可以用。这是 Tauri 2.x 的核心安全机制。第 13 章专门讲。
  3. WebView Runtime:前端跑在系统 WebView 里,这意味着:
    • 没有 window.require、没有 Node API;
    • 可以 fetch 网络,但受 CSP 和权限双重约束;
    • 部分浏览器 API 在不同系统的 WebView 里表现不同。

一个最小的 Tauri 应用长什么样

代码暂且不用敲,先混个眼熟:

// src-tauri/src/main.rs
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}
// src/App.tsx
import { invoke } from '@tauri-apps/api/core';
import { useState } from 'react';

export default function App() {
  const [msg, setMsg] = useState('');
  return (
    <main>
      <button onClick={async () => setMsg(await invoke('greet', { name: 'Tauri' }))}>
        打招呼
      </button>
      <p>{msg}</p>
    </main>
  );
}

点一下按钮,JS 通过 invoke 跨进程调用 Rust 的 greet,后者返回字符串,前端拿到显示。这就是 Tauri 开发的基本节奏。

常见误解

误解 1:Tauri 只适合做小工具。

不对。1Password、Spacedrive、Cap、Warp Desktop 客户端、DeepL、Remotion 等都已在用 Tauri。

误解 2:因为用系统 WebView,兼容性会很惨。

WebView2(Windows)和 WKWebView(macOS)都是 Chromium/WebKit 的现代版本,支持 ES2022+。唯一需要注意的是 Linux 的 WebKitGTK 版本受发行版管理,老 Ubuntu 用户会遇到坑——这也是后面第 46 章会讲到的。

误解 3:Rust 很难,不适合入门。

对 Tauri 开发来说,你不需要精通 Rust 所有权。大部分业务代码是 async fn、serde 结构体、sqlx::query! 这一类「现代 Rust」,和你写 TypeScript 的体感非常接近。真正卡你的是并发和生命周期——所以第 16 章是重点。

本章小结

  • Tauri = Rust 主进程 + 系统 WebView + 显式权限。
  • 它不是 Electron 的「精简版」,而是一个重新设计的、安全优先、性能优先的框架。
  • 代价:WebView 不一致、前端不能用 Node 生态。
  • 收益:小、快、更安全。

动手时刻

现在还不用写代码,先花 10 分钟做一件事:

打开你现在用的 3–5 款桌面应用,用任务管理器看看它们各自占用多少内存。然后去它们官网或 GitHub 看看用的是 Electron、Tauri、原生还是别的。记下来。

这个练习会让你对「为什么要关心性能」有一个本能的体感,远比我讲十页有用。

下一章,我们真正动手——把 Rust、Node、Tauri CLI 一口气装好。

第 2 章 开发环境全套搭建

本章目标

  • 在 macOS / Windows / Linux 任意一个系统下装好 Rust、Node.js、Tauri CLI、系统 WebView 依赖。
  • 配置好 VS Code 的必备插件,让 Rust 和 TypeScript 写起来顺手。
  • 跑通 cargo tauri info,确认环境无误。

为什么环境是大坑

Tauri 的环境比单一语言项目复杂:你需要 Rust 工具链 + Node 工具链 + 系统原生依赖,三者任意一个不对就会卡在 cargo build 阶段半天。我在开始做本书的时候专门统计过社区 issue,「环境搭不起来」占了初学者提问的约 40%。把这一章做扎实,后面才能愉快地学。

一、Rust 工具链

安装 rustup

所有平台都用 rustup。官网:https://rustup.rs。

macOS / Linux:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Windows:下载 rustup-init.exe,双击运行,选 默认安装(MSVC 工具链)。

装完关掉当前终端重开一个,执行:

rustc --version
cargo --version

应该能看到 rustc 1.77.x 以上。

国内网络加速

访问 crates.io 可能慢。创建 ~/.cargo/config.toml(Windows 是 %USERPROFILE%\.cargo\config.toml),写入:

[source.crates-io]
replace-with = 'rsproxy-sparse'

[source.rsproxy]
registry = "https://rsproxy.cn/crates.io-index"

[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"

[registries.rsproxy]
index = "https://rsproxy.cn/crates.io-index"

[net]
git-fetch-with-cli = true

常用组件

rustup component add rust-src rust-analyzer clippy rustfmt
  • rust-analyzer:让 IDE 跳转/补全。
  • clippy:Rust 官方 lint。
  • rustfmt:格式化。

二、Node.js 工具链

安装 Node 20+

推荐用 fnm(跨平台)或 nvm。

fnm(推荐):

# macOS / Linux
curl -fsSL https://fnm.vercel.app/install | bash
fnm install 20
fnm use 20

# Windows (PowerShell)
winget install Schniz.fnm
fnm install 20
fnm use 20

检查:

node -v   # v20.x
npm -v    # 10.x

推荐使用 pnpm

本书示例使用 pnpm,速度快、磁盘占用少。

npm install -g pnpm
pnpm -v

国内 npm 镜像

pnpm config set registry https://registry.npmmirror.com

三、系统原生依赖

这是最容易被忽略的一步,但 Tauri 编译需要本地的 WebView 运行时和一些 C/C++ 库。

macOS

只需要 Xcode Command Line Tools:

xcode-select --install

WebView 是系统自带的 WKWebView,无需单独装。

Windows

必须装两个东西:

  1. Microsoft C++ Build Tools(在 Visual Studio Installer 里勾选「Desktop development with C++」,或者单独下载 Build Tools for Visual Studio)。
  2. WebView2 Runtime:Windows 11 默认自带。Windows 10 从 https://developer.microsoft.com/microsoft-edge/webview2/ 下载 Evergreen Bootstrapper 装好。

Linux(以 Ubuntu 24.04 为例)

sudo apt update
sudo apt install libwebkit2gtk-4.1-dev \
    build-essential \
    curl \
    wget \
    file \
    libxdo-dev \
    libssl-dev \
    libayatana-appindicator3-dev \
    librsvg2-dev

其他发行版(Fedora、Arch)请查 Tauri 官网 Prerequisites。

四、Tauri CLI

有两种装法:

方式 1:全局 cargo 安装(本书推荐)

cargo install create-tauri-app --locked
cargo install tauri-cli --version "^2.0" --locked

装完后:

cargo tauri --version
# tauri-cli 2.x.x

方式 2:项目级 npm 依赖

在每个 Tauri 项目里单独装:

pnpm add -D @tauri-apps/cli@^2

调用方式变成 pnpm tauri dev。

两种方式可以共存。

五、VS Code 与插件

Tauri 有前后端两套语言,所以 IDE 要能同时驾驭。VS Code 是社区主流。

必装插件:

  • rust-analyzer(rust-lang.rust-analyzer)
  • Tauri(tauri-apps.tauri-vscode):对 capability JSON 的 schema 支持
  • ESLint(dbaeumer.vscode-eslint)
  • Prettier(esbenp.prettier-vscode)
  • Tailwind CSS IntelliSense(bradlc.vscode-tailwindcss)
  • Error Lens(usernamehw.errorlens):把报错直接显示在行尾
  • Even Better TOML(tamasfe.even-better-toml)

推荐 settings.json(VS Code 的 UserSetting 或工程 .vscode/settings.json):

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[rust]": {
    "editor.defaultFormatter": "rust-lang.rust-analyzer"
  },
  "rust-analyzer.cargo.features": "all",
  "rust-analyzer.check.command": "clippy",
  "files.watcherExclude": {
    "**/target/**": true,
    "**/node_modules/**": true
  }
}

六、验证环境:运行 cargo tauri info

随便找个目录,执行:

cargo tauri info

你会看到类似:

[✔] Environment
    - OS: Mac OS 14.x ...
    - Xcode Command Line Tools: installed
    - rustc: 1.77.x
    - cargo: 1.77.x
    - rustup: 1.26.x

[-] Packages
    - tauri [RUST]: 2.0.x
    - tauri-build [RUST]: 2.0.x
    - @tauri-apps/api [NPM]: 2.0.x
    - @tauri-apps/cli [NPM]: 2.0.x

如果有任何 ✗,按提示处理。常见的:

  • Microsoft C++ Build Tools 未装 → 去 Visual Studio Installer 勾选。
  • libwebkit2gtk-4.1-dev 缺失 → apt install 一下。
  • WebView2 Runtime 未装 → 下载 Evergreen 安装。

七、第一个「能跑」的 Tauri 项目(预览)

下一章(第 3 章)之前,请先跑通这个动作,确认环境完全就绪:

cd ~/projects   # 或你喜欢的目录
pnpm create tauri-app@latest

交互式问答时选:

  • Project name:tauri-smoke-test
  • Identifier:com.example.smoke
  • Choose which language to use for your frontend:TypeScript / JavaScript
  • Choose your package manager:pnpm
  • Choose your UI template:React
  • Choose your UI flavor:TypeScript

然后:

cd tauri-smoke-test
pnpm install
pnpm tauri dev

第一次 cargo build 会花 3–10 分钟(取决于网络和 CPU)。结束后一个窗口弹出,里面显示 "Welcome to Tauri!"——你就完事了。

常见陷阱

1. 国内网络太慢,cargo build 一直卡在 Updating crates.io index。

检查 ~/.cargo/config.toml 的镜像配置,rustup show 确认默认工具链。

2. Windows 上 error: linker 'link.exe' not found。

没装 C++ Build Tools。去 Visual Studio Installer 勾选「使用 C++ 的桌面开发」工作负载。

3. Linux 上 error: failed to run custom build command for 'webkit2gtk-sys'。

Tauri 2.x 用的是 libwebkit2gtk-4.1,Ubuntu 20.04/22.04 默认只有 4.0。升级到 24.04 或手动装 4.1 包。

4. macOS M1/M2 下,cargo tauri dev 编译缓慢。

第一次编译慢是正常的。之后增量编译很快(2–5 秒)。如果每次都满编译,检查 target/ 是否被清理、是否用了 cargo clean。

本章小结

  • Rust + Node + 系统 WebView 依赖 + Tauri CLI = 环境完整。
  • VS Code 插件要装齐,开发体验差距巨大。
  • cargo tauri info 是你验证环境的银弹。

动手时刻

  • rustc --version 能输出 1.77+。
  • node -v 能输出 v20+。
  • cargo tauri info 所有项为 ✓。
  • 跑通 tauri-smoke-test 能看到欢迎窗口。

完成上面四项后进入第 3 章:零基础前端速成。

第 3 章 前端基石:HTML 与 CSS(从零开始)

本章假设你一点前端基础都没有。我们从"网页到底是什么"开始,一步一步走到能独立写出 CloudTone 的主界面。每一节都有可以直接复制跑起来的小例子,鼓励你边看边试。

零、先把地基打平:网页到底是什么?

先回答几个常常被跳过的问题。

问题 1:你在浏览器里看到的"网页"是什么? 本质上是三个文件的配合:

  • HTML 文件 → 决定页面上有什么东西(标题、按钮、图片、输入框……)。像盖房子时先立的骨架。
  • CSS 文件 → 决定这些东西长什么样(颜色、大小、位置、字体)。像给骨架刷漆、贴瓷砖。
  • JavaScript 文件 → 决定这些东西会做什么(点按钮弹窗、拖滑块变音量)。像给房子装电器和开关(下一章讲)。

问题 2:Tauri 和这些有什么关系? Tauri 把一个真正的浏览器引擎(WebView)塞进一个桌面窗口里。你写的 HTML + CSS + JS 就在这个窗口里运行,看起来像一个原生桌面软件。所以学前端 = 学怎么做 Tauri 应用的界面。

问题 3:我需要装什么才能开始? 什么都不用。你已经有浏览器了——Chrome、Edge、Safari 都行。接下来的例子,你只需要:

  1. 新建一个文件夹,比如 Desktop/hello-web/。
  2. 里面新建一个文件叫 index.html。
  3. 把本章每个示例的 HTML 代码粘进去。
  4. 双击 index.html,浏览器就会打开它。
  5. 改代码 → 保存 → 浏览器按 F5 刷新 → 看效果。

就这么简单。这个循环(写 → 存 → 刷 → 看)你会重复几千次,越快越舒服。

一、你的第一个 HTML 页面

把下面这段完整复制到 index.html 并双击打开:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <title>我的第一个网页</title>
  </head>
  <body>
    <h1>你好,世界</h1>
    <p>这是我用 HTML 写的第一个页面。</p>
  </body>
</html>

你应该看到一个大标题"你好,世界"和下面一行小字。

解剖一下这段代码。 HTML 由一堆"标签"组成。标签长这样:<标签名>内容</标签名>。带斜杠的那一半叫闭合标签。

  • <!doctype html>:第一行固定写,告诉浏览器"按现代标准来"。不用深究。
  • <html>...</html>:整个页面的最外层包裹。所有东西都在它里面。
  • <head>...</head>:看不见的信息(标题、编码、引入的样式表)。
  • <body>...</body>:看得见的页面内容(标题、段落、按钮……)。
  • <h1>:一级标题(Heading 1)。<h2>、<h3> 到 <h6> 依次变小。
  • <p>:段落(Paragraph)。

标签可以嵌套:<body> 里放 <h1>,<h1> 里放文字。嵌套关系就是所谓的"父子关系"——<body> 是 <h1> 的父元素。

动手试试 ①:把 <h1> 改成 <h2>,看字号变小;再加一行 <h3>小标题</h3> 和另一段 <p>,看浏览器的反应。

二、HTML 标签和属性

2.1 标签的"属性"

属性写在开始标签里,格式是 属性名="值":

<a href="https://tauri.app">打开 Tauri 官网</a>
<img src="cat.jpg" alt="一只猫" />
<input type="text" placeholder="请输入姓名" />
  • <a> 是链接(anchor),href 属性说明链到哪里。
  • <img> 是图片,src 是图片地址,alt 是"图片加载不出时显示的文字"(也给盲人屏幕阅读器用)。
  • <input> 是输入框,type 决定它是普通文本、密码、数字还是复选框。

自闭合标签:<img>、<input>、<br>(换行)、<hr>(横线)这些没有内容,写成 <img ... /> 就行,不用结束标签。

2.2 最常用的 15 个标签

背下这张表够你写 90% 的界面:

标签作用例子
<h1>~<h6>标题,从大到小<h1>欢迎</h1>
<p>段落<p>正文一段话</p>
<a>链接<a href="/about">关于</a>
<img>图片<img src="logo.png" alt="logo" />
<ul> + <li>无序列表(圆点)<ul><li>苹果</li><li>香蕉</li></ul>
<ol> + <li>有序列表(数字)同上换成 <ol>
<button>按钮<button>点我</button>
<input>输入框<input type="text" />
<label>输入框的说明<label>姓名 <input /></label>
<form>表单容器<form>...</form>
<div>万能容器(块级)<div>一堆东西</div>
<span>万能容器(行内)<span>一小段</span>
<br>换行文字<br>换行
<strong>重要文字(加粗)<strong>警告</strong>
<em>强调(斜体)<em>特别提醒</em>

<div> 和 <span> 特别重要:它们本身没有语义,就是纯粹的"盒子",用来把一堆东西包起来方便加样式。

  • <div> 是块级——默认独占一行,像一块砖。
  • <span> 是行内——像一个文字片段,同行能挤多个。

动手试试 ②:写一个小页面,包含一个标题、一段话、一个"我的爱好"有序列表(至少 3 项)、一张图(可以随便找网上的图片链接贴进 src)、一个按钮。

2.3 文档骨架的标准写法

后面 CloudTone 里完整的 index.html 其实长这样:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>CloudTone</title>
    <link rel="stylesheet" href="/src/styles/index.css" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

逐行理解:

  • <meta charset="utf-8" /> → 使用 UTF-8 编码,中文不会乱码的保险。
  • <meta name="viewport" ... /> → 移动设备上的缩放设置,Tauri 桌面端用不到但保留更稳妥。
  • <link rel="stylesheet" href="..." /> → 引入一个 CSS 文件。
  • <div id="root"></div> → 一个空盒子,React 启动后会把整个界面塞进去(第 5 章讲)。
  • <script type="module" src="..."></script> → 引入一个 JavaScript 文件。type="module" 表示用现代模块化方式加载。

现在你应该能看懂任何网页源码的开头了。

三、CSS:给 HTML 穿衣服

3.1 第一个 CSS 示例

把下面复制到 index.html,整体替换之前的内容:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <title>CSS 初体验</title>
    <style>
      h1 {
        color: white;
        background-color: #4f46e5;
        padding: 20px;
      }
      p {
        color: #555;
        font-size: 18px;
      }
    </style>
  </head>
  <body>
    <h1>你好,CSS</h1>
    <p>这段段落变成灰色了,字号也变大了。</p>
  </body>
</html>

保存刷新,你应该看到:一个紫色背景、白色字的大标题,下面是灰色变大的正文。

解剖 CSS 语法:

选择器 {
  属性: 值;
  属性: 值;
}
  • 选择器告诉浏览器"要给谁加样式"。h1 就是"页面上所有 <h1> 元素"。
  • 属性: 值告诉浏览器"加什么样式"。color: white 就是"文字颜色设为白色"。

3.2 CSS 写在哪里?

三种方式,由差到好排序:

  1. 行内样式(不推荐,只做临时调试):

    <h1 style="color: red;">红色标题</h1>
    
  2. 内部样式表(小页面调试 OK):把 <style>...</style> 放在 <head> 里,像上面那个例子。

  3. 外部样式表(正式项目都这么做):单独写一个 style.css,在 HTML 里引入:

    <link rel="stylesheet" href="style.css" />
    
    /* style.css */
    h1 { color: red; }
    

CloudTone 用的是第三种(配合后面章节的 Tailwind)。

3.3 常用选择器

这是 CSS 的核心技能——怎么精准地选中你想改的元素。

/* 1. 标签选择器:所有 <p> */
p { color: gray; }

/* 2. 类选择器:class="title" 的所有元素 */
.title { font-size: 24px; }

/* 3. ID 选择器:id="hero" 的那个元素(一个页面只应有一个同名 id) */
#hero { background: black; }

/* 4. 后代选择器:.card 里面的所有 .title */
.card .title { color: blue; }

/* 5. 子代选择器:.card 的直接子元素中的 .title */
.card > .title { color: blue; }

/* 6. 属性选择器 */
input[type="text"] { border: 1px solid gray; }

/* 7. 伪类:特定状态时生效 */
button:hover { background: yellow; }      /* 鼠标悬停 */
button:disabled { opacity: 0.5; }         /* 被禁用 */
input:focus { outline: 2px solid blue; }  /* 获得焦点 */

类(class)是最常用的。用法:

<p class="title">标题</p>
<p class="title important">重要标题</p>  <!-- 一个元素可以有多个类,空格分隔 -->
.title { font-size: 20px; }
.important { color: red; }

3.4 优先级:谁说了算?

如果多条规则都匹配到同一个元素,谁赢?简单版打分:

规则类型分数
行内 style="..."1000
ID 选择器 #hero100
类 .title / 属性 [type] / 伪类 :hover10
标签 p / 伪元素 ::before1

分高的赢;同分时后面写的赢。

日常 99% 情况你只用到类选择器,基本不会出冲突。万一碰上"我明明写了颜色但没生效",按 F12 打开浏览器开发者工具,点 Elements 面板,能看到哪条规则生效、哪条被划掉,一目了然。

动手试试 ③:写一个页面,有 2 个按钮。第一个加类 .primary,背景蓝色白字;第二个加类 .danger,背景红色白字。都加 :hover 时背景颜色变深。

四、盒模型:CSS 的核心概念

每一个 HTML 元素,在页面上都是一个"盒子"。每个盒子由四层组成(从里到外):

    ┌─────────── margin(外边距,和别人的距离)─────────┐
    │                                                      │
    │   ┌────── border(边框)──────────────────┐          │
    │   │                                        │          │
    │   │   ┌── padding(内边距,留白)─────┐    │          │
    │   │   │                                │    │          │
    │   │   │       content(内容)          │    │          │
    │   │   │                                │    │          │
    │   │   └────────────────────────────────┘    │          │
    │   │                                        │          │
    │   └────────────────────────────────────────┘          │
    │                                                      │
    └──────────────────────────────────────────────────────┘

CSS 里对应:

.box {
  width: 200px;      /* 内容宽度 */
  height: 100px;     /* 内容高度 */
  padding: 16px;     /* 四个方向的内边距 */
  border: 2px solid black;  /* 边框 */
  margin: 10px;      /* 四个方向的外边距 */
}

padding vs margin 的区别(这是初学者最困惑的):

  • padding 是盒子内部的留白——比如按钮里文字和边框之间的空隙。
  • margin 是盒子外部的距离——比如两个段落之间的空行。

4.1 必加的一条"保命"样式

默认情况下,width: 200px 指的是内容宽,实际占地 = width + padding + border。结果你设了 width: 200px; padding: 20px;,实测是 240px,很反直觉。

解法:几乎所有现代项目都在最开头写:

*, *::before, *::after { box-sizing: border-box; }

这行让 width 包含 padding 和 border。你设 200px 就是 200px,所见即所得。本章后面所有例子都假设你加了这一行。

4.2 四个方向单独设

.box {
  padding-top: 10px;
  padding-right: 20px;
  padding-bottom: 10px;
  padding-left: 20px;
  /* 等价于简写: */
  padding: 10px 20px 10px 20px;   /* 上 右 下 左 */
  padding: 10px 20px;              /* 上下10 左右20 */
  padding: 10px;                   /* 四边都10 */
}

margin 和 border 也是同样的简写规则。

动手试试 ④:画一个 200x200 的盒子,背景灰色,内边距 20px,边框 2px 红色实线,外边距 30px。盒子里放一段文字,观察文字离边框的距离。

五、尺寸、颜色、字体

5.1 长度单位

单位含义什么时候用
px像素(绝对)最常用,默认就对
%相对父元素例如 width: 50% 占父宽一半
rem相对根元素字号做整体缩放(默认 1rem = 16px)
em相对自己父元素字号排版里偶尔用,嵌套容易乱
vw / vh视口 1% 宽 / 1% 高做全屏效果,如 height: 100vh

初学阶段就记住 px 和 %,其他用到再说。

5.2 颜色

四种写法,效果一样:

color: red;                    /* 颜色名,常用的有 red/blue/white/black 等 */
color: #ff0000;                /* 十六进制:#RRGGBB */
color: #f00;                   /* 三位简写 */
color: rgb(255, 0, 0);         /* 红绿蓝 0-255 */
color: rgba(255, 0, 0, 0.5);   /* 最后是透明度 0-1 */

常用参考:

  • 白 #fff / 黑 #000 / 灰 #888
  • 主题蓝 #4f46e5 / 警告红 #ef4444 / 成功绿 #10b981

5.3 字体

body {
  font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
  font-size: 16px;
  font-weight: 400;       /* 400 普通 / 700 粗 / 900 超粗 */
  line-height: 1.5;       /* 行高,建议 1.4 ~ 1.7 */
  text-align: left;       /* left / center / right / justify */
}

font-family 可以写多个,用逗号隔开,前面的找不到就用后面的。最后一个一般写 sans-serif(无衬线通用字体)当兜底。

5.4 一个完整的排版示例

<style>
  * { box-sizing: border-box; }
  body {
    font-family: "PingFang SC", sans-serif;
    line-height: 1.6;
    background: #f5f5f7;
    color: #333;
    margin: 0;
    padding: 40px;
  }
  .card {
    background: white;
    max-width: 600px;
    margin: 0 auto;         /* 左右 auto 实现水平居中 */
    padding: 24px;
    border-radius: 12px;    /* 圆角 */
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);  /* 阴影 */
  }
  .card h2 { margin-top: 0; color: #1a1a1a; }
  .card p { color: #666; }
</style>

<div class="card">
  <h2>一个卡片</h2>
  <p>看,一点 CSS 就能让页面不像原始 HTML 那么丑。</p>
</div>

把这段替换到 index.html 的 <style> 和 <body> 部分,你会看到一个居中的白色圆角卡片。这就是现代网页 UI 的基本长相。

六、布局入门:文档流与 display

默认情况下,HTML 元素按从上到下(块级)或从左到右(行内)排列。这叫"文档流"。

  • 块级元素(<div>、<p>、<h1>……):独占一行,宽度撑满父元素。
  • 行内元素(<span>、<a>、<strong>……):跟文字一起排,一行能挤多个。

display 属性可以改变这个行为:

display: block;         /* 变块级 */
display: inline;        /* 变行内 */
display: inline-block;  /* 行内但能设宽高 */
display: flex;          /* 变成 Flex 容器(下一节讲) */
display: grid;          /* 变成 Grid 容器 */
display: none;          /* 完全隐藏,不占空间 */

初学者最常遇到:"我给 <span> 设 width: 200px 怎么没效果?"——因为 <span> 默认是 inline,不接受宽高。改成 inline-block 就行。

七、Flexbox:一维布局(最重要的一节)

90% 的网页布局问题用 Flex 就能解决。记住这幅图:

容器(display: flex)
┌────────────────────────────────────────────────┐
│  主轴方向 →                                     │
│  ┌─────┐  ┌─────┐  ┌─────┐                    │
│  │ A   │  │ B   │  │ C   │    ↕ 交叉轴        │
│  └─────┘  └─────┘  └─────┘                    │
└────────────────────────────────────────────────┘
       justify-content 控制主轴方向
       align-items 控制交叉轴方向

Flex 的核心就两件事:

  1. 给父元素加 display: flex,它就变成"Flex 容器"。
  2. 然后用几个属性控制子元素怎么排。

7.1 一个最小的 Flex 例子

<style>
  .row {
    display: flex;
    gap: 12px;                  /* 子元素之间的间距 */
    background: #eee;
    padding: 10px;
  }
  .row > div {
    background: #4f46e5;
    color: white;
    padding: 20px;
  }
</style>

<div class="row">
  <div>A</div>
  <div>B</div>
  <div>C</div>
</div>

三个 <div> 原本会各占一行,加了 display: flex 后变成同一行横排。

7.2 四个必须会的容器属性

.container {
  display: flex;

  /* 1. 方向:row 横排(默认)/ column 竖排 */
  flex-direction: row;

  /* 2. 主轴对齐(横排时就是"水平"对齐) */
  justify-content: flex-start;
    /* flex-start 左 | center 中 | flex-end 右 |
       space-between 两端对齐中间平分 |
       space-around 每项两侧均有间距 */

  /* 3. 交叉轴对齐(横排时就是"垂直"对齐) */
  align-items: stretch;
    /* stretch 拉伸撑满 | flex-start 顶 | center 中 | flex-end 底 */

  /* 4. 子元素之间的间距 */
  gap: 12px;
}

7.3 四个经典需求的标准答案

需求 1:水平 + 垂直居中(以前是经典面试题):

.center {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 400px;           /* 要有高度才能垂直居中 */
}
<div class="center"><div>我被居中了</div></div>

需求 2:顶部栏,左边标题右边按钮:

.topbar {
  display: flex;
  justify-content: space-between;   /* 两端对齐 */
  align-items: center;
  padding: 12px 20px;
}
<div class="topbar">
  <h1>CloudTone</h1>
  <button>登录</button>
</div>

需求 3:一行三列,中间自适应撑满:

.row { display: flex; align-items: center; gap: 8px; }
.row > .middle { flex: 1; }    /* 关键:flex: 1 表示占用所有剩余空间 */
<div class="row">
  <div>左侧固定</div>
  <div class="middle">中间自适应</div>
  <div>右侧固定</div>
</div>

flex: 1 是 Flex 里最常用的子项属性,含义:"剩余空间都给我"。多个子项都写 flex: 1 就平分。

需求 4:多行自动换行(标签云):

.tags {
  display: flex;
  flex-wrap: wrap;       /* 装不下时换行 */
  gap: 8px;
}

7.4 Flex 常见坑

  • 想垂直居中没成功?检查容器有没有高度——容器本身如果是内容高,align-items: center 看起来没反应。
  • 文字长了撑破布局?给这一列加 min-width: 0,否则 Flex 默认不让子项比它的内容更窄。
  • 按钮被拉变形?align-items 默认 stretch,改成 flex-start 或 center 即可。

动手试试 ⑤:写一个顶部导航栏,左边是 logo 文字,中间是三个菜单项(首页/发现/我的),右边是一个"登录"按钮。用 Flex 实现。

八、Grid:二维布局

Flex 只能管一维(要么一行要么一列)。当你要做二维网格(比如三栏布局、卡片墙),就用 Grid。

8.1 一个最小的 Grid 例子

<style>
  .grid {
    display: grid;
    grid-template-columns: 1fr 1fr 1fr;   /* 三等宽列 */
    gap: 10px;
  }
  .grid > div {
    background: #4f46e5;
    color: white;
    padding: 20px;
    text-align: center;
  }
</style>

<div class="grid">
  <div>1</div><div>2</div><div>3</div>
  <div>4</div><div>5</div><div>6</div>
</div>

6 个 div 自动变成 2 行 3 列。

fr 是什么? Fraction(分数)的缩写,表示"剩余空间的份数"。1fr 2fr 1fr 意思是三列宽度比 1:2:1。

8.2 常用的三个模板写法

模板 1:固定骨架(左 240 + 自适应中间 + 右 320):

.layout {
  display: grid;
  grid-template-columns: 240px 1fr 320px;
  height: 100vh;     /* 占满整个视口高度 */
}

这是 CloudTone 主界面的基础结构。

模板 2:自适应卡片墙:

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 16px;
}

一行话理解:每列最少 180px,能放几列放几列,剩余空间平分。窗口变宽,自动加列;窗口变窄,自动换行。非常实用。

模板 3:带名字的区域(适合复杂布局):

.app {
  display: grid;
  grid-template-columns: 240px 1fr;
  grid-template-rows: 60px 1fr 80px;
  grid-template-areas:
    "sidebar header"
    "sidebar main"
    "player  player";
  height: 100vh;
}
.sidebar { grid-area: sidebar; }
.header  { grid-area: header; }
.main    { grid-area: main; }
.player  { grid-area: player; }

用 grid-template-areas 画出整个布局的"平面图",再把每个元素 grid-area 指到对应格子。可读性很强。

8.3 Grid 和 Flex 什么时候用哪个?

  • 一维、内容驱动 → Flex(导航栏、按钮组、小列表)。
  • 二维、整体骨架 → Grid(整页布局、卡片墙)。
  • 实际项目通常嵌套:大骨架用 Grid,每个区块内部再用 Flex。

动手试试 ⑥:写一个个人主页骨架:顶部 60px 是页眉,左边 200px 是导航,右边剩余空间是内容区。用 Grid 实现。

九、Position:自由定位与层叠

position 属性控制元素是否脱离文档流。

position: static;    /* 默认,按文档流排 */
position: relative;  /* 相对自己原位偏移,但仍然占原位 */
position: absolute;  /* 脱离文档流,相对最近的"有定位"的祖先 */
position: fixed;     /* 固定在屏幕上,滚动也不动 */
position: sticky;    /* 滚到一定位置后吸住 */

9.1 最常见用法:右上角的关闭按钮

<style>
  .dialog {
    position: relative;       /* 让自己成为"定位参照物" */
    width: 300px;
    padding: 20px;
    background: white;
    border: 1px solid #ddd;
  }
  .close {
    position: absolute;       /* 相对 .dialog 定位 */
    top: 8px;
    right: 8px;
  }
</style>

<div class="dialog">
  <button class="close">×</button>
  <p>这是一个对话框</p>
</div>

关键规则:想让子元素 position: absolute 相对某个父元素定位,父元素必须有 position: relative(或其他非 static 值)。

9.2 z-index:谁盖在谁上面

.overlay { position: fixed; z-index: 100; }
.modal { position: fixed; z-index: 200; }   /* modal 在 overlay 上层 */

z-index 只对非 static 定位的元素有效。数字大的在上面。

十、过渡与动画

鼠标悬停时让按钮颜色渐变,而不是瞬间跳变——这叫"过渡"(transition)。

.btn {
  background: #4f46e5;
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 6px;
  transition: background 200ms ease, transform 200ms ease;
  /* 对这两个属性的变化,持续 200ms,缓动曲线 ease */
}
.btn:hover {
  background: #6366f1;
  transform: scale(1.05);     /* 鼠标悬停时放大 5% */
}

transform 能做放大、旋转、平移,且不会引起重新布局,性能最好:

transform: scale(1.2);              /* 放大到 1.2 倍 */
transform: rotate(45deg);           /* 旋转 45 度 */
transform: translateX(10px);        /* 水平移动 */
transform: scale(1.05) rotate(3deg);/* 组合多个 */

循环动画用 @keyframes:

@keyframes spin {
  from { transform: rotate(0); }
  to   { transform: rotate(360deg); }
}
.loading-icon {
  animation: spin 1s linear infinite;
}

infinite 表示无限循环,这就是加载转圈圈。

十一、响应式:适配不同宽度

窗口宽度不同,布局也应该变。用 @media:

.sidebar { width: 240px; }

@media (max-width: 1024px) {         /* 屏幕宽 ≤ 1024 时 */
  .sidebar { width: 56px; }          /* 侧栏折叠成图标条 */
}

@media (max-width: 768px) {          /* 更窄时 */
  .sidebar { display: none; }        /* 直接隐藏 */
}

跟随系统暗色主题:

@media (prefers-color-scheme: dark) {
  body { background: #1a1a1a; color: #eee; }
}

十二、CSS 变量:一处改,处处变

:root {
  --brand: #4f46e5;
  --bg: #f5f5f7;
  --radius: 8px;
}

.btn {
  background: var(--brand);
  border-radius: var(--radius);
}
.card {
  background: var(--bg);
  border-radius: var(--radius);
}

--xxx 就是自定义变量,var(--xxx) 取值。改 :root 里一个值,所有用到的地方都跟着变。这是现代主题系统的基础。

切换主题:

:root { --bg: #fff; --fg: #000; }
.theme-dark { --bg: #1a1a1a; --fg: #eee; }
document.body.classList.toggle("theme-dark");

十三、实战:CloudTone 主界面骨架

把前面学的东西组合起来,这就是 CloudTone 的界面骨架:

<style>
  * { box-sizing: border-box; }
  body { margin: 0; font-family: "PingFang SC", sans-serif; }

  :root {
    --bg: #0f0f14;
    --fg: #f2f2f7;
    --panel: #1a1a22;
    --accent: #4f46e5;
  }

  .shell {
    display: grid;
    height: 100vh;
    grid-template-columns: 240px 1fr 320px;
    grid-template-rows: 40px 1fr 80px;
    grid-template-areas:
      "sidebar titlebar titlebar"
      "sidebar content  panel"
      "player  player   player";
    background: var(--bg);
    color: var(--fg);
  }

  .sidebar  { grid-area: sidebar;  background: var(--panel); padding: 16px; overflow-y: auto; }
  .titlebar { grid-area: titlebar; border-bottom: 1px solid #ffffff10; display: flex; align-items: center; padding: 0 16px; }
  .content  { grid-area: content;  padding: 24px; overflow-y: auto; }
  .panel    { grid-area: panel;    background: var(--panel); padding: 16px; overflow-y: auto; }
  .player   { grid-area: player;   background: var(--panel); border-top: 1px solid #ffffff10; display: flex; align-items: center; padding: 0 16px; gap: 16px; }

  /* 窄屏:侧栏变图标条,右侧面板隐藏 */
  @media (max-width: 1024px) {
    .shell {
      grid-template-columns: 56px 1fr 0;
      grid-template-areas:
        "sidebar titlebar titlebar"
        "sidebar content  content"
        "player  player   player";
    }
    .panel { display: none; }
  }
</style>

<div class="shell">
  <aside class="sidebar">导航</aside>
  <header class="titlebar">CloudTone</header>
  <main class="content">主内容区</main>
  <aside class="panel">右侧面板</aside>
  <footer class="player">播放器</footer>
</div>

把它存成 index.html 打开,拖拽窗口大小观察变化。你已经写出了 CloudTone 主界面的骨架。

十四、调试 CSS 的方法论

当样式不符合预期,打开浏览器开发者工具(F12 或右键 → 检查):

  1. Elements 面板:点任一元素,右边显示它命中的所有 CSS 规则。被划掉的就是被覆盖了。
  2. Computed 面板:显示最终生效的值,不管来自哪条规则。
  3. Box Model 图:显示这个元素真实的 margin/border/padding/content 尺寸。
  4. 临时加样式:可以直接在右边面板改数字看效果,不用回编辑器。

排查技巧:

  • 拿不准元素边界 → 加 outline: 1px solid red(不占空间,不影响布局)。
  • 层级不对 → 检查 position、z-index、有没有父元素 transform 创建新层叠上下文。
  • Flex 子项被挤压 → 加 flex-shrink: 0 或 min-width: 0。

十五、语义化与可访问性(简介)

写"差不多能看"的页面用 <div> 就够。但正式项目建议:

  • 顶部用 <header>,导航用 <nav>,主要内容用 <main>,侧栏 <aside>,底部 <footer>。
  • 按钮一定用 <button>,不要用"可点击的 <div>"——前者键盘 Tab 能聚焦、Enter/Space 能触发,后者什么都没有。
  • 图标按钮加 aria-label:<button aria-label="播放">▶</button>。
  • 图片写 alt:<img src="cover.jpg" alt="专辑封面:起风了" />,装饰图写空的 alt=""。

这些是给屏幕阅读器用户和搜索引擎看的,不影响视觉效果但极大提升体验和可维护性。

十六、常见陷阱速查

  • width: 100% + padding 会超出父宽 → 一定要加 box-sizing: border-box。
  • 垂直居中失败 → 父容器没高度,或者忘了 align-items: center。
  • position: absolute 定位不对 → 父元素没 position: relative。
  • z-index 无效 → 那个元素必须是 position 非 static。
  • margin 上下不叠加 → 叫"margin 折叠",相邻垂直 margin 取较大值,是正常行为;用 padding 或 Flex 容器可避免。
  • 滚动条一直不出现 → 容器必须有固定高度,overflow: auto 才会生效。

本章小结

  • HTML = 页面结构(标签 + 嵌套 + 属性)。
  • CSS = 页面外观(选择器 + 盒模型 + 布局 + 主题)。
  • 布局三把斧:文档流处理简单情况、Flex 解决一维、Grid 解决二维。
  • 调试靠开发者工具 Elements 面板。

建议练习:把本章每个"动手试试"都亲手敲一遍,再把第十三节的 CloudTone 骨架抄一遍。抄完你就有了独立实现任何 UI 界面的能力。

下一章进入 JavaScript——让页面真正"动起来"。

第 4 章 JavaScript 与 TypeScript(从零开始)

本章继续假设你没基础。HTML 给了页面骨架,CSS 给了外观,这一章的 JavaScript 负责让页面会响应——点击按钮弹窗、输入搜索词过滤结果、拖进度条改音量。没有 JS 的网页就是一张贴纸。本章末尾会引入 TypeScript,它只是"给 JS 加上类型检查",避免一类粗心错误。

零、先问几个问题

JavaScript 在哪里运行?

  • 浏览器里(包括 Tauri 的 WebView 窗口)——操作页面、发网络请求。
  • Node.js 里(服务器或命令行工具)——读写文件、启 HTTP 服务。

本书只关心浏览器里的 JS(Tauri 前端就是这个场景)。

怎么最快试一段 JS? 打开任意浏览器,按 F12 打开开发者工具,切到 Console(控制台) 标签。里面就能直接输入一行 JS 按回车执行:

console.log("Hello");       // 控制台打印 "Hello"
1 + 2                        // 回显 3
Math.random()                // 回显一个 0~1 的随机数

或者在 HTML 里这样写,一行代码验证:

<!doctype html>
<html>
  <body>
    <h1 id="title">Hello</h1>
    <button id="btn">点我</button>
    <script>
      document.getElementById("btn").addEventListener("click", () => {
        document.getElementById("title").textContent = "被点到了!";
      });
    </script>
  </body>
</html>

保存,双击打开。点按钮,标题就变了。这就是 JS 做的事。

一、变量:给值起名字

const name = "CloudTone";   // 不会改变的用 const
let count = 0;              // 可能变化的用 let
count = count + 1;          // 可以重新赋值

// const name = "别的";     // 报错:const 不能改
  • const = constant(常量),不能再次赋值。默认都用它。
  • let = 可以再次赋值。确实需要变化时才用。
  • 别用 var,是老语法,有各种坑。

命名规则:用英文字母、数字、下划线、$,但不能以数字开头。习惯用 小驼峰 风格:userName、playCount、isLoading。

二、基本数据类型

JS 里你会频繁用到这 5 种:

const age = 25;                 // 数字(number)
const title = "起风了";         // 字符串(string),单双引号都行
const isPlaying = true;         // 布尔(boolean),只有 true / false
const nothing = null;           // "明确的空"
let u;                          // undefined,"还没赋值"

字符串的模板语法(比加号拼接好用 10 倍):

const name = "小明";
const age = 18;

// 老写法
const msg1 = "我叫" + name + ",今年" + age + "岁";

// 模板字符串(用反引号 ` 而不是引号)
const msg2 = `我叫${name},今年${age}岁`;

反引号里 ${} 中可以放任何表达式:

const price = 99;
const text = `总价:${price * 1.1} 元`;

三、运算符

// 算术
1 + 2   // 3
10 / 3  // 3.333...
10 % 3  // 1   取余数
2 ** 8  // 256 幂

// 比较(返回 true / false)
1 === 1    // true  严格相等,永远用这个
1 === "1"  // false 类型不同
1 !== 2    // true
3 > 2      // true
3 >= 3     // true

// 逻辑
true && false   // false  "和",都真才真
true || false   // true   "或",一个真就真
!true           // false  "非"

// 字符串拼接
"a" + "b"       // "ab"

关键规则:永远用 === 和 !==,不要用 == 和 !=。后者会做奇怪的类型转换,比如 0 == "" 竟然是 true。

四、条件判断

const age = 18;

if (age >= 18) {
  console.log("成年");
} else if (age >= 12) {
  console.log("青少年");
} else {
  console.log("儿童");
}

三元运算符(if-else 的简写,在 React 里用得非常多):

const status = age >= 18 ? "成年" : "未成年";
// 等价于:if (age >= 18) status = "成年"; else status = "未成年";

短路语法(常见简写):

const name = userName || "匿名";          // userName 为空就用 "匿名"
const len = arr?.length ?? 0;             // arr 可能不存在时取 length,没有就 0
  • a || b:a 是 "假值"(false/0/""/null/undefined)时取 b。
  • a ?? b:a 是 null 或 undefined 时取 b,空字符串和 0 不算。
  • obj?.field:obj 是 null/undefined 时直接返回 undefined,不报错。

五、数组:有序列表

const fruits = ["苹果", "香蕉", "橘子"];

fruits[0];          // "苹果",下标从 0 开始
fruits.length;      // 3
fruits.push("梨");   // 末尾追加
fruits.pop();        // 弹出末尾
fruits.includes("苹果");  // true

最常用的三个数组方法(React 里天天用):

const nums = [1, 2, 3, 4];

// map:每个元素做一次变换,得到新数组
const doubled = nums.map(n => n * 2);         // [2, 4, 6, 8]

// filter:只保留满足条件的元素
const evens = nums.filter(n => n % 2 === 0);  // [2, 4]

// find:找第一个满足条件的
const first = nums.find(n => n > 2);          // 3

n => n * 2 是"箭头函数"(下一节讲),先知道它就是个函数。

遍历数组:

for (const fruit of fruits) {
  console.log(fruit);
}

// 或者 forEach
fruits.forEach(fruit => console.log(fruit));

六、对象:带标签的数据

对象是"一堆键值对":

const song = {
  id: 1,
  title: "起风了",
  artist: "买辣椒也用券",
  duration: 321,
  isLiked: true,
};

song.title;       // "起风了"
song.duration;    // 321

song.title = "新标题";   // 修改
song.rating = 5;          // 添加新字段

对象可以嵌套:

const user = {
  name: "小明",
  address: {
    city: "北京",
    zip: "100000",
  },
};
user.address.city;    // "北京"

对象数组(最常见的数据结构):

const songs = [
  { id: 1, title: "歌曲 A", artist: "歌手甲" },
  { id: 2, title: "歌曲 B", artist: "歌手乙" },
  { id: 3, title: "歌曲 C", artist: "歌手丙" },
];

// 找 ID 为 2 的歌
const song = songs.find(s => s.id === 2);

// 列出所有标题
const titles = songs.map(s => s.title);   // ["歌曲 A", "歌曲 B", "歌曲 C"]

// 过滤出 "歌手甲" 的歌
const filtered = songs.filter(s => s.artist === "歌手甲");

这三个操作(map、filter、find)将来你会在 React 里每天用几十次。

6.1 解构:快速提取字段

const song = { id: 1, title: "起风了", artist: "买辣椒" };

// 老写法
const id = song.id;
const title = song.title;

// 解构(推荐)
const { id, title } = song;
const { artist: singer } = song;   // 顺便重命名

数组解构:

const [first, second] = [10, 20, 30];  // first=10, second=20

6.2 展开运算符 ...

const a = [1, 2, 3];
const b = [...a, 4, 5];              // [1, 2, 3, 4, 5]

const song1 = { id: 1, title: "A" };
const song2 = { ...song1, title: "B" };  // 复制 + 覆盖 title
// song2 = { id: 1, title: "B" }

展开运算符在 React 里做"不可变更新"必备。

七、函数:可复用的代码块

三种写法,功能几乎一样:

// 1. 函数声明
function add(a, b) {
  return a + b;
}

// 2. 函数表达式
const add2 = function(a, b) {
  return a + b;
};

// 3. 箭头函数(推荐,最简洁)
const add3 = (a, b) => a + b;

// 如果函数体多行:
const add4 = (a, b) => {
  const sum = a + b;
  return sum;
};

调用方式相同:

add(3, 4);    // 7
add3(3, 4);   // 7

默认参数:

function greet(name = "朋友") {
  return `你好,${name}`;
}
greet();          // "你好,朋友"
greet("小明");    // "你好,小明"

函数是一等公民:函数可以存进变量,也可以当参数传给别的函数。这就是为什么 arr.map(n => n * 2) 能工作——map 接收一个函数参数。

function applyTwice(fn, x) {
  return fn(fn(x));
}
applyTwice(n => n + 1, 5);   // 7,因为 (5+1)+1

八、DOM 操作:让 JS 控制 HTML

DOM 是浏览器给 JS 的"操作界面"——每个 HTML 元素在 JS 里对应一个对象,改这个对象,页面就变。

<h1 id="title">原文字</h1>
<button id="btn">点我</button>
<ul id="list"></ul>

<script>
  // 找元素
  const title = document.getElementById("title");
  const btn = document.getElementById("btn");
  const list = document.getElementById("list");

  // 改内容
  title.textContent = "新文字";

  // 改样式
  title.style.color = "red";
  title.style.fontSize = "30px";

  // 加类
  title.classList.add("active");
  title.classList.remove("active");
  title.classList.toggle("active");

  // 监听点击
  btn.addEventListener("click", () => {
    // 动态生成一个 <li> 加进列表
    const li = document.createElement("li");
    li.textContent = "新的一项 " + Math.random();
    list.appendChild(li);
  });
</script>

把上面整段存成 index.html 打开,每次点按钮,列表里就多一项。

这就是"原生 JS 做前端"的全貌。但你会发现:

  • 要精确找到每个元素(getElementById)。
  • 每次数据变了都要手动改 DOM。
  • 数据多了代码很乱。

这正是 React 要解决的问题(下一章)。

8.1 其他常用选择方法

document.querySelector("#title");        // 用 CSS 选择器找第一个
document.querySelector(".card");         // 找第一个 class="card" 的
document.querySelectorAll(".card");      // 找所有,返回数组样的东西

九、事件:响应用户操作

btn.addEventListener("click", (e) => {
  console.log("被点到了");
  console.log("鼠标坐标:", e.clientX, e.clientY);
});

input.addEventListener("input", (e) => {
  console.log("当前输入:", e.target.value);
});

document.addEventListener("keydown", (e) => {
  if (e.key === "Escape") console.log("按了 Esc");
});

常用事件名:click、input、change、submit、keydown、mouseenter、mouseleave、focus、blur。

十、异步:处理"需要等待的事情"

问题:从服务器拉数据、读文件、等几秒、动画……这些操作不会立刻完成。JS 是单线程的,不能傻等,否则页面就卡死了。

解法:回调 → Promise → async/await。现代 JS 基本只用后两种。

10.1 先看一个卡住的例子(反例)

console.log("A");
for (let i = 0; i < 1000000000; i++) {} // 跑一秒多
console.log("B");
// 输出:A(卡一下)B
// 这期间页面完全动不了

正确的"等一下"用 setTimeout:

console.log("A");
setTimeout(() => {
  console.log("B");
}, 1000);                   // 1000 毫秒 = 1 秒后执行
console.log("C");
// 输出:A → C → (1秒后) B

注意顺序:setTimeout 不会阻塞,它说"1 秒后再跑我给你的函数",JS 继续往下走,所以 C 在 B 之前。

10.2 Promise:描述"将来会有的结果"

想象你点外卖:下单那一刻外卖还没到,但你知道将来会到(或失败)。这就是 Promise。

// 一个 3 秒后才给出结果的 Promise
const orderFood = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = Math.random() > 0.2;
    if (success) {
      resolve("外卖到了 🍜");     // 成功:调 resolve
    } else {
      reject("商家取消订单");     // 失败:调 reject
    }
  }, 3000);
});

// 用法
orderFood
  .then(result => console.log("✓", result))
  .catch(err => console.log("✗", err));

console.log("下单完成,继续干别的");

.then(f) 是"成功时跑 f",.catch(f) 是"失败时跑 f"。

真实世界的例子:fetch 就是返回 Promise 的网络请求。

fetch("https://api.github.com/users/torvalds")
  .then(response => response.json())
  .then(data => console.log(data.name))
  .catch(err => console.error(err));

10.3 async / await:让异步代码像同步一样读

上面的写法链式调用多了会嵌套得很丑。async/await 是糖衣,让代码从上到下读:

async function loadUser() {
  try {
    const response = await fetch("https://api.github.com/users/torvalds");
    const data = await response.json();
    console.log(data.name);
  } catch (err) {
    console.error("出错了:", err);
  }
}

loadUser();

三点必记:

  1. 用 async 标记的函数总是返回 Promise。
  2. await 只能在 async 函数里用。
  3. await X 会"暂停这个函数"直到 X 出结果。但不阻塞整个页面。

并发多个异步:

// ❌ 一个一个等(慢)
const songs = await fetchSongs();
const artists = await fetchArtists();

// ✓ 同时发,一起等(快)
const [songs, artists] = await Promise.all([fetchSongs(), fetchArtists()]);

10.4 一个完整的小例子

<input id="q" placeholder="输入 GitHub 用户名" />
<button id="go">搜</button>
<div id="result"></div>

<script>
  const q = document.getElementById("q");
  const go = document.getElementById("go");
  const result = document.getElementById("result");

  go.addEventListener("click", async () => {
    result.textContent = "加载中...";
    try {
      const res = await fetch(`https://api.github.com/users/${q.value}`);
      if (!res.ok) throw new Error("未找到");
      const data = await res.json();
      result.innerHTML = `<img src="${data.avatar_url}" width="80" /><p>${data.name}</p>`;
    } catch (e) {
      result.textContent = "出错: " + e.message;
    }
  });
</script>

动手试试 ①:把这段存成 HTML 打开,输入 torvalds 点搜索看看。

十一、模块化:代码分文件

一个项目几千行代码放一个文件不现实。JS 用 import / export 拆文件:

// utils.js
export const PI = 3.14;
export function area(r) {
  return PI * r * r;
}

// 默认导出(一个文件最多一个)
export default function hello() {
  return "hi";
}
// main.js
import hello, { PI, area } from "./utils.js";

console.log(PI);
console.log(area(5));
console.log(hello());

在 HTML 里用模块:

<script type="module" src="./main.js"></script>

type="module" 是关键。Vite / Tauri 项目里默认都是模块,不用你手动写。

十二、TypeScript:给 JS 加类型

12.1 为什么需要它

纯 JS 的这种问题特别常见:

function sum(nums) {
  return nums.reduce((a, b) => a + b, 0);
}
sum(5);           // 炸了!5 不是数组,但直到运行才发现

TypeScript 让你在写代码时就标出类型,编辑器会立即报红:

function sum(nums: number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}
sum(5);           // 编辑器直接红线:参数不对
sum([1, 2, 3]);   // ✓

12.2 基本类型写法

let name: string = "小明";
let age: number = 18;
let isMember: boolean = true;
let tags: string[] = ["pop", "rock"];     // 字符串数组
let ids: number[] = [1, 2, 3];

// 可能为空
let nickname: string | null = null;
let optional: number | undefined;

// 字面量联合(限定几个值之一)
let status: "idle" | "loading" | "success" | "error" = "idle";

联合类型 A | B 意思是"要么 A 要么 B"。非常常用。

12.3 对象类型:用 interface

interface Song {
  id: number;
  title: string;
  artist: string;
  duration: number;
  isLiked?: boolean;        // 问号表示"可选"
}

const s: Song = {
  id: 1,
  title: "起风了",
  artist: "买辣椒",
  duration: 321,
};

interface 就是"这个对象必须长什么样"的说明。

12.4 函数类型

function add(a: number, b: number): number {
  return a + b;
}

// 箭头函数
const greet = (name: string): string => `你好 ${name}`;

// 返回值可以省,让 TS 自动推断
const mult = (a: number, b: number) => a * b;

12.5 数组里装对象

const songs: Song[] = [
  { id: 1, title: "A", artist: "甲", duration: 200 },
  { id: 2, title: "B", artist: "乙", duration: 180 },
];

// map/filter 的类型自动推断
const titles = songs.map(s => s.title);     // string[]
const longOnes = songs.filter(s => s.duration > 190);

12.6 泛型:参数化类型

如果一个工具"对任何类型都行",用泛型:

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

first([1, 2, 3]);           // number | undefined
first(["a", "b"]);          // string | undefined
first<Song>([/* ... */]);   // Song | undefined

<T> 里的 T 就像一个类型参数。调用时 TS 自动填它。

12.7 any 和 unknown

let x: any;         // 什么都能塞,什么检查都没(逃避类型检查的逃生舱,别滥用)
let y: unknown;     // 什么都能塞,但用之前必须"收窄"到具体类型

尽量别用 any,它等于放弃了 TS 的保护。

12.8 tsconfig.json 关键开关

项目根目录的 tsconfig.json 控制检查严格程度:

{
  "compilerOptions": {
    "target": "ES2022",
    "strict": true,                    // 打开所有严格检查(必选)
    "noUncheckedIndexedAccess": true,  // arr[i] 可能是 undefined,逼你处理
    "skipLibCheck": true
  }
}

strict: true 这一个开关就够大部分项目用了。

十三、常见陷阱与坑

  • typeof null === "object":JS 历史遗留 bug,判断 null 要 x === null。
  • 0.1 + 0.2 !== 0.3:浮点数精度问题,金额用分为单位存整数。
  • arr.sort() 默认按字符串排:[10, 2, 1].sort() 结果是 [1, 10, 2]。数字要写 arr.sort((a,b) => a-b)。
  • 对象是引用:const a = { x: 1 }; const b = a; b.x = 2; 此时 a.x 也是 2!要拷贝用 { ...a }。
  • for...in 遍历 key,for...of 遍历 value:别搞混。
  • 箭头函数没有自己的 this:在回调里想用外层 this 时特别好用,这也是推荐它的原因之一。

十四、一个综合小项目:TODO 列表

把本章学的全部用上,写一个最简 TODO。新建 todo.html:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <style>
      * { box-sizing: border-box; font-family: sans-serif; }
      body { max-width: 400px; margin: 40px auto; padding: 20px; }
      .row { display: flex; gap: 8px; margin-bottom: 16px; }
      .row input { flex: 1; padding: 8px; }
      .row button { padding: 8px 16px; }
      ul { list-style: none; padding: 0; }
      li { display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #eee; }
      li.done span { text-decoration: line-through; color: #aaa; }
      li button { margin-left: auto; background: #ef4444; color: white; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; }
    </style>
  </head>
  <body>
    <h1>我的 TODO</h1>
    <div class="row">
      <input id="input" placeholder="要做什么?" />
      <button id="add">添加</button>
    </div>
    <ul id="list"></ul>

    <script>
      const todos = [];

      const input = document.getElementById("input");
      const addBtn = document.getElementById("add");
      const list = document.getElementById("list");

      function render() {
        list.innerHTML = "";
        todos.forEach((todo, idx) => {
          const li = document.createElement("li");
          if (todo.done) li.classList.add("done");

          const checkbox = document.createElement("input");
          checkbox.type = "checkbox";
          checkbox.checked = todo.done;
          checkbox.addEventListener("change", () => {
            todos[idx].done = checkbox.checked;
            render();
          });

          const span = document.createElement("span");
          span.textContent = todo.text;

          const delBtn = document.createElement("button");
          delBtn.textContent = "删";
          delBtn.addEventListener("click", () => {
            todos.splice(idx, 1);
            render();
          });

          li.appendChild(checkbox);
          li.appendChild(span);
          li.appendChild(delBtn);
          list.appendChild(li);
        });
      }

      addBtn.addEventListener("click", () => {
        const text = input.value.trim();
        if (!text) return;
        todos.push({ text, done: false });
        input.value = "";
        render();
      });

      input.addEventListener("keydown", (e) => {
        if (e.key === "Enter") addBtn.click();
      });
    </script>
  </body>
</html>

打开这个文件,你能:输入任务回车添加、勾选打钩、点"删"删除。

这就是原生 JS 版的 TODO。注意每次改数据都要手动 render(),页面才更新。React 就是来解决这个烦恼的。

本章小结

  • 变量:const(不变)/ let(变)。
  • 类型:number / string / boolean / null / undefined / 数组 / 对象。
  • 函数:三种写法功能相同,推荐箭头函数。
  • 数组三件套:map / filter / find。
  • DOM 操作:getElementById + addEventListener 改 textContent/style/classList。
  • 异步:async/await + try/catch 处理网络请求等。
  • 模块:import / export 拆代码。
  • TypeScript:给变量、参数、返回值加类型标注,编辑器帮你提前抓错。

下一章:React——让你不再手动拼 DOM,用"写 UI = 写函数"的方式高效做界面。

第 5 章 React(从零开始)

假设你看完了第 3、4 章,知道 HTML 结构、CSS 样式、JS 变量函数数组对象、DOM 操作和异步。这一章告诉你:为什么还需要一个 React?每一个概念都有可跑示例。读完你能独立写出 CloudTone 主界面的所有交互。

零、为什么需要 React?

回想第 4 章末尾的 TODO 例子。每次数据变化,你都要:

  1. 清空 <ul>。
  2. 循环生成 <li>。
  3. 分别加 checkbox、文字、按钮。
  4. 给每个元素绑事件。

数据一多,手写 DOM 操作既啰嗦又容易漏改。React 的核心理念:

你只负责说"界面现在应该长什么样",React 自己搞定怎么改 DOM。

换句话说:

  • 纯 JS:你写操作指令。"找到 ul,清空,然后循环 append li……"
  • React:你写一个"图纸"。"当前状态是 X,所以界面应该是这个样"。数据变了你只改数据,React 对比新旧图纸,自动更新 DOM。

一、跑起第一个 React

最快方式:用 Vite 一行命令创建项目。打开终端:

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

浏览器打开控制台提示的地址(通常 http://localhost:5173),你会看到一个 React 欢迎页。

打开 src/App.tsx,删掉里面所有内容,替换成:

function App() {
  return <h1>Hello React</h1>;
}

export default App;

保存,浏览器自动热更新,显示 "Hello React"。

二、JSX:HTML 长在 JS 里

上面 return <h1>Hello React</h1> 看起来像 HTML,但它写在 TS 代码里——这就是 JSX。本质上它被编译成 JS 对象:

<div className="box">Hello</div>
// 等价于
React.createElement("div", { className: "box" }, "Hello")

2.1 JSX 和 HTML 的几点区别

// ❌ HTML 里的 class
<div class="card">

// ✓ JSX 里要写 className(因为 class 是 JS 关键字)
<div className="card">

// ❌ HTML 里 for="id"
<label for="name">

// ✓ JSX 里 htmlFor
<label htmlFor="name">

// 自闭合标签必须有斜杠
<img src="..." />     // ✓
<img src="...">       // ❌ 报错

// style 是对象,不是字符串
<div style={{ color: "red", fontSize: 16 }}>

// 注释要写在 {/* */} 里
<div>{/* 这是注释 */}</div>

2.2 JSX 里嵌 JS 表达式:{}

大括号里可以写任何 JS 表达式:

function App() {
  const name = "小明";
  const age = 18;
  const items = ["苹果", "香蕉", "橘子"];

  return (
    <div>
      <h1>你好 {name}</h1>
      <p>今年 {age} 岁,{age >= 18 ? "成年" : "未成年"}</p>
      <p>1 + 1 = {1 + 1}</p>
      <ul>
        {items.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

三点关键:

  1. {name} 把变量插进 HTML 里。
  2. {items.map(...)} 循环生成一堆元素。列表里每个元素都要有唯一的 key 属性,React 用它追踪哪个是哪个。
  3. 多个顶层元素要用一个父元素包起来(或者用 <>...</>,叫 Fragment):
return (
  <>
    <h1>标题</h1>
    <p>段落</p>
  </>
);

三、组件:UI 的积木

组件 = 一个返回 JSX 的函数。组件名必须大写开头。

function Greeting() {
  return <h1>你好,世界</h1>;
}

function App() {
  return (
    <div>
      <Greeting />
      <Greeting />
      <Greeting />
    </div>
  );
}

用大写的 <Greeting />,React 会去找 Greeting 函数;小写的 <greeting /> 会被当成 HTML 标签。

3.1 props:给组件传参数

interface GreetingProps {
  name: string;
  age?: number;           // 可选
}

function Greeting({ name, age }: GreetingProps) {
  return <p>你好 {name},{age ? `今年 ${age} 岁` : "请问贵庚?"}</p>;
}

function App() {
  return (
    <div>
      <Greeting name="小明" age={18} />
      <Greeting name="张三" />
    </div>
  );
}

关键点:

  • 参数像 HTML 属性一样传:name="小明" age={18}。
  • 字符串用双引号,其他值(数字、变量、对象)用 {}。
  • 组件函数用对象解构接收:function X({ name, age })。
  • 用 interface XxxProps 定义参数类型,编辑器帮你检查。

3.2 children:把 JSX 当参数传

interface CardProps {
  title: string;
  children: React.ReactNode;    // 内容可以是任意 JSX
}

function Card({ title, children }: CardProps) {
  return (
    <div style={{ border: "1px solid #ddd", padding: 16, borderRadius: 8 }}>
      <h3>{title}</h3>
      <div>{children}</div>
    </div>
  );
}

function App() {
  return (
    <Card title="我的卡片">
      <p>这是卡片里的内容</p>
      <button>按钮</button>
    </Card>
  );
}

<Card>...</Card> 之间的内容,在 Card 里通过 children 拿到。组件组合的核心。

四、State:组件的记忆

组件函数每次调用都会从头执行一遍——局部变量每次都是新的。那怎么记住"点了几次按钮"这种状态?用 useState:

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);   // 初始值 0

  return (
    <div>
      <p>当前计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
}

解剖 useState:

const [count, setCount] = useState(0);
//      ↑        ↑              ↑
//      当前值   更新函数        初始值
  • useState 返回一个数组,习惯上用解构拿出来。
  • count 是当前值,只能读,不能直接改(count++ 无效)。
  • setCount(新值) 更新状态,React 会重新调用整个组件函数,用新值再画一次。

4.1 重要规则

① 不要直接改 state,要用 setter:

// ❌ 无效
count = count + 1;
arr.push(x);
obj.x = 2;

// ✓ 用 setter
setCount(count + 1);
setArr([...arr, x]);           // 创建新数组
setObj({ ...obj, x: 2 });      // 创建新对象

React 靠"引用变了没"判断是否需要重画。改内部没用,必须整个换。

② 依赖上一次的值时,用函数式写法:

// ❌ 连点两次只加了 1
<button onClick={() => { setCount(count + 1); setCount(count + 1); }}>

// ✓ 函数式,基于最新值
<button onClick={() => { setCount(c => c + 1); setCount(c => c + 1); }}>

4.2 多个 state

function Form() {
  const [name, setName] = useState("");
  const [age, setAge] = useState(0);
  const [agree, setAgree] = useState(false);

  return (
    <div>
      <input value={name} onChange={e => setName(e.target.value)} placeholder="姓名" />
      <input type="number" value={age} onChange={e => setAge(Number(e.target.value))} />
      <label>
        <input type="checkbox" checked={agree} onChange={e => setAgree(e.target.checked)} />
        同意协议
      </label>
      <p>{agree ? `${name}, ${age} 岁` : "未同意"}</p>
    </div>
  );
}

每个独立状态一个 useState。

动手试试 ①:把这段代码放到 App.tsx 看看效果。试试输入时 <p> 实时更新。

五、事件处理

React 的事件名是驼峰式(onClick、onChange、onSubmit),值是函数:

<button onClick={() => alert("点了!")}>点我</button>

<button onClick={handleClick}>点我</button>
// 其中
function handleClick() {
  alert("点了!");
}

<input onChange={e => setText(e.target.value)} />
<form onSubmit={e => { e.preventDefault(); submit(); }}>

e.preventDefault() 阻止默认行为(比如表单提交后刷新页面)。

5.1 传参给事件处理函数

function List() {
  const items = ["a", "b", "c"];

  function handleDelete(item: string) {
    console.log("删", item);
  }

  return (
    <ul>
      {items.map(item => (
        <li key={item}>
          {item}
          <button onClick={() => handleDelete(item)}>删</button>
        </li>
      ))}
    </ul>
  );
}

注意是 onClick={() => handleDelete(item)}(传一个函数),而不是 onClick={handleDelete(item)}(这会立即调用)。

六、条件渲染 & 列表渲染(最常用两招)

6.1 条件渲染

function App() {
  const [loggedIn, setLoggedIn] = useState(false);

  return (
    <div>
      {loggedIn ? <p>欢迎回来</p> : <button onClick={() => setLoggedIn(true)}>登录</button>}

      {/* 只在为 true 时显示(没 else) */}
      {loggedIn && <p>你已登录</p>}
    </div>
  );
}

三元 A ? B : C 处理两个分支;&& 处理"要么显示要么不显示"。

6.2 列表渲染

const songs = [
  { id: 1, title: "起风了", artist: "买辣椒" },
  { id: 2, title: "晴天", artist: "周杰伦" },
];

return (
  <ul>
    {songs.map(song => (
      <li key={song.id}>
        {song.title} - {song.artist}
      </li>
    ))}
  </ul>
);

key 必须唯一且稳定。通常用数据里的 id。别用数组下标 index 当 key——列表顺序变动时 React 会认错。

七、完整示例:React 版 TODO

对比第 4 章的原生 JS 版本,代码更短、更清晰:

import { useState } from "react";

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");

  function addTodo() {
    const text = input.trim();
    if (!text) return;
    setTodos([...todos, { id: Date.now(), text, done: false }]);
    setInput("");
  }

  function toggle(id: number) {
    setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }

  function remove(id: number) {
    setTodos(todos.filter(t => t.id !== id));
  }

  return (
    <div style={{ maxWidth: 400, margin: "40px auto", fontFamily: "sans-serif" }}>
      <h1>我的 TODO</h1>
      <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
        <input
          style={{ flex: 1, padding: 8 }}
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => e.key === "Enter" && addTodo()}
          placeholder="要做什么?"
        />
        <button onClick={addTodo}>添加</button>
      </div>
      <ul style={{ listStyle: "none", padding: 0 }}>
        {todos.map(todo => (
          <li key={todo.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: 8, borderBottom: "1px solid #eee" }}>
            <input type="checkbox" checked={todo.done} onChange={() => toggle(todo.id)} />
            <span style={{ flex: 1, textDecoration: todo.done ? "line-through" : "none", color: todo.done ? "#aaa" : "#000" }}>
              {todo.text}
            </span>
            <button onClick={() => remove(todo.id)} style={{ background: "#ef4444", color: "white", border: "none", padding: "4px 8px", borderRadius: 4 }}>
              删
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

对比原生 JS 版,注意:

  • 没有 getElementById,没有 createElement。
  • 没有手动调 render()。
  • 只改数据(setTodos),界面自动同步。

动手试试 ②:在你的 Vite 项目里替换 App.tsx 为这段,验证能跑。然后尝试自己加一个"清空已完成"按钮。

八、拆分组件:一个 TODO 拆成三块

代码长了要拆。原则:一个组件只关心一件事。

// TodoItem.tsx
interface TodoItemProps {
  todo: Todo;
  onToggle: (id: number) => void;
  onRemove: (id: number) => void;
}

function TodoItem({ todo, onToggle, onRemove }: TodoItemProps) {
  return (
    <li>
      <input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
      <span>{todo.text}</span>
      <button onClick={() => onRemove(todo.id)}>删</button>
    </li>
  );
}

// TodoList.tsx
function TodoList({ todos, onToggle, onRemove }: { todos: Todo[]; onToggle: (id: number) => void; onRemove: (id: number) => void }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onToggle={onToggle} onRemove={onRemove} />
      ))}
    </ul>
  );
}

// App.tsx
function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  // ... 之前的 add/toggle/remove

  return (
    <div>
      {/* 输入框部分 */}
      <TodoList todos={todos} onToggle={toggle} onRemove={remove} />
    </div>
  );
}

数据流规律:

  • 数据向下传(通过 props)。父组件有 todos,传给 TodoList,再传给 TodoItem。
  • 事件向上传(通过回调)。子组件里点了删,调父传下来的 onRemove(id),父来更新数据。

这就是 React 的"单向数据流"。

九、useEffect:处理副作用

副作用 = 组件渲染之外的事:网络请求、订阅事件、定时器、读写 localStorage。

import { useEffect, useState } from "react";

function GitHubUser() {
  const [data, setData] = useState<any>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("https://api.github.com/users/torvalds")
      .then(r => r.json())
      .then(d => {
        setData(d);
        setLoading(false);
      });
  }, []);       // ← 依赖数组:空就是"只在组件挂载时跑一次"

  if (loading) return <p>加载中...</p>;
  return <p>{data.name} · {data.followers} 粉丝</p>;
}

useEffect(fn, deps) 三种写法:

// 每次渲染都跑(少用)
useEffect(() => { console.log("每次"); });

// 只在挂载时跑一次
useEffect(() => { console.log("一次"); }, []);

// 依赖变化时跑
useEffect(() => { console.log("id 变了:", id); }, [id]);

9.1 清理函数

订阅、定时器要清理,避免内存泄漏。return 一个函数就是清理:

useEffect(() => {
  const timer = setInterval(() => {
    console.log("tick");
  }, 1000);

  return () => clearInterval(timer);   // 组件卸载或依赖变化前先清掉
}, []);

9.2 依赖传参

function UserInfo({ userId }: { userId: string }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(`/api/user/${userId}`).then(r => r.json()).then(setData);
  }, [userId]);     // userId 变时重新请求

  return <div>{data?.name}</div>;
}

9.3 严格模式下 Effect 跑两次是正常的

开发环境下 React 故意把 effect 跑两次(mount → unmount → mount 再跑),逼你检查有没有忘清理。不是 bug,生产环境不会这样。

9.4 别滥用 useEffect

很多人把"派生数据"错塞进 effect:

// ❌ 不需要 effect
const [filtered, setFiltered] = useState([]);
useEffect(() => {
  setFiltered(songs.filter(s => s.isLiked));
}, [songs]);

// ✓ 渲染时直接算
const filtered = songs.filter(s => s.isLiked);

渲染时就能算出的东西,不要用 state + effect。

十、表单与受控组件

上面例子里 <input value={name} onChange={e => setName(e.target.value)} /> 就是"受控组件"——值由 React 管。几乎所有表单都这么写。

完整例子:

function SignupForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [agree, setAgree] = useState(false);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!agree) { alert("请同意协议"); return; }
    console.log({ name, email });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        姓名
        <input value={name} onChange={e => setName(e.target.value)} required />
      </label>
      <label>
        邮箱
        <input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
      </label>
      <label>
        <input type="checkbox" checked={agree} onChange={e => setAgree(e.target.checked)} />
        同意协议
      </label>
      <button type="submit">提交</button>
    </form>
  );
}

十一、useRef:拿 DOM 或存可变值

11.1 拿 DOM 元素

function AutoFocus() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus();    // 页面加载就聚焦
  }, []);

  return <input ref={inputRef} />;
}

11.2 存可变但不需要触发重渲染的值

function Timer() {
  const countRef = useRef(0);

  function inc() {
    countRef.current++;           // 改 ref 不会触发重渲
    console.log(countRef.current);
  }

  return <button onClick={inc}>点我(看控制台)</button>;
}

区别 state 和 ref:

  • 要显示在界面上的用 useState。
  • 只是存一下、不影响界面的用 useRef。

十二、useContext:跨层传数据

props 一层层传烦人时,用 Context。最典型是主题:

import { createContext, useContext, useState } from "react";

const ThemeContext = createContext<"light" | "dark">("light");

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  return (
    <ThemeContext.Provider value={theme}>
      <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>切换</button>
      <Page />
    </ThemeContext.Provider>
  );
}

function Page() {
  return <Article />;
}

function Article() {
  const theme = useContext(ThemeContext);     // 不用层层传
  return <div style={{ background: theme === "dark" ? "#222" : "#fff" }}>...</div>;
}

十三、自定义 Hook:封装复用逻辑

Hook 就是用到 useState、useEffect 等的函数。你可以写自己的,函数名必须 use 开头。

例子:封装"防抖"(用户停止输入 300ms 后才生效):

function useDebounce<T>(value: T, ms = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(t);
  }, [value, ms]);

  return debounced;
}

// 使用
function Search() {
  const [q, setQ] = useState("");
  const debouncedQ = useDebounce(q, 300);

  useEffect(() => {
    if (debouncedQ) console.log("发请求:", debouncedQ);
  }, [debouncedQ]);

  return <input value={q} onChange={e => setQ(e.target.value)} />;
}

用户狂敲键盘,q 实时变,但 debouncedQ 只在停下 300ms 后才更新,网络请求频率骤减。

十四、性能(先会用,再优化)

先说最重要的话:绝大多数场景你不需要优化。先把功能写对,卡了再说。

三个常见优化手段,看得懂就够了:

14.1 useMemo:缓存昂贵计算

const sortedSongs = useMemo(() => {
  return songs.slice().sort((a, b) => b.plays - a.plays);
}, [songs]);

只有 songs 变了才重新排。songs 没变就用上次缓存的结果。

14.2 useCallback:缓存函数引用

const handleClick = useCallback(() => {
  doSomething(id);
}, [id]);

让传给子组件的函数引用稳定,避免子组件无谓重渲。

14.3 React.memo:缓存组件

const Row = React.memo(function Row({ song }: { song: Song }) {
  return <div>{song.title}</div>;
});

Row 的 props 没变就不重画。

这三个联用才有效,而且有分析成本 > 收益的情况,别提前用。React DevTools 的 Profiler 面板能告诉你哪个组件真慢。

十五、React 的 10 个常见坑

  1. state 更新了但组件没变 → 你改了对象内部而不是换了引用。用 setX({ ...x, y: 2 })。
  2. effect 跑两次 → 严格模式特性,不是 bug。
  3. 依赖数组警告你漏了 → 照 ESLint 提示加,或想清楚为啥不需要(通常还是加上对)。
  4. 闭包陷阱:useEffect 里读的 state 永远是挂载时那个值 → 用函数式更新 setCount(c => c + 1) 或把值加进依赖。
  5. 列表用 index 当 key → 列表重排或删除时出 bug,用稳定 id。
  6. input 光标乱跳 → 检查是不是 key 写错导致重新挂载。
  7. Context 变一次所有用它的都重渲 → 把大对象拆小 Context,或用 Zustand。
  8. className 拼错 → React 不检查 class 名,CSS 找不到的类静默不生效。
  9. 忘了 e.preventDefault() → 表单提交刷新页面。
  10. 组件函数里写副作用(直接 fetch) → 必须放 useEffect 里。

十六、从这里到精通的路

已经会的:

  • 写组件 / 传 props / 组件组合。
  • useState / useEffect / useRef / useContext。
  • 自定义 Hook、表单、列表。

下一步(本书后续章节):

  • Tailwind CSS(第 6 章):更快地写样式。
  • Zustand(第 25 章):比 Context 更好用的全局状态。
  • TanStack Query(第 25 章):数据请求的工业级方案。
  • React Router(第 24 章):多页面切换。

本章小结

  • 组件 = 返回 JSX 的函数,大写开头。
  • props 向下传,事件向上传。
  • state 不能直接改,要用 setter。
  • JSX 里 {} 里写 JS 表达式,列表要加 key。
  • 副作用放 useEffect,清理用返回的函数。
  • 先把功能写对,别过早优化。

强烈建议:

  1. 把第七节 TODO 例子亲手敲一遍。
  2. 再加一个功能:"只看未完成" 的过滤开关。
  3. 再把 TODO 按第八节那样拆成 3 个组件。

做完这三件事,你对 React 就不是"看过",而是"会用"了。

下一章,用 Tailwind CSS 把 CSS 写作效率翻几倍。

第 6 章 Tailwind CSS 工程化用法(从零到精通)

本章目标

学完这一章,你应该能够:

  • 说清 Tailwind 的设计哲学、与 BEM / CSS Modules / CSS-in-JS 的差别,以及它在什么项目里会拖后腿。
  • 掌握 Tailwind 工具类的完整分类(而不是背 50 个类),遇到新需求能直接查官方 ref 写出来。
  • 配置并定制 tailwind.config.ts:设计 token(颜色/字号/间距/圆角/字体)、暗色模式、扩展 theme、写自定义 plugin。
  • 熟练使用断点、伪类、group / peer、data-* 变体、@container、arbitrary values / arbitrary variants。
  • 组织 @layer / @apply,知道什么时候写 utility、什么时候抽 component class。
  • 配合 clsx + tailwind-merge 写条件样式,和 class-variance-authority (cva) 写多变体组件。
  • 和 shadcn/ui、lucide-react、Radix UI 的集成方式。
  • 理解 Tailwind v3 与 v4(Oxide 引擎 + CSS-first 配置)的核心差异,能从 v3 平滑过渡到 v4。
  • 避开动态类名、min-w-0、dark: 对比度、content 路径等高发陷阱。

这一章的篇幅远不止「50 个常用类」。它是让你从「会用 Tailwind 糊页面」升级到「能主导团队 Tailwind 规范」的那一跃。


一、Tailwind 到底是什么:三句话讲清楚

第一句:Tailwind 是一组预生成的 CSS utility class。它把 padding: 8px 做成 .p-2,把 display: flex 做成 .flex。每个类只做一件事,叫 atomic / utility class。

第二句:Tailwind 的核心价值不是这些类,而是它的设计系统。它把常用 CSS 值离散化成有限的 scale:间距用 4px 步进(p-1 = 4px、p-2 = 8px、p-4 = 16px),颜色按 50/100/200/.../950 分成 11 级,字号有 12 档(text-xs...text-9xl)。这套 scale 让你在不思考的情况下做出「看起来不会丑」的页面。

第三句:Tailwind 通过扫描源码的 JIT 编译器,只打包你用到的类。所以你写了 text-red-500,这个类才会出现在最终 CSS 里;你没写 text-red-600,它就不存在。最终 CSS 常常只有 10–20 KB(gzip 后 4–8 KB)。

1.1 与其他 CSS 方案对比

传统写法(BEM):
  .song-card { ... }
  .song-card__title { ... }
  .song-card--active { ... }

  ✅ 语义清晰
  ❌ 起名累死
  ❌ 样式散落在 .css 文件里,看组件要两个文件来回跳
  ❌ 删除组件时 CSS 常常忘删

CSS Modules:
  import s from "./SongCard.module.css";
  <div className={s.card}>

  ✅ 自动 scope,不污染
  ❌ 还是要起名
  ❌ 样式与结构在不同文件

CSS-in-JS(styled-components / emotion):
  const Card = styled.div`padding: 16px; ...`

  ✅ 动态样式天然支持
  ❌ 运行时开销(emotion v10 以后好一些)
  ❌ SSR / 流式渲染复杂
  ❌ 现在明显退潮

Tailwind:
  <div className="p-4 rounded-lg bg-zinc-900">

  ✅ 不用起名
  ✅ 样式与结构在同一行
  ✅ JIT 之后 CSS 体积极小
  ✅ 设计 token 统一
  ❌ className 字符串长
  ❌ 初学者要背映射表
  ❌ 动态值不能用字符串拼接(见陷阱)

CloudTone 体量(中等前端 + Electron 式桌面应用),Tailwind 的 ROI 极高;它也是 Tauri / shadcn / Next.js 生态的事实标准。

1.2 什么时候 Tailwind 不合适

  • 原型极重的图形设计(比如 Figma 导出的像素级 landing page):任意 px / 不规则布局很多时,你会写一堆 [15px]、[357px],体验不如直接写 CSS。
  • SSR 流式渲染 + 极端 TTI 需求:Tailwind 本身很轻,但 atomic class 会让 HTML 变大(<div class="flex items-center gap-3 ...">),Gzip 后差别通常可忽略,但极限场景要测。
  • 长期维护的大型设计系统:Tailwind 可以做,但你最后会抽出一层「Box / Stack / Text」语义组件,这时和用 token + styled-system 差别不大。

CloudTone 不在上述范围内,我们直接用 Tailwind。


二、工具类按「CSS 属性分类」全景记忆法

背 50 个类迟早会忘。正确的做法是按 CSS 属性分类 记住 Tailwind 的命名规则,然后遇到需求直接查。以下是 CloudTone 前 3000 行代码涉及到的完整分类。

2.1 Layout

类别类作用
displayblock inline inline-block flex inline-flex grid inline-grid hidden contentsdisplay: ...
positionstatic relative absolute fixed stickyposition: ...
insetinset-0 inset-x-0 top-0 left-4 -top-2 top-[12px]定位偏移
z-indexz-0 z-10 z-20 z-30 z-40 z-50 z-[100]层级
floatfloat-left float-right float-none极少用
overflowoverflow-auto overflow-hidden overflow-scroll overflow-x-auto overflow-y-hidden溢出
overscrolloverscroll-contain overscroll-none阻止边缘联动滚动
object-fitobject-contain object-cover object-fill object-none<img> 填充方式
isolationisolate创建独立 stacking context
aspect-ratioaspect-square aspect-video aspect-[16/9]固定宽高比
columnscolumns-2 columns-3多列布局,少用

2.2 Flexbox

容器:
  flex-row / flex-row-reverse / flex-col / flex-col-reverse
  flex-wrap / flex-nowrap / flex-wrap-reverse
  items-start / items-center / items-end / items-baseline / items-stretch
  justify-start / justify-center / justify-end / justify-between / justify-around / justify-evenly
  content-start / content-center / ...              (多行 align-content)
  gap-0 / gap-1 / gap-2 / gap-4 / gap-6 / gap-x-2 / gap-y-4

子项:
  flex-1 / flex-auto / flex-initial / flex-none
  grow / grow-0 / shrink / shrink-0
  basis-0 / basis-1/2 / basis-full / basis-[200px]
  order-1 / order-2 / order-first / order-last
  self-start / self-center / self-end / self-stretch

2.3 Grid

容器:
  grid-cols-1 / grid-cols-2 / ... / grid-cols-12
  grid-cols-[200px_1fr] / grid-cols-[repeat(auto-fill,minmax(180px,1fr))]
  grid-rows-3 / grid-rows-[auto_1fr_auto]
  grid-flow-row / grid-flow-col / grid-flow-dense

子项:
  col-span-2 / col-span-full / col-start-2 / col-end-4
  row-span-2 / row-start-1

其他:
  grid-cols-subgrid(Tailwind v3.4+)

2.4 Spacing(padding / margin / space / gap)

padding: p-0 p-px p-0.5 p-1 p-2 p-3 p-4 p-6 p-8 p-10 p-12 p-16 ...
         px-4 py-2 pt-2 pr-4 pb-2 pl-4 ps-4 pe-4 (ps/pe 是 RTL-aware 逻辑属性)

margin: m-auto mx-auto my-4 -mx-2(负值)mt-[3px](任意值)

space:  space-x-2 space-y-4  → 给子元素之间加间距,等价于 sibling margin
         .parent > * + * { margin-left: 0.5rem }

gap:    gap-2 gap-x-2 gap-y-4  → flex/grid 专用

经验:flex/grid 容器用 gap-*;不是 flex/grid 的父容器用 space-*。

2.5 Sizing

width:   w-0 w-px w-1 w-2 w-full w-screen w-fit w-min w-max w-1/2 w-1/3 w-1/4 w-[240px]
height:  h-full h-screen h-dvh h-svh h-lvh h-fit h-[calc(100vh-56px)]
min-w:   min-w-0 min-w-full min-w-max
max-w:   max-w-xs max-w-sm max-w-md max-w-lg max-w-xl max-w-2xl ... max-w-7xl max-w-screen-lg max-w-prose
min-h / max-h: 类似
size:    size-8  (= w-8 h-8,Tailwind v3.4+)

dvh / svh / lvh 是 CSS 新单位,用于移动端适配地址栏动态伸缩。

2.6 Typography

font-size:        text-xs text-sm text-base text-lg text-xl text-2xl text-3xl text-4xl text-5xl text-6xl text-7xl text-8xl text-9xl text-[15px]
font-weight:      font-thin font-light font-normal font-medium font-semibold font-bold font-extrabold font-black
font-family:      font-sans font-serif font-mono font-[Inter]
letter-spacing:   tracking-tighter tracking-tight tracking-normal tracking-wide tracking-wider
line-height:      leading-none leading-tight leading-snug leading-normal leading-relaxed leading-loose leading-[1.8]
color:            text-white text-black text-transparent text-zinc-500 text-pink-500/80
alignment:        text-left text-center text-right text-justify text-start text-end
decoration:       underline line-through no-underline decoration-2 decoration-pink-500 underline-offset-2
text-transform:   uppercase lowercase capitalize normal-case
text-overflow:    truncate text-ellipsis text-clip
line-clamp:       line-clamp-1 line-clamp-2 line-clamp-3 line-clamp-none   (多行省略)
white-space:      whitespace-nowrap whitespace-pre whitespace-pre-wrap
word-break:       break-normal break-words break-all
text-wrap:        text-wrap text-nowrap text-balance text-pretty   (CSS 新属性)
font-variant-numeric: tabular-nums proportional-nums   (数字等宽)

2.7 Backgrounds

color:           bg-zinc-900 bg-white/10 bg-transparent bg-current bg-inherit
gradient:        bg-gradient-to-r bg-gradient-to-br from-pink-500 via-purple-500 to-indigo-500
                 from-[10%] via-[40%] to-[90%]
image:           bg-[url('/cover.jpg')]
repeat/size/pos: bg-no-repeat bg-cover bg-contain bg-center bg-fixed bg-top
blend-mode:      bg-blend-multiply bg-blend-overlay

2.8 Borders / Radius / Outline / Ring

border-width:   border border-2 border-4 border-t border-x border-b-0 border-y-2
border-style:   border-solid border-dashed border-dotted border-none
border-color:   border-zinc-700 border-pink-500/50
divide:         divide-x divide-y divide-zinc-700   (给多个子元素加分隔线)

border-radius:  rounded-none rounded-sm rounded rounded-md rounded-lg rounded-xl rounded-2xl rounded-full
                rounded-t-lg rounded-tl-lg rounded-[20px]

outline:        outline-none outline-1 outline-pink-500 outline-offset-2
ring:           ring ring-2 ring-pink-500/50 ring-offset-2 ring-offset-black   (focus 常用)

ring-* 是 Tailwind 独有的抽象,它基于 box-shadow 实现,不占据布局空间、能叠加。:focus-visible:ring-2 是 accessibility 首选写法。

2.9 Effects / Filters

opacity:         opacity-0 opacity-50 opacity-100 opacity-[0.35]
shadow:          shadow shadow-sm shadow-md shadow-lg shadow-xl shadow-2xl shadow-inner shadow-pink-500/30
mix-blend-mode:  mix-blend-multiply mix-blend-screen mix-blend-overlay

filter:          blur-sm blur blur-md blur-lg  brightness-50 brightness-110 contrast-125 grayscale invert sepia saturate-150 hue-rotate-15
backdrop:        backdrop-blur backdrop-blur-md backdrop-brightness-75 backdrop-saturate-150

backdrop-blur-* 是做毛玻璃效果(macOS / Apple Music 风格)的关键。CloudTone 的侧边栏和 mini-player 大量使用。

2.10 Transforms / Transitions / Animations

transform:    (v3 里不需要加 transform,直接写下面这些就够)
translate:    translate-x-1 translate-y-1 -translate-x-1/2 translate-x-[200px]
rotate:       rotate-45 -rotate-12 rotate-[17deg]
scale:        scale-75 scale-95 scale-100 scale-110 hover:scale-105
skew:         skew-x-3
origin:       origin-center origin-top-left origin-[0_0]

transition:   transition transition-all transition-colors transition-opacity transition-transform
duration:     duration-75 duration-150 duration-200 duration-300 duration-500 duration-700 duration-1000
timing:       ease-linear ease-in ease-out ease-in-out
delay:        delay-100 delay-500

animation:    animate-none animate-spin animate-ping animate-pulse animate-bounce
              animate-[pulse_2s_ease-in-out_infinite]

2.11 Interactivity

cursor:        cursor-pointer cursor-not-allowed cursor-grab cursor-wait cursor-text
user-select:   select-none select-text select-all
pointer-events: pointer-events-none pointer-events-auto
resize:        resize resize-y resize-none
touch-action:  touch-none touch-pan-y   (拖拽时关键)
accent-color:  accent-pink-500          (form 控件着色)
caret-color:   caret-pink-500
scroll:        scroll-smooth scroll-auto snap-x snap-mandatory snap-center

2.12 Forms

Tailwind 默认对表单只做轻量样式。@tailwindcss/forms plugin 把 <input> 等重置成合理默认样式后再用工具类覆盖。

2.13 SVG / 可访问性

fill / stroke:  fill-current fill-pink-500 stroke-white stroke-2
sr-only / not-sr-only:    视觉隐藏但屏幕阅读器可读
appearance-none:          去掉原生 UI 外观

2.14 查询类别的心理映射

遇到新需求时,我的脑内检索顺序大致是:

  1. 「这是什么 CSS 属性?」
  2. 「Tailwind 叫什么前缀?」(通常属性名省略元音或缩写:padding → p-、margin → m-、background → bg-、border → border-、text color → text-、overflow → overflow-)
  3. 「scale 上的哪个级别?」

查 tailwindcss.com/docs 永远比背表快。VS Code 的 Tailwind IntelliSense 插件会在你打字时补全。


三、状态变体(variants)与响应式

3.1 伪类变体

hover:           hover:bg-white/10
focus:           focus:outline-none focus-visible:ring-2
active:          active:scale-95
disabled:        disabled:opacity-50
visited:         visited:text-purple-500
focus-within:    focus-within:bg-white/5     (任意子元素获得焦点时)
first / last:    first:pt-0 last:border-b-0
odd / even:      odd:bg-white/5 even:bg-white/10
empty:           empty:hidden
placeholder:     placeholder:text-zinc-400
read-only:       read-only:bg-zinc-800

3.2 结构变体:group / peer

group 让父元素状态影响子元素:

<div className="group">
  <img src={cover} />
  <button className="opacity-0 group-hover:opacity-100 transition-opacity">
    播放
  </button>
</div>

多个 group 嵌套用「命名 group」:

<div className="group/card">
  <div className="group/title">
    <span className="group-hover/card:text-pink-500 group-hover/title:underline">
      ...
    </span>
  </div>
</div>

peer 是「兄弟状态」:

<input className="peer" />
<label className="peer-focus:text-pink-500 peer-invalid:text-red-500">
  用户名
</label>

CloudTone 的歌曲卡片、表单错误提示大量使用 group / peer。

3.3 data-* 变体(Tailwind v3.2+)

<div
  data-state="open"
  className="data-[state=open]:bg-white/10 data-[state=closed]:opacity-0"
/>

这是 Radix UI / shadcn/ui 接入 Tailwind 的关键机制——Radix 把组件状态写成 data-state="open",我们直接用变体响应。

3.4 aria-* 变体

<button
  aria-busy="true"
  className="aria-busy:opacity-50 aria-disabled:cursor-not-allowed"
/>

3.5 响应式断点

默认断点(mobile-first):

sm:  ≥ 640px
md:  ≥ 768px
lg:  ≥ 1024px
xl:  ≥ 1280px
2xl: ≥ 1536px

Tailwind 的响应式是 min-width,所以基础样式写移动端,md:、lg: 逐级覆盖:

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">

断点也可以写成 max-*(Tailwind v3.2+ 的 max-width variant)或任意值:

<div className="text-base md:text-lg max-md:text-sm min-[900px]:text-xl" />

CloudTone 是桌面应用,窗口可以任意改变大小,所以还是要用响应式,且配合 @container 做组件级响应式。

3.6 dark mode

在 tailwind.config.ts 里:

darkMode: "class",   // 或 "media",或 ["class", ".dark"]
  • "media":跟随系统 prefers-color-scheme,不可切换。
  • "class":给根元素加 .dark 类才启用 dark。CloudTone 用这个方案。

使用:

<div className="bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100" />

切换逻辑:

document.documentElement.classList.toggle("dark", prefersDark);

CloudTone 实际上是「永远 dark」的音乐播放器,所以我们不用切换逻辑,直接在 <html> 上加 class="dark",但样式仍然写 dark: 前缀,方便将来加「浅色主题皮肤」。

3.7 自定义变体

tailwind.config.ts 里:

import plugin from "tailwindcss/plugin";

export default {
  // ...
  plugins: [
    plugin(({ addVariant }) => {
      addVariant("hocus", ["&:hover", "&:focus"]);
      addVariant("parent-open", ':merge(.parent)[data-state="open"] &');
    }),
  ],
};

hocus: 一次覆盖 hover 与 focus。

3.8 arbitrary variants(v3.1+)

变体里的选择器也可以任意写:

<div className="[&>p]:mt-4 [&:nth-child(3)]:bg-white/5 [@supports(color:oklch(0_0_0))]:bg-[oklch(0.2_0_0)]" />
  • [&>p]:mt-4:所有直接子 <p> 加 margin-top。
  • [@media(prefers-reduced-motion)]:animate-none:媒体查询。
  • [@supports(...)]:...:feature query。

四、配置 tailwind.config.ts

4.1 基础文件

// tailwind.config.ts
import type { Config } from "tailwindcss";
import forms from "@tailwindcss/forms";
import typography from "@tailwindcss/typography";
import containerQueries from "@tailwindcss/container-queries";
import animate from "tailwindcss-animate";   // shadcn/ui 官方推荐

export default {
  content: [
    "./index.html",
    "./src/**/*.{ts,tsx}",
  ],
  darkMode: ["class"],
  theme: {
    container: {
      center: true,
      padding: "1rem",
    },
    extend: {
      colors: {
        // 语义色(推荐用 CSS 变量 + HSL,见 4.3)
        brand: {
          50: "#fff1f2",
          500: "#ec4899",
          600: "#db2777",
        },
        background: "hsl(var(--background) / <alpha-value>)",
        foreground: "hsl(var(--foreground) / <alpha-value>)",
        card: {
          DEFAULT: "hsl(var(--card) / <alpha-value>)",
          foreground: "hsl(var(--card-foreground) / <alpha-value>)",
        },
        primary: {
          DEFAULT: "hsl(var(--primary) / <alpha-value>)",
          foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
        },
      },
      fontFamily: {
        sans: [
          "Inter",
          "system-ui",
          "-apple-system",
          "PingFang SC",
          "Microsoft YaHei",
          "sans-serif",
        ],
        mono: ["JetBrains Mono", "SFMono-Regular", "Consolas", "monospace"],
      },
      fontSize: {
        // 覆盖默认字号,如果你有自己的 scale
      },
      spacing: {
        // "sidebar": "240px",  // 自定义命名间距
      },
      borderRadius: {
        xl: "var(--radius)",          // 动态圆角
      },
      keyframes: {
        "accordion-down": {
          from: { height: "0" },
          to:   { height: "var(--radix-accordion-content-height)" },
        },
        "fade-in": {
          from: { opacity: "0" },
          to:   { opacity: "1" },
        },
      },
      animation: {
        "accordion-down": "accordion-down 0.2s ease-out",
        "fade-in": "fade-in 0.2s ease-out",
        "spin-slow": "spin 4s linear infinite",
      },
    },
  },
  plugins: [forms, typography, containerQueries, animate],
} satisfies Config;

几个要点:

  • 用 satisfies Config 而不是 : Config,保留字面量类型,同时做类型检查。
  • content 扫描路径错了,就会出现「类存在但不生效」。记住把 .html 和所有源码扩展名都写进去。
  • theme.extend 是增加 token;直接写 theme.colors = {} 会覆盖整个调色板,新手常踩。

4.2 content 扫描细节

Tailwind 是纯静态扫描:它会用正则从源文件里把可能是 class 的字符串提取出来。这就意味着:

// ✅ 能被扫出来
<div className="bg-pink-500" />

// ❌ 扫不到
const color = "pink";
<div className={`bg-${color}-500`} />

// ✅ 扫到 bg-pink-500 和 bg-green-500
const map = { red: "bg-red-500", green: "bg-green-500" };
<div className={map[color]} />

// ✅ 安全的命中列表(safelist)
// tailwind.config.ts:
safelist: ["bg-red-500", "bg-green-500", { pattern: /bg-(red|green)-\d+/ }],

4.3 设计 token:CSS 变量 + HSL 方案(shadcn/ui 风格)

硬编码颜色值的问题:换主题要改 300 个 dark: 前缀。现代做法是:

src/styles/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    /* 浅色:CloudTone 其实没用,只是留口子 */
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --primary: 330 81% 60%;
    --primary-foreground: 0 0% 100%;
    --radius: 0.75rem;
  }
  .dark {
    --background: 240 10% 3.9%;
    --foreground: 0 0% 98%;
    --primary: 330 81% 60%;
    --primary-foreground: 0 0% 100%;
  }
}

tailwind.config.ts 里:

colors: {
  background: "hsl(var(--background) / <alpha-value>)",
  primary: "hsl(var(--primary) / <alpha-value>)",
  // ...
}

使用:

<div className="bg-background text-foreground" />
<button className="bg-primary text-primary-foreground hover:bg-primary/90" />

切换主题时只需要改 <html> 上的 .dark 类,所有颜色自动变。

为什么用 HSL 而不是 #rrggbb:因为 Tailwind 的 <alpha-value> 只对 hsl() / rgb() 纯数字形式起作用。v4 支持更现代的 color-mix、oklch。

4.4 写自定义 plugin

官方约定:用 tailwindcss/plugin 导出函数:

import plugin from "tailwindcss/plugin";

const scrollbarPlugin = plugin(({ addUtilities, matchUtilities, theme }) => {
  // 1. 静态工具类
  addUtilities({
    ".scrollbar-none": {
      "scrollbar-width": "none",
      "&::-webkit-scrollbar": { display: "none" },
    },
  });

  // 2. 动态工具类
  matchUtilities(
    {
      "scrollbar-color": (value) => ({
        "scrollbar-color": `${value} transparent`,
      }),
    },
    { values: theme("colors") },
  );
});

export default {
  // ...
  plugins: [scrollbarPlugin],
};

现在你可以写:

<div className="scrollbar-none" />
<div className="scrollbar-color-pink-500" />

CloudTone 里我们写过 scrollbar-slim、drag-region(Tauri 自定义标题栏拖拽区域)、text-gradient 等几个专用 plugin。


五、@layer 与 @apply:什么时候该、什么时候不该

Tailwind 生成的 CSS 分三层:

@tailwind base;       → Preflight(reset)+ @layer base { }
@tailwind components; → @layer components { }  组件类
@tailwind utilities;  → @layer utilities { }   工具类

优先级:utilities > components > base,这样你写 className="btn bg-red-500" 时 bg-red-500 一定赢过 .btn 里的底色。

5.1 @apply:把工具类「打包」成一个 class

@layer components {
  .btn {
    @apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors;
    @apply bg-primary text-primary-foreground hover:bg-primary/90;
    @apply disabled:opacity-50 disabled:cursor-not-allowed;
  }
}

使用:<button class="btn">。

什么时候该用 @apply:

  • 需要在 非 JSX 环境(纯 HTML、Markdown、第三方组件的插槽 className)里复用。
  • 设计系统确定的「原子组件」(Button、Input 等)需要一个名字。
  • 和其他 CSS 框架(Radix, tauri-plugin-window-state 的默认皮肤)整合时。

什么时候不该:

  • 你本来写 React / Vue / Svelte,组件就是 class 复用的单位,<Button variant="primary" /> 永远比 .btn 更好。
  • 复杂状态逻辑(active / disabled / loading 变体)写在 CSS 里会炸,不如用 cva(见 6.3)。

CloudTone 的经验:90% 的代码用 utility,10% 的基础组件偶尔用 @apply(比如 .drag-region、.no-drag、Markdown 正文样式)。

5.2 @layer base 全局样式

@layer base {
  html {
    font-feature-settings: "cv11", "ss01";   /* Inter 字体的数字形态 */
  }
  html, body, #root {
    height: 100%;
  }
  body {
    @apply bg-background text-foreground font-sans antialiased;
    -webkit-font-smoothing: antialiased;
  }
  ::selection {
    @apply bg-primary/30 text-foreground;
  }
  /* 自定义滚动条 */
  ::-webkit-scrollbar { width: 8px; height: 8px; }
  ::-webkit-scrollbar-thumb { @apply bg-white/10 rounded; }
  ::-webkit-scrollbar-thumb:hover { @apply bg-white/20; }
}

5.3 @layer utilities 自定义工具类

@layer utilities {
  .text-gradient {
    @apply bg-gradient-to-r from-pink-400 to-purple-400 bg-clip-text text-transparent;
  }
  .drag-region {
    -webkit-app-region: drag;
  }
  .no-drag {
    -webkit-app-region: no-drag;
  }
}

drag-region 是 Tauri 自定义标题栏的关键:用 <div class="drag-region h-10 w-full" /> 做可拖拽区域,按钮用 no-drag 排除。


六、条件样式与变体组件

6.1 clsx + tailwind-merge

// src/lib/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
  • clsx 处理「条件拼接」:clsx("a", false && "b", ["c", "d"], { e: isOn })。
  • tailwind-merge 处理「冲突合并」:cn("p-2", "p-4") === "p-4"。

为什么要 tailwind-merge:在「基类 + 覆盖」场景里,单纯 clsx 会留下两个互相冲突的 class,浏览器按书写顺序取后者;但如果覆盖类是 @apply 生成或顺序先于基类,会翻车。tailwind-merge 认识 Tailwind 语义,自动去掉前者。

6.2 一个典型的 Button 组件(无变体)

import { cn } from "@/lib/cn";
import { forwardRef, ButtonHTMLAttributes } from "react";

export const Button = forwardRef<
  HTMLButtonElement,
  ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => (
  <button
    ref={ref}
    className={cn(
      "inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium",
      "bg-primary text-primary-foreground hover:bg-primary/90",
      "disabled:opacity-50 disabled:cursor-not-allowed",
      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
      "transition-colors",
      className,   // ← 让调用者可覆盖
    )}
    {...props}
  />
));
Button.displayName = "Button";

6.3 class-variance-authority (cva):多变体

真实项目的 Button 有 variant (primary/ghost/outline/destructive)、size (sm/md/lg/icon)、fullWidth 等维度。手写 if/else 快速失控。用 cva:

import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/cn";

const buttonVariants = cva(
  // 基础
  "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        primary: "bg-primary text-primary-foreground hover:bg-primary/90",
        ghost:   "hover:bg-white/5",
        outline: "border border-white/10 bg-transparent hover:bg-white/5",
        destructive: "bg-red-500 text-white hover:bg-red-600",
      },
      size: {
        sm:  "h-8 px-3 text-xs",
        md:  "h-9 px-4 text-sm",
        lg:  "h-11 px-8 text-base",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: { variant: "primary", size: "md" },
  },
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

使用:

<Button>播放全部</Button>
<Button variant="ghost" size="icon"><Play /></Button>
<Button variant="destructive">删除歌单</Button>

cva 是 shadcn/ui 每个组件的骨架。CloudTone 的全部可复用 UI 都这么写。


七、Container Queries:组件级响应式

断点响应的是视口;但 CloudTone 的歌曲卡片在 sidebar 内 220px 宽,在主列表区 900px 宽,同样的组件要根据「自身容器」变样子。这就是 Container Query。

安装 plugin:pnpm add -D @tailwindcss/container-queries

<section className="@container">
  <div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-4 gap-4">
    ...
  </div>
</section>

@md 表示「当这个 container 宽度 ≥ 28rem」。不需要知道视口尺寸。

命名 container(多层嵌套):

<div className="@container/list">
  <div className="@container/card">
    <div className="@lg/list:grid-cols-3 @sm/card:flex-col">

CloudTone 的 SongList 在桌面布局是 4 列网格,在窄 sidebar 里是 1 列堆叠,完全靠 container query。


八、和生态集成

8.1 shadcn/ui

shadcn/ui 不是组件库,而是一个 CLI,帮你把某个组件的代码 复制 到你的项目里。这些组件已经用 cva + Radix UI + Tailwind 写好。

pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest add button dialog dropdown-menu

结果:src/components/ui/button.tsx 等文件出现在你的仓库里,你可以改任何一行。

CloudTone 用到的 shadcn 组件:Button、Dialog、DropdownMenu、Tooltip、Slider、ScrollArea、ContextMenu、Toast。第 22 章会一次配齐。

8.2 lucide-react

图标库。SVG 导入,支持 className,大小靠 className="w-4 h-4" 控制:

import { Play, Pause, SkipForward } from "lucide-react";

<Play className="w-5 h-5 text-white" />

CloudTone 整个播放器约 40 个图标全部来自 lucide-react。

8.3 Radix UI primitives

Radix 提供无样式、可访问的组件基元(Dropdown、Dialog、Tooltip、Popover、Toast、Slider 等)。它输出 data-state、data-side 等属性,配合 Tailwind 的 data-* 变体:

<DropdownMenu.Content
  className="
    min-w-[8rem] rounded-md border border-white/10 bg-popover p-1 text-popover-foreground shadow-md
    data-[state=open]:animate-in data-[state=closed]:animate-out
    data-[side=bottom]:slide-in-from-top-2
  "
/>

shadcn/ui 底层几乎全是 Radix。

8.4 tailwindcss-animate

提供 animate-in、fade-in-0、slide-in-from-top-2 等组合类,专门搭配 Radix 的进入/离开动画。shadcn/ui 依赖它。


九、Tailwind v3 vs v4(Oxide)

CloudTone 本书主线用 v3(稳定),但你要知道 v4(2024 末稳定)的核心变化:

维度v3v4
引擎PostCSS + 自研 JITOxide(Rust 重写,5-10× 更快)
配置方式tailwind.config.ts (JS/TS)CSS-first:@import "tailwindcss"; + @theme { ... } 在 CSS 里
content 扫描显式配置自动发现(依赖 git/项目扫描)
颜色空间RGB/HSLOKLCH 为默认,更准的对比度
CSS 变量靠自己写所有 token 自动生成 CSS 变量,浏览器里可 var(--color-pink-500)
@apply支持支持,语法更贴近原生 CSS
Safari 要求14+16.4+ (因为依赖 @property)

v4 的 @theme 示例:

@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.7 0.15 340);
  --font-family-sans: "Inter", system-ui, sans-serif;
  --radius-xl: 1rem;
}

自动生成 bg-brand-500、font-sans、rounded-xl 等 utility。

迁移建议:v4 生态(shadcn、tailwindcss-animate)在 2025 年陆续适配。CloudTone 如果你起新项目,可以直接上 v4;本书代码兼容两种写法,关键不同点会在相应章节提示。附录会给 v3 → v4 的迁移清单。


十、常见陷阱(高压警示区)

10.1 动态类名丢失

// ❌ 扫不到,上线后没样式
<div className={`bg-${color}-500`} />

// ✅ 固定映射
const bgMap = {
  red: "bg-red-500",
  green: "bg-green-500",
} as const;
<div className={bgMap[color]} />

// ✅ 或 safelist(tailwind.config.ts)
safelist: [{ pattern: /bg-(red|green|blue)-(100|500|900)/ }]

10.2 min-w-0 让 flex 子元素不撑破

flex 子元素默认 min-width: auto(内容最小宽度),长歌名会把整行撑出屏幕:

// ❌ 歌名把布局撑爆
<div className="flex gap-3">
  <img />
  <div className="flex-1">
    <div className="truncate">{veryLongTitle}</div>
  </div>
</div>

// ✅
<div className="flex gap-3">
  <img />
  <div className="flex-1 min-w-0">
    <div className="truncate">{veryLongTitle}</div>
  </div>
</div>

同理 grid 子项是 min-width: 0,通常不需要,但嵌套 grid 里偶尔也要加。

10.3 dark: 对比度 / 颜色系统

不要指望「把浅色模式的 bg-white 直接换成 bg-black 就行」。人眼对暗色下的对比度更敏感。建议:

  • 用 11 级灰阶 zinc / neutral / slate 而不是纯黑纯白。CloudTone 用 zinc 系列。
  • 背景 zinc-950(几乎纯黑但有 1-2% 色偏),前景 zinc-100。
  • 卡片层叠时用不同透明度的白(bg-white/5、bg-white/10),让层次分明。
  • 配色工具:tailwindcss 官方 color palette、oklch.fyi、huetone。

10.4 Preflight 重置副作用

Tailwind 的 Preflight 重置会:

  • 把 <h1>..<h6> 的 font-size / font-weight 统一(所以标题默认和正文一样大,得自己加 class)。
  • 把 <ul> <ol> 的 list-style 去掉。
  • 把 <a> 的颜色继承化。

如果你要渲染一段由第三方 Markdown 生成的 HTML(CloudTone 的评论区或歌单描述),要么用 @tailwindcss/typography 的 prose 类,要么单独包一层自定义样式。

10.5 @layer 外写的 CSS 没有层级

/* ❌ 没被 Tailwind 管理,优先级混乱 */
.btn { padding: 8px 16px; }

/* ✅ 放进 layer */
@layer components {
  .btn { @apply px-4 py-2; }
}

10.6 group-* 嵌套冲突

在 group 里再用另一个 group:内层 group-hover: 会响应最近祖先的 group。要明确命名:

<div className="group/outer">
  <div className="group/inner">
    <span className="group-hover/outer:text-red-500 group-hover/inner:underline" />
  </div>
</div>

10.7 transition 时机

// ❌ 元素初始 display:none,hover 时改成 block,transition 不会生效
<div className="hidden hover:block transition-opacity" />

// ✅ 用 opacity + pointer-events
<div className="opacity-0 pointer-events-none hover:opacity-100 hover:pointer-events-auto transition-opacity" />

10.8 content 路径遗漏

新增一个 .mdx 或 .svelte 文件类型却忘了加进 content,类就不会被扫出。上线才暴露。

10.9 dark: 只在 darkMode: "class" 下能手动切换

忘改 config,darkMode 还是默认 media,手动加 .dark 不生效。

10.10 @apply 里用自定义 class

.btn { @apply some-custom-class; }   /* ❌ 通常会报错,@apply 只接受 Tailwind 工具类 */

绕过:内联 CSS 或用 theme() 函数取值。


十一、CloudTone 风格导览

CloudTone 的视觉骨架(第 22–24 章会逐步实现):

  • 整体背景:bg-zinc-950(顶层 <html class="dark">)。
  • 侧边栏:w-60 shrink-0 bg-zinc-900/60 backdrop-blur-md border-r border-white/5。
  • 主内容区:flex-1 overflow-y-auto p-6,内容卡片 rounded-2xl bg-white/[0.03] border border-white/5 p-4。
  • 播放器栏:h-20 bg-zinc-900/80 backdrop-blur-xl border-t border-white/5 flex items-center px-6。
  • 歌曲行:group flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 transition-colors。
  • 品牌色:primary = OKLCH(0.7 0.15 340)(粉红偏紫),其实就是网易云的配色。
  • 所有动效:transition-all duration-200 ease-out,motion-safe:* 保护减少动画偏好的用户。

最终一个歌曲卡片长这样:

import { cn } from "@/lib/cn";
import { Play } from "lucide-react";

interface Props {
  song: { id: number; title: string; artist: string; cover: string };
  active?: boolean;
  onPlay: () => void;
}

export function SongCard({ song, active, onPlay }: Props) {
  return (
    <div
      className={cn(
        "group flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer",
        "transition-colors duration-150",
        "hover:bg-white/5",
        active && "bg-white/10 ring-1 ring-primary/30",
      )}
      onDoubleClick={onPlay}
    >
      <div className="relative w-12 h-12 shrink-0">
        <img
          src={song.cover}
          className="w-full h-full rounded object-cover bg-white/5"
          alt=""
        />
        <button
          className={cn(
            "absolute inset-0 flex items-center justify-center rounded",
            "bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
          )}
          onClick={onPlay}
          aria-label={`播放 ${song.title}`}
        >
          <Play className="w-5 h-5 text-white fill-current" />
        </button>
      </div>
      <div className="flex-1 min-w-0">
        <div className="truncate text-sm font-medium">{song.title}</div>
        <div className="truncate text-xs text-zinc-400">{song.artist}</div>
      </div>
    </div>
  );
}

这段代码展示了本章的大部分要点:group 变体、min-w-0 陷阱、accessibility(aria-label + focus ring)、lucide-react、cn 条件样式、token 化颜色、transition 节制使用。


十二、工作流与 DX

12.1 编辑器设置

  • VS Code:装 Tailwind CSS IntelliSense(完成 / hover 预览 / 冲突检测)、Prettier + prettier-plugin-tailwindcss(自动排序 class)。
  • settings.json 建议:
{
  "tailwindCSS.experimental.classRegex": [
    ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
    ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
  ],
  "tailwindCSS.classAttributes": ["class", "className", "classNames"],
  "editor.quickSuggestions": { "strings": true }
}

这样 cva(...) 和 cn(...) 里的字符串也会有补全和 hover 预览。

12.2 Prettier 排序

prettier-plugin-tailwindcss 会按 Tailwind 推荐顺序自动排序 class:布局 → flex/grid → 间距 → 尺寸 → 排版 → 背景 → 边框 → 效果 → 变体。

pnpm add -D prettier prettier-plugin-tailwindcss
// .prettierrc
{ "plugins": ["prettier-plugin-tailwindcss"] }

12.3 ESLint 规则

  • eslint-plugin-tailwindcss:检查无效类、条件类一致性。
  • tailwindcss/no-contradicting-classname:禁止 p-2 p-4 这种冲突(tailwind-merge 能解决,但还是希望代码本身不冲突)。

12.4 构建产物检视

开发环境 CSS 上万行是正常的(Tailwind 生成几乎所有可能的类)。生产构建(Vite build)JIT 只保留用到的类,CloudTone 最终 index.css 约 18 KB,gzip 6 KB。命令:

pnpm build && du -h dist/assets/index-*.css

十三、学习与回查路径

  • 官方文档:https://tailwindcss.com/docs(没必要从头读,用搜索)。
  • Play ground:https://play.tailwindcss.com,贴代码即调,非常适合调试。
  • tailwindcss.com/docs/installation 的 Preflight 列表背一下。
  • shadcn/ui 源码:https://github.com/shadcn-ui/ui,是学习「Tailwind + Radix + cva」组合的最佳范本。
  • Lucide 图标搜索:https://lucide.dev/icons。
  • 颜色工具:https://uicolors.app/create、https://oklch.com。

十四、精通自查清单

做完这一章你应该能对每项回答「是」:

  • 说得出 Tailwind vs BEM vs CSS Modules vs CSS-in-JS 四个方案各自的优劣。
  • 看到一个需求能直接想到用哪个属性分类的工具类,而不是 Google「how to center div in tailwind」。
  • 自己写过至少一个自定义 plugin(addUtilities / matchUtilities)。
  • 用 CSS 变量 + HSL 方案搭建了一套可切换主题的 token 系统。
  • 写过用 cva 的多变体组件。
  • 用 group/name + data-[state=...] 给 Radix 组件写过过渡动画。
  • 知道 @layer base/components/utilities 的三层优先级,并且在设计系统里正确放置自己的规则。
  • 能判断 @apply 什么时候用、什么时候不用。
  • 踩过并解决过 min-w-0、动态类、dark 对比度、group 嵌套至少各一次。
  • 对 v3 vs v4 的迁移策略有清晰判断。

如果上面你还有 1-2 项答不出,翻回相应小节补齐;3 项以上的话,写 500 行 CloudTone UI 是最好的巩固方式——第 22 章起我们就开始。


本章小结

  • Tailwind 的威力来自「设计 token + atomic class + JIT + 变体系统」这四样东西的合力,不是任何一个单独的点。
  • 要精通,关键是记分类而不是记条目,熟变体系统而不是熟类名。
  • 工程化落地三件套:tailwind.config.ts + cn() + cva();再加 shadcn/ui 就是现代 React 项目的默认起手。
  • 避开 10 个陷阱,写 3000 行 UI,就过了精通门槛。

动手时刻

  1. 在第 2 章的 smoke-test 项目里,把 SongCard 按本章最终版本写一遍,加上 group + data-[state=active] 变体。
  2. 建立 src/lib/cn.ts、src/components/ui/button.tsx(用 cva)。
  3. 用 @tailwindcss/container-queries 做一个「在宽度 < 400px 时变成单列」的歌曲列表。
  4. 用 CSS 变量 + HSL 定义 --background / --foreground / --primary 三个 token,切换 <html> 的 class 看效果。

下一章,我们回到 Rust,复盘在 Tauri 场景下你最容易踩的几个坑——生命周期、Send/Sync、async runtime 的交互。

第 7 章 Rust 关键点速览(面向 Tauri)

本章目标

  • 你已经比较熟练 Rust。本章只复习最影响 Tauri 开发的几个点。
  • 把 async、Send + Sync、tokio::spawn、Mutex/RwLock、Arc、trait object 这套组合拳梳清楚。
  • 介绍 serde、thiserror、anyhow 三个在 Tauri 中几乎必装的 crate。

一、Tauri 的 Rust 口味

Tauri 2.x 的核心默认用 异步 Rust。#[tauri::command] 可以是同步 fn,也可以是 async fn。生产项目里大多数是 async——因为你会读文件、查数据库、调 HTTP。

所以我们必须对下面这套东西非常熟:

  1. async / .await 和 tokio 运行时。
  2. Send + Sync、Arc<Mutex<_>>、RwLock、跨线程/跨 await 共享状态。
  3. 错误建模:thiserror 定义枚举 error,anyhow 做 bubble up,向前端序列化成字符串。
  4. serde 把结构体与 JSON 互转。
  5. trait object Box<dyn Trait> 和对象安全。

二、async 与 tokio

Tauri 内置 tokio 运行时。你直接写:

#[tauri::command]
async fn fetch_lyrics(song_id: i64) -> Result<String, String> {
    let res = reqwest::get(format!("https://lyric.api/{}", song_id))
        .await
        .map_err(|e| e.to_string())?;
    let text = res.text().await.map_err(|e| e.to_string())?;
    Ok(text)
}

要点:

  • async fn 返回一个 Future,直到被 .await 才运行。
  • 运行时默认就绪。你可以在函数体里 tokio::spawn(async move { ... }) 起后台任务。
  • #[tauri::command] 里的 async fn 会在 Tauri 的 worker 上执行,不阻塞 UI 线程——这是性能的关键。

spawn vs spawn_blocking

  • I/O 密集、协程友好的工作:tokio::spawn。
  • CPU 密集或调用了阻塞 C 库(比如 rodio、某些解码器内部有阻塞 I/O):tokio::task::spawn_blocking,避免把 tokio worker 卡住。
let result = tokio::task::spawn_blocking(move || decode_entire_mp3(&path))
    .await
    .unwrap();

三、Send + Sync 这两个 trait 你必须吃透

在 Tauri 里:

  • Tauri State<T> 要求 T: Send + Sync + 'static。
  • tokio::spawn 里闭包要求 Send + 'static。
  • async fn 的返回 Future 是不是 Send,取决于它捕获的所有变量是否 Send。

最常见的报错:

future cannot be sent between threads safely
within `...`, the trait `Send` is not implemented for `...`

大部分情况是:你在 .await 跨点之间持有了一个不可 Send 的东西(典型:std::sync::MutexGuard)。解决:

  • 把 std::sync::Mutex 换成 tokio::sync::Mutex(它的 MutexGuard 是 Send)。
  • 或者在 .await 之前把 guard drop 掉。

Arc<Mutex<T>> 的正确姿势

Tauri 里共享状态常用:

use std::sync::Arc;
use tokio::sync::Mutex;

pub struct AppState {
    pub player: Arc<Mutex<AudioPlayer>>,
}

#[tauri::command]
async fn play(state: tauri::State<'_, AppState>, id: i64) -> Result<(), String> {
    let mut p = state.player.lock().await;  // await 这里,合法
    p.play(id).map_err(|e| e.to_string())?;
    Ok(())
}

要点:

  • State 本身不要再包 Mutex,而是内部字段包。State<AppState> 克隆的是 Arc。
  • 读多写少用 RwLock。

Arc<RwLock<T>>

use tokio::sync::RwLock;

let libs: Arc<RwLock<Library>> = ...;
let r = libs.read().await;  // 多读并行
let mut w = libs.write().await; // 写独占

四、错误处理:thiserror + anyhow

在业务库(被别人调用)里定义具体错误枚举:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum LibraryError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("db: {0}")]
    Db(#[from] sqlx::Error),
    #[error("unsupported format: {0}")]
    UnsupportedFormat(String),
}

pub type Result<T> = std::result::Result<T, LibraryError>;

在上层(二进制或 command 层)用 anyhow:

pub async fn import_folder(path: &str) -> anyhow::Result<usize> {
    let songs = scan(path)?;          // From 转换自动实现
    for s in &songs { save(s).await?; }
    Ok(songs.len())
}

Tauri command 里返回什么

#[tauri::command] 的返回值必须能 serde。标准模式是:

#[tauri::command]
async fn import(path: String) -> Result<usize, String> {
    do_import(&path).await.map_err(|e| e.to_string())
}

前端拿到一个字符串 error,调用方 try/catch 或 TanStack Query 处理。

我们在 CloudTone 里会包装一个更漂亮的 AppError 枚举,并实现 serde::Serialize,让前端拿到结构化错误(第 20 章)。

五、serde:JSON 世界的通行证

use serde::{Serialize, Deserialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Song {
    pub id: i64,
    pub title: String,
    pub artist: String,
    pub duration_ms: i64,
    pub liked: bool,
}

#[serde(rename_all = "camelCase")] 让后端 snake_case 字段自动转为前端惯用的 camelCase,TS 类型对上。

嵌套与可选

#[derive(Serialize, Deserialize)]
struct Playlist {
    id: i64,
    name: String,
    description: Option<String>,   // 可选字段 -> TS: string | null
    songs: Vec<Song>,
}

枚举序列化

默认 Rust 枚举序列化成 {"TypeA": ...},对前端不友好。常用改写:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Message {
    PlaybackProgress { position: f64, duration: f64 },
    SongChanged(Song),
}
// 输出:{"type":"playbackProgress","data":{"position":1.2,"duration":200}}

六、trait object 与 Provider 模式

CloudTone 里「在线音源 Provider」就是 trait object:

#[async_trait::async_trait]
pub trait MusicProvider: Send + Sync {
    fn name(&self) -> &'static str;
    async fn search(&self, keyword: &str) -> anyhow::Result<Vec<Song>>;
    async fn stream_url(&self, song_id: &str) -> anyhow::Result<String>;
}

pub struct ProviderRegistry {
    providers: Vec<Box<dyn MusicProvider>>,
}

要点:

  • async trait 需要 async-trait crate(Rust 1.75 起支持原生 async fn in trait,但库生态还在过渡,Tauri 项目建议继续用 async-trait)。
  • trait 要对象安全:方法里别用泛型、别带 Self: Sized。

七、Tauri 专属:tauri::command 签名速查

// 无参
#[tauri::command]
fn ping() -> String { "pong".into() }

// 按名传参(前端:invoke("greet", { name: "a" }))
#[tauri::command]
async fn greet(name: String) -> String { format!("hi {}", name) }

// 注入 AppHandle
#[tauri::command]
async fn open_window(app: tauri::AppHandle) { /* ... */ }

// 注入窗口
#[tauri::command]
async fn resize(window: tauri::Window) { window.set_fullscreen(true).unwrap(); }

// 注入 State
#[tauri::command]
async fn get_songs(state: tauri::State<'_, AppState>) -> Vec<Song> { ... }

// 混合
#[tauri::command]
async fn play(app: tauri::AppHandle, state: tauri::State<'_, AppState>, id: i64) -> Result<(), String> { ... }

注意:

  • State<'_, T> 里的 '_ 来自 Tauri 的宏生成,别手写生命周期。
  • 命令签名里参数顺序无关紧要,Tauri 按类型注入。
  • 传原生 u64 的极大值会在 JS 里丢精度(见第 4 章陷阱)。

八、你必须装的 Rust 速查表

下面这些 crate 在 Tauri 项目里出现频率最高:

crate用途
serde + serde_json序列化
thiserror定义错误枚举
anyhow上层错误传递
async-traitasync trait
tokio异步运行时
tracing + tracing-subscriber结构化日志
reqwestHTTP 客户端
sqlxSQLite/MySQL 异步访问
urlURL 构造
once_cell全局 lazy static
uuid唯一 ID
chrono / time时间处理
dirs / directories跨平台目录
lofty音频元数据读取
symphonia音频解码
cpal音频输出
walkdir目录遍历

第 22 章以后我们会一个个用到。

常见陷阱

1. 在 Tauri command 里用 std::sync::Mutex 跨 .await,编译不过

换 tokio::sync::Mutex。

2. 闭包 move 了 non-Send 的数据到 tokio::spawn

常见于 Rc、RefCell、原生指针。改成 Arc<Mutex<_>>。

3. async fn in trait 抛 dyn-compatible 错误

用 #[async_trait]。

4. 数字精度丢失

前后端统一约定:DB id 用 i64,前端拿到是 number。不要用 u64。

5. Tauri State 更新找不到新值

State 是 &T,不是 &mut T。内部用 Mutex/RwLock 可变。

本章小结

  • async + tokio + Arc<Mutex> 是 Tauri 后端的基本调味品。
  • serde 接通前后端类型。
  • thiserror + anyhow 是错误处理的双子星。
  • trait object 搭 Provider 扩展点。

动手时刻

不用写代码,脑中回答:

  1. 为什么 Tauri State 里要包 Mutex?
  2. std::sync::Mutex 和 tokio::sync::Mutex 在 Tauri 里怎么选?
  3. #[tauri::command] async fn f(state: tauri::State<'_, S>) 的生命周期从哪里来?

答对进入下一部分:Tauri 核心。

第 8 章 Tauri 架构解剖:三进程模型与 WebView

本章目标

  • 看清楚一个 Tauri 应用跑起来之后,内存里到底发生了什么。
  • 理解「Core 进程、WebView 进程、Isolation 进程」三者的职责与通信。
  • 明白「IPC Bridge」这个词在 Tauri 里指什么。

一、一眼看懂的结构图

┌─────────────────────────────────────────────────────────────┐
│                      Tauri 应用 (OS 进程 1)                   │
│                                                             │
│   ┌──────────────────────────────────────────────────────┐  │
│   │   Core 进程 (Rust 主进程)                             │  │
│   │   - tauri::Builder                                  │  │
│   │   - Commands 注册表                                  │  │
│   │   - Window Manager / Menu / Tray                   │  │
│   │   - Tokio runtime                                   │  │
│   │   - 业务代码 (DB / 音频 / 网络)                        │  │
│   └──────────────────────────────────────────────────────┘  │
│                        ↑  ↓  IPC                             │
│   ┌──────────────────────────────────────────────────────┐  │
│   │   WebView 进程 (系统组件)                             │  │
│   │   - WKWebView / WebView2 / WebKitGTK               │  │
│   │   - 加载你的 React UI                                │  │
│   │   - @tauri-apps/api: invoke / listen                │  │
│   └──────────────────────────────────────────────────────┘  │
│                                                             │
│   (可选) Isolation 进程 — 运行中介层 JS,加固 IPC 安全        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
  • Core 进程:你的 Rust 二进制本身。这里跑 tokio、你的业务代码、你的 Command 注册表。
  • WebView 进程:操作系统的 WebView 组件。它是不同的进程(macOS 上你能在 Activity Monitor 看到 YourApp Helper (Renderer))。
  • IPC Bridge:把前端 invoke('x', {...}) 和后端 #[tauri::command] fn x(...) 接起来的那根"电话线"。

二、Core 进程:tauri::Builder 到底做了什么

一个 Tauri 应用的入口长这样:

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .manage(AppState::default())
        .setup(|app| { /* 启动钩子 */ Ok(()) })
        .invoke_handler(tauri::generate_handler![greet, play, pause])
        .on_window_event(|window, event| { /* 窗口事件 */ })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

过程拆解:

  1. tauri::generate_context!() 读取 tauri.conf.json + 资源文件,编译期嵌入到二进制。
  2. Builder 构造出 App,启动一个 tokio runtime。
  3. 根据 tauri.conf.json 的 windows 数组创建 WebView 窗口。每个窗口会向系统申请 WebView 实例。
  4. setup 闭包只在第一次启动时跑一次,适合初始化数据库、注册托盘等。
  5. 事件循环启动。主线程负责窗口和托盘事件;业务逻辑跑在 tokio 线程池上。

Plugins

Tauri 2.x 把很多原本内置的能力(文件系统、对话框、通知、HTTP、SQL 等)拆成了独立 crate tauri-plugin-*。好处是:

  • 不用的功能不编译,二进制更小。
  • 你可以写自己的插件(第 42 章会演示)。

三、WebView 进程:前端跑在哪里

你的 index.html 和 dist/assets/*.js 会通过 Tauri 的内嵌 HTTP server(开发时)或 tauri:// 协议(生产时)加载进 WebView。

有几件事必须清楚:

  • 前端不是跑在 Node.js 里。require、process、__dirname 都没有。
  • localStorage、IndexedDB、fetch 都是 WebView 自带的,可以用。但 fetch 调用外部域名要受 CSP 管控。
  • window.__TAURI__ 是 Tauri 注入的全局对象,@tauri-apps/api 底层就是调它。

多窗口

Tauri 可以开多个窗口。每个窗口是独立的 WebView 实例,它们之间不共享内存,但可以通过 Core 的事件总线通信。

CloudTone 会开至少 3 个窗口:主窗口、迷你播放器、桌面歌词。第 14、36 章会写。

四、IPC Bridge:invoke 到底走哪条路

前端:

import { invoke } from "@tauri-apps/api/core";
const res = await invoke<number>("add", { a: 1, b: 2 });

发生了什么:

  1. invoke 通过 WebView 的原生桥(iOS 的 WKScriptMessageHandler、Windows 的 postMessage 给 WebView2、Linux 的 webkit_user_content_manager)把消息 { cmd: "add", args: { a:1, b:2 } } 送到 Core。
  2. Core 在注册表里找到 add,反序列化参数。
  3. 调用 fn add(a: i64, b: i64) -> i64。
  4. 返回值序列化,走反向通道回到 WebView。
  5. 前端 Promise 解析。

IPC 有两种传输路径:

  • 默认 JSON 序列化(@tauri-apps/api/core 的 invoke):适合小数据。
  • Raw binary channel(Channel、InvokeResponseBody::Raw):传大二进制,比如音频波形、封面图流。

第 11 章写 invoke 的细节,第 12 章写 Channel 做流式通信。

五、Isolation 进程(可选)

Tauri 2 引入 Isolation Pattern:在 WebView 和 Core 之间插一层 JS「中介」,用来做权限校验、过滤、日志。前端发给 Core 的所有消息先经过 Isolation 脚本。

对小应用可以不用;对面向不可信插件的应用(比如 CloudTone 插件系统)很有价值。第 13、42 章会提到。

六、和 Electron 的架构对比

方面ElectronTauri
主进程Node.jsRust
渲染进程打包 Chromium系统 WebView
IPCipcRenderer.invoke / ipcMain.handleinvoke + #[tauri::command]
权限默认全开默认拒绝,显式 Capabilities
本地模块Node Native Modules (C++ N-API)Rust crates
包大小150MB+5–10MB

七、读源码小贴士(为第 48 章预热)

Tauri 核心仓库结构:

  • crates/tauri/ — 对外 crate,Builder、Manager。
  • crates/tauri-runtime/ — 运行时 trait 抽象。
  • crates/tauri-runtime-wry/ — 基于 wry 的默认实现(WebView 适配)。
  • crates/tauri-utils/ — 工具。
  • crates/tauri-macros/ — #[command]、generate_handler! 等宏实现。

你写 #[tauri::command] 的时候,宏在编译期生成了一段反序列化 + 调用的胶水。第 48 章我们会打开 cargo expand 看。

常见陷阱

以为 WebView 和 Core 是同一进程:它们在大多数系统上不是。在 WebView 里跑死循环不会冻住 Core;但 Core 调用 WebView 的方法会跨 IPC。

以为前端能直接 require('fs'):没有 Node,请走 #[tauri::command] 或者 tauri-plugin-fs 提供的桥接。

本章小结

  • Core + WebView + (可选) Isolation = Tauri 的三进程模型。
  • invoke / emit / listen = IPC 三板斧。
  • 架构决定了你做性能和安全取舍的思路。

动手时刻

打开第 2 章跑通的 smoke-test 应用,在 Activity Monitor / 任务管理器里找到它的所有进程,记下 PID。你会看到至少两个进程。

下一章:真正创建第一个带业务意义的 Tauri 应用。

第 9 章 创建第一个 Tauri 应用(create-tauri-app)

本章目标

  • 用 create-tauri-app 生成一个标准 Tauri 2 + React + TS + Vite + Tailwind 项目。
  • 把项目跑起来,看见第一个带按钮的 UI。
  • 知道每个生成文件的作用。

一、动手

打开终端:

pnpm create tauri-app@latest

交互式问答(按回车走过默认时注意):

  • Project name: tauri-hello
  • Identifier: dev.codecow.hello
  • Choose which language to use for your frontend: TypeScript / JavaScript
  • Choose your package manager: pnpm
  • Choose your UI template: React
  • Choose your UI flavor: TypeScript

完成后:

cd tauri-hello
pnpm install
pnpm tauri dev

第一次 build 大概 3–10 分钟。结束后看到窗口:Welcome 页面 + 一个输入框 + 一个按钮。

二、生成的目录

tauri-hello/
├── src/                    # 前端 (React + Vite 入口)
│   ├── App.tsx             # 根组件
│   ├── main.tsx            # ReactDOM 挂载
│   ├── assets/
│   ├── App.css
│   └── styles.css
├── src-tauri/              # 后端 (Rust)
│   ├── src/
│   │   ├── main.rs         # main 函数
│   │   └── lib.rs          # 大部分业务代码(Tauri 2 起的新约定)
│   ├── capabilities/
│   │   └── default.json    # 权限声明
│   ├── icons/              # 应用图标
│   ├── tauri.conf.json     # Tauri 配置
│   ├── Cargo.toml
│   └── build.rs
├── index.html              # 前端 HTML 模板
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
└── vite.config.ts

关键文件逐个看

src-tauri/tauri.conf.json

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "tauri-hello",
  "version": "0.1.0",
  "identifier": "dev.codecow.hello",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeDevCommand": "pnpm dev",
    "beforeBuildCommand": "pnpm build"
  },
  "app": {
    "windows": [
      { "title": "tauri-hello", "width": 800, "height": 600 }
    ],
    "security": { "csp": null }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.icns", "icons/icon.ico"]
  }
}
  • beforeDevCommand:在 tauri dev 启动后端之前先跑 pnpm dev,启动 Vite dev server。
  • devUrl:Tauri 在 dev 模式去这个地址拉前端。
  • frontendDist:发布模式下使用的前端产物目录。
  • app.windows:启动时创建的窗口。
  • security.csp:Content Security Policy。

src-tauri/src/main.rs

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    tauri_hello_lib::run()
}

极简,把真正的逻辑委托给 lib.rs。这是 Tauri 2 模板的新约定(方便移动端共用代码)。

src-tauri/src/lib.rs

#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

#[tauri::command] 注册一个命令,invoke_handler 把它挂到 IPC。

src/App.tsx

import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/core";
import "./App.css";

function App() {
  const [greetMsg, setGreetMsg] = useState("");
  const [name, setName] = useState("");

  async function greet() {
    setGreetMsg(await invoke("greet", { name }));
  }

  return (
    <main className="container">
      <h1>Welcome to Tauri!</h1>
      <form onSubmit={e => { e.preventDefault(); greet(); }}>
        <input value={name} onChange={e => setName(e.currentTarget.value)} placeholder="Enter a name..." />
        <button type="submit">Greet</button>
      </form>
      <p>{greetMsg}</p>
    </main>
  );
}

export default App;

三、开发流程是什么样

  • pnpm tauri dev 起两个东西:Vite dev server(端口 1420)+ Rust 后端。
  • 改前端:热更新,保存立刻刷新。
  • 改 Rust:重编译整个后端,窗口自动重开。
  • 构建产物:pnpm tauri build,产物在 src-tauri/target/release/bundle/。

四、加入 Tailwind

模板默认没有 Tailwind。我们一次性加上:

cd tauri-hello
pnpm add -D tailwindcss@3 postcss autoprefixer
pnpm exec tailwindcss init -p

编辑 tailwind.config.js:

export default {
  content: ["./index.html", "./src/**/*.{ts,tsx}"],
  darkMode: "class",
  theme: { extend: {} },
  plugins: [],
};

新建/覆盖 src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

html, body, #root { height: 100%; margin: 0; }
body { background: #0a0a0a; color: #e5e7eb; font-family: ui-sans-serif, system-ui; }

src/main.tsx 里 import "./index.css";。

删掉老的 App.css,把 App.tsx 改成:

import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";

export default function App() {
  const [name, setName] = useState("");
  const [msg, setMsg] = useState("");

  return (
    <main className="flex flex-col items-center justify-center h-full gap-4">
      <h1 className="text-3xl font-semibold">你好,Tauri</h1>
      <div className="flex gap-2">
        <input
          className="bg-zinc-800 rounded px-3 py-1 outline-none"
          value={name}
          onChange={e => setName(e.target.value)}
          placeholder="输入你的名字"
        />
        <button
          className="bg-brand-500 hover:bg-brand-600 rounded px-4 py-1"
          onClick={async () => setMsg(await invoke<string>("greet", { name }))}
        >
          打招呼
        </button>
      </div>
      {msg && <p className="text-gray-400">{msg}</p>}
    </main>
  );
}

注意 bg-brand-500 需要我们在 tailwind.config 里加 brand 色(见第 6 章)。

五、常见陷阱

pnpm tauri dev 卡在 Waiting for your frontend dev server

1420 端口被占用或 Vite 启动失败。先 pnpm dev 看错误。

Windows 上 error: linker link.exe not found

第 2 章的 C++ Build Tools 没装全。

invoke 调用报 Not allowed by ACL

Capabilities 不对。第 13 章详述。

WebView 白屏

常见于 Linux WebKitGTK。查 RUST_LOG=tauri=debug pnpm tauri dev 日志。或在 WebView 里按 F12(开发模式默认开 DevTools)。

本章小结

  • create-tauri-app 一键生成项目。
  • 前端 src/、后端 src-tauri/、配置 tauri.conf.json。
  • 热更新(前端)+ 重编译(Rust)是基本开发节奏。

动手时刻

  • 跑通 pnpm tauri dev。
  • 加上 Tailwind,把 UI 改成中文。
  • 在 lib.rs 加一个 #[tauri::command] async fn now() -> String 返回当前时间字符串,在前端显示。
  • 打一个 release 包:pnpm tauri build,看看 bundle 目录里的安装包。

下一章,工程化目录规范。

第 10 章 项目工程化与目录规范

本章目标

  • 把 tauri-hello 扩展成一个「能支撑上千行代码」的工程结构。
  • 敲定前端 / 后端目录分层。
  • 配置 ESLint、Prettier、路径别名 @/...。
  • 介绍 Cargo workspace 的用法(为 CloudTone 的多 crate 结构铺路)。

一、最终要长成这样

后面 CloudTone 的目录大致是:

cloudtone/
├── src/                            # 前端
│   ├── app/                        # 页面层(路由对应)
│   │   ├── home/
│   │   ├── library/
│   │   ├── playlist/
│   │   └── settings/
│   ├── components/                 # 可复用 UI
│   │   ├── ui/                     # shadcn/ui 生成
│   │   └── player/
│   ├── features/                   # 业务切片(Zustand store + hooks)
│   │   ├── player/
│   │   ├── library/
│   │   ├── lyrics/
│   │   └── download/
│   ├── lib/                        # 工具/封装(cn、invoke wrappers)
│   ├── types/                      # TS 类型
│   ├── main.tsx
│   ├── router.tsx
│   └── index.css
├── src-tauri/
│   ├── src/
│   │   ├── main.rs
│   │   ├── lib.rs
│   │   ├── cmds/                   # 按领域拆分的 command 模块
│   │   │   ├── mod.rs
│   │   │   ├── player.rs
│   │   │   ├── library.rs
│   │   │   └── settings.rs
│   │   ├── core/                   # 业务核心(与 Tauri 解耦)
│   │   │   ├── mod.rs
│   │   │   ├── audio/              # 音频引擎
│   │   │   ├── library/
│   │   │   ├── lyrics/
│   │   │   ├── db/
│   │   │   └── providers/
│   │   ├── state.rs                # 全局 AppState
│   │   ├── error.rs                # 统一错误
│   │   └── events.rs               # 事件定义
│   ├── capabilities/
│   ├── migrations/                 # SQL 迁移
│   ├── tauri.conf.json
│   ├── Cargo.toml
│   └── build.rs
├── packages/                       # 前端共享包(可选)
│   └── cloudtone-ipc/              # 前后端共享类型自动生成
├── .vscode/
├── .github/workflows/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
├── postcss.config.js
└── README.md

本章先按这个骨架改造出一个空壳,CloudTone 每一章在上面加砖加瓦。

二、Vite 路径别名

src/ 下目录深了,../../../../ 很难看。

vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";

export default defineConfig({
  plugins: [react()],
  server: { port: 1420, strictPort: true },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

tsconfig.json 里同步:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noImplicitReturns": true,
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] },
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["vite/client"]
  },
  "include": ["src"]
}

使用:

import { cn } from "@/lib/cn";
import { usePlayer } from "@/features/player/store";

三、ESLint + Prettier

pnpm add -D eslint @eslint/js typescript-eslint eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-react-refresh prettier eslint-config-prettier

eslint.config.js(flat config):

import js from "@eslint/js";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import prettier from "eslint-config-prettier";

export default [
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    files: ["src/**/*.{ts,tsx}"],
    plugins: { react, "react-hooks": reactHooks },
    rules: {
      ...react.configs.recommended.rules,
      ...reactHooks.configs.recommended.rules,
      "react/react-in-jsx-scope": "off",
      "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
    },
  },
  prettier,
];

.prettierrc.json:

{
  "semi": true,
  "singleQuote": false,
  "tabWidth": 2,
  "printWidth": 100,
  "trailingComma": "all"
}

.vscode/settings.json:

{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
  "editor.defaultFormatter": "esbenp.prettier-vscode"
}

四、后端模块分层

避开「所有 command 堆一起」的坑。拆成:

src-tauri/src/lib.rs —— 入口,只做注册:

mod cmds;
mod core;
mod error;
mod events;
mod state;

use state::AppState;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .manage(AppState::new())
        .setup(|app| {
            // 初始化数据库、扫描库等
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            cmds::player::play,
            cmds::player::pause,
            cmds::library::scan,
            cmds::settings::get_all,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

src-tauri/src/cmds/mod.rs:

pub mod player;
pub mod library;
pub mod settings;

src-tauri/src/cmds/player.rs(示意):

use crate::{state::AppState, error::AppError};
use serde::Serialize;

#[derive(Serialize)]
pub struct PlayerStatus { pub playing: bool, pub position: f64 }

#[tauri::command]
pub async fn play(state: tauri::State<'_, AppState>, song_id: i64) -> Result<(), AppError> {
    state.player().play(song_id).await
}

#[tauri::command]
pub async fn pause(state: tauri::State<'_, AppState>) -> Result<(), AppError> {
    state.player().pause().await
}

核心原则:command 层薄,业务逻辑放 core/。等到写测试时你就会感谢自己。

五、Cargo workspace(可选,但推荐)

当 Rust 代码超过 3000 行,建议拆子 crate。src-tauri/Cargo.toml 改成 workspace:

[workspace]
members = [".", "crates/audio", "crates/library", "crates/providers"]

[package]
name = "cloudtone"
version = "0.1.0"
edition = "2021"

[dependencies]
tauri = { version = "2", features = [] }
audio = { path = "crates/audio" }
library = { path = "crates/library" }
providers = { path = "crates/providers" }
# ...

然后 src-tauri/crates/audio/ 独立成 crate。好处:

  • audio 可以脱离 Tauri 单独测(只跑 cargo test -p audio)。
  • 编译缓存粒度更细。
  • 概念清晰:audio::Player 是「音频引擎」,不是「Tauri 命令」。

不强制,但 CloudTone 第 26 章会演示这么拆。

六、前后端共享类型

手写两遍 TS 类型和 Rust struct 是万恶之源。两种方案:

  1. ts-rs:在 Rust 里派生 TS,cargo test 时导出 .ts。
  2. specta / tauri-specta:为 Tauri 定制的方案,能自动生成类型安全的前端 invoke 包装。

CloudTone 采用 tauri-specta(2026 年社区主流)。第 22 章集成时详解。

七、提交规范与 Husky

可选,但生产项目建议配:

pnpm add -D husky lint-staged
pnpm husky init

.husky/pre-commit:

pnpm lint-staged

package.json:

"lint-staged": {
  "src/**/*.{ts,tsx}": ["eslint --fix", "prettier --write"],
  "src-tauri/src/**/*.rs": ["rustfmt"]
}

常见陷阱

别名 @/ 在 Rust 侧报错:别名是前端的事。Rust 侧用 crate::、super::。

改了 tsconfig.json 后 IDE 不生效:VS Code 需要 TypeScript: Restart TS Server。

Rust 模块私有访问:默认模块 item 是私有的。跨模块调用需要 pub。

本章小结

  • 分层清晰的前后端目录是可持续开发的前提。
  • 路径别名、ESLint、Prettier 不是花架子——团队协作必需。
  • command 层薄,业务放 core/。

动手时刻

把你第 9 章生成的项目按本章结构改造:

  • 加 @/ 路径别名并生效。
  • 把 greet 命令挪到 cmds/hello.rs。
  • 在 lib.rs 里只做注册。

下一章,正式讲 invoke 和 #[tauri::command] 的深水区。

第 11 章 前后端通信 1:Commands(invoke)

本章目标

  • 把 #[tauri::command] 的所有签名玩熟。
  • 掌握参数、返回值、错误、窗口与 AppHandle 注入。
  • 学会给前端生成类型安全的 invoke 包装(tauri-specta)。
  • 踩完「命名大小写」、「参数序列化」、「错误结构化」三大坑。

一、Command 的 5 种常用签名

// 1. 同步无参
#[tauri::command]
fn ping() -> String { "pong".into() }

// 2. 异步带参
#[tauri::command]
async fn add(a: i64, b: i64) -> i64 { a + b }

// 3. 带错误
#[tauri::command]
async fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 { Err("div by zero".into()) } else { Ok(a / b) }
}

// 4. 注入 AppHandle / Window / State
#[tauri::command]
async fn play(
    app: tauri::AppHandle,
    window: tauri::Window,
    state: tauri::State<'_, AppState>,
    song_id: i64,
) -> Result<(), AppError> {
    state.player().play(song_id).await?;
    app.emit("song-changed", song_id).ok();
    Ok(())
}

// 5. 结构化参数
#[derive(serde::Deserialize)]
struct ScanRequest { root: String, recursive: bool }

#[tauri::command]
async fn scan(req: ScanRequest) -> Result<usize, AppError> { /* ... */ }

前端调用:

import { invoke } from "@tauri-apps/api/core";

await invoke("ping");                                // "pong"
await invoke("add", { a: 1, b: 2 });                 // 3
await invoke("divide", { a: 6, b: 0 });              // 抛异常
await invoke("play", { songId: 123 });               // songId 自动映射 song_id
await invoke("scan", { req: { root: "/music", recursive: true } });

二、命名大小写:Rust snake_case ↔ JS camelCase

Tauri 自动把 song_id 映射到 songId(前端传 camelCase,后端接 snake_case)。这是 Tauri 2 的默认行为,由 rename_all="camelCase" 在宏层面实现。

两边约定清晰:

  • 命令名:Rust fn play → 前端 invoke("play")。
  • 参数名:Rust song_id → 前端 songId。
  • 如果 Rust 侧字段就叫 songId(不推荐),前端也传 songId。

三、返回值的序列化

返回值必须实现 serde::Serialize。CloudTone 里的常用返回类型:

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanResult {
    pub added: usize,
    pub updated: usize,
    pub skipped: usize,
    pub duration_ms: u64,
}

前端得到:

interface ScanResult { added: number; updated: number; skipped: number; durationMs: number; }

大字节流

返回几十 MB 的字节数组用 JSON 会把主线程卡住。两个办法:

  • #[tauri::command] 返回 Vec<u8>——Tauri 会走 binary channel,前端拿 ArrayBuffer。
  • 或者用 Channel<T> 做流式返回(见第 12 章)。

四、错误处理:结构化 AppError

直接 Result<T, String> 对调试不友好。推荐:

// src-tauri/src/error.rs
use serde::{Serialize, Serializer};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("db: {0}")]
    Db(#[from] sqlx::Error),
    #[error("audio: {0}")]
    Audio(String),
    #[error("not found: {0}")]
    NotFound(String),
    #[error("{0}")]
    Other(String),
}

impl Serialize for AppError {
    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        #[derive(Serialize)]
        struct Repr<'a> { kind: &'a str, message: String }
        let repr = match self {
            AppError::Io(e) => Repr { kind: "io", message: e.to_string() },
            AppError::Db(e) => Repr { kind: "db", message: e.to_string() },
            AppError::Audio(m) => Repr { kind: "audio", message: m.clone() },
            AppError::NotFound(m) => Repr { kind: "notFound", message: m.clone() },
            AppError::Other(m) => Repr { kind: "other", message: m.clone() },
        };
        repr.serialize(s)
    }
}

前端:

try {
  await invoke("play", { songId });
} catch (e) {
  // e 是 { kind: "audio", message: "..." } 或 string
  if (typeof e === "object" && e !== null && "kind" in e) {
    const err = e as { kind: string; message: string };
    toast.error(`[${err.kind}] ${err.message}`);
  } else {
    toast.error(String(e));
  }
}

五、State 注入与并发

#[tauri::command]
async fn set_volume(state: tauri::State<'_, AppState>, volume: f32) -> Result<(), AppError> {
    let mut player = state.player.lock().await;
    player.set_volume(volume);
    Ok(())
}

注意点:

  • State<'_, T> 的生命周期由宏自动填。不要手写 'a。
  • 同一个 State 会被多次并发调用。确保里面的同步原语正确(第 7 章)。

六、类型安全的前端包装:tauri-specta

默认 invoke<T>(cmd, args) 里 T 靠你手写。大项目两边签名很容易对不上。

方案:引入 specta + tauri-specta,自动把 Rust command 导出成 TS 函数。

Cargo.toml:

[dependencies]
specta = { version = "2.0.0-rc", features = ["serde", "derive"] }
tauri-specta = { version = "=2.0.0-rc", features = ["derive", "typescript"] }

在 command 上同时加 #[specta::specta]:

#[tauri::command]
#[specta::specta]
async fn add(a: i64, b: i64) -> i64 { a + b }

导出 TS:

use tauri_specta::{collect_commands, Builder};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let specta = Builder::<tauri::Wry>::new().commands(collect_commands![add, play, pause]);
    #[cfg(debug_assertions)]
    specta.export(
        specta_typescript::Typescript::default(),
        "../src/lib/ipc.ts",
    ).expect("export TS");

    tauri::Builder::default()
        .invoke_handler(specta.invoke_handler())
        .run(tauri::generate_context!())
        .unwrap();
}

pnpm tauri dev 一跑,src/lib/ipc.ts 自动生成:

// ipc.ts (auto-generated)
export const commands = {
  async add(a: number, b: number): Promise<number> { return invoke("add", { a, b }); },
  async play(songId: number): Promise<void> { return invoke("play", { songId }); },
  // ...
};

前端直接 commands.add(1,2) —— 改 Rust 签名编译器立刻报错。这是 CloudTone 生产级项目的标配。

七、invoke 的小魔法

取消

Tauri 2 的 invoke 目前不原生支持取消——但你可以通过 Channel + AbortController 模拟。

超时

封一个工具:

export async function invokeWithTimeout<T>(cmd: string, args: any, ms = 10000): Promise<T> {
  return Promise.race([
    invoke<T>(cmd, args),
    new Promise<T>((_, r) => setTimeout(() => r(new Error("timeout")), ms)),
  ]);
}

进度反馈

对长任务(扫库、下载),用 Events 或 Channel 往前端推进度,invoke 只负责启停。

常见陷阱

1. 前端 invoke("play", { song_id: 1 }),后端收不到

用 camelCase:{ songId: 1 }。

2. Result<(), AppError> 在 JS 里的 resolved value 是 null

() 会被序列化成 null。前端判空用 === null 或忽略返回值。

3. 返回很大的 Vec<u8>,内存爆

流式用 Channel;或者落盘后返回 path,前端用 asset:// 协议读。

4. 命令没注册:Command x not found

忘记写到 generate_handler![...]。

5. State<'_, T> 里的 T 没 Send + Sync

Rust 会大段报错。常见原因:T 里放了 Rc、RefCell;改成 Arc<Mutex<_>>。

本章小结

  • #[tauri::command] 是前后端桥梁。
  • camelCase / snake_case 自动转换。
  • AppError + serde::Serialize 让错误结构化。
  • tauri-specta 让前端类型安全,是生产级项目必备。

动手时刻

在你的 hello 项目里:

  • 加一个 get_system_info 命令返回结构体 { os: String, arch: String, memory_mb: u64 },前端展示。
  • 让它带上错误路径(模拟 NotFound),前端 toast 显示。
  • 接入 tauri-specta,生成 ipc.ts 后在前端用。

下一章,讲 Events:事件总线、订阅取消、与 Channel 的关系。

第 12 章 前后端通信 2:Events(emit / listen)

本章目标

  • 搞清 Events 和 Commands 的定位差异。
  • 掌握 emit、emit_to、listen、once、unlisten 全家桶。
  • 使用 Tauri 2 的 Channel<T> 做点对点的流式通信(比 emit/listen 更精准)。
  • 在 CloudTone 的「播放进度」「下载进度」「扫描进度」三个场景中做出正确选型。

一、一张图看清两种通信

模式方向特点适用
invoke (Command)JS → Rust → 返回值请求-响应(Promise)RPC 调用:play/pause/save
emit / listen (Event)任意 → 任意(广播)订阅-发布跨窗口通知、进度广播
Channel<T>Rust → JS(点对点)持续流,单消费者流式返回:下载进度、日志 tail

二、Events 基本用法

Rust 侧 emit

use tauri::Emitter;

#[tauri::command]
async fn hello_world(app: tauri::AppHandle) -> Result<(), ()> {
    app.emit("greeting", "Hello from Rust")?;  // 全局广播
    Ok(())
}

Rust 侧只发给特定窗口

app.emit_to("main", "greeting", "Hello main window")?;

Rust 侧发给当前窗口(在 command 里注入 Window)

#[tauri::command]
async fn refresh(window: tauri::Window) -> Result<(), ()> {
    window.emit("refreshed", ())?;
    Ok(())
}

前端 listen

import { listen, UnlistenFn } from "@tauri-apps/api/event";

const unlisten: UnlistenFn = await listen<string>("greeting", event => {
  console.log(event.payload);
});
// 组件卸载时调用
unlisten();

前端只监听一次

import { once } from "@tauri-apps/api/event";
await once<string>("app-ready", e => console.log(e.payload));

前端也可以 emit

import { emit } from "@tauri-apps/api/event";
await emit("frontend-event", { foo: 1 });

Rust 监听:

use tauri::Listener;

app.listen("frontend-event", |event| {
    let payload: Value = serde_json::from_str(event.payload()).unwrap();
    // ...
});

三、React Hook 封装

每次都写 useEffect + unlisten 太啰嗦。封一个:

// src/lib/hooks/useTauriEvent.ts
import { listen } from "@tauri-apps/api/event";
import { useEffect, useRef } from "react";

export function useTauriEvent<T>(name: string, handler: (payload: T) => void) {
  const savedHandler = useRef(handler);
  savedHandler.current = handler;

  useEffect(() => {
    const p = listen<T>(name, e => savedHandler.current(e.payload));
    return () => { p.then(unlisten => unlisten()); };
  }, [name]);
}

使用:

useTauriEvent<{ position: number }>("player:progress", ({ position }) => {
  setPosition(position);
});

四、事件命名约定

  • 用领域前缀:player:progress、library:scanned、download:state。
  • 进度类用 throttle:Rust 侧每 100ms 发一次就够了,别每帧发。
  • 事件 payload 用 #[serde(rename_all = "camelCase")] 保持前端一致。

五、Channel<T>:流式点对点

emit 是广播。如果一个 UI 组件订阅了 download:progress,其他窗口也会收到。有时你只想针对这一次调用的调用者发进度。

Tauri 2 引入 Channel<T>:

Rust 侧

use tauri::ipc::Channel;
use serde::Serialize;

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
enum DownloadEvent {
    Started { url: String, total: u64 },
    Progress { transferred: u64, bps: u64 },
    Done { path: String },
    Failed { error: String },
}

#[tauri::command]
async fn download(url: String, channel: Channel<DownloadEvent>) -> Result<(), AppError> {
    channel.send(DownloadEvent::Started { url: url.clone(), total: 0 })?;
    // ... 分块写 & 发 Progress
    channel.send(DownloadEvent::Done { path: "/tmp/a".into() })?;
    Ok(())
}

前端

import { Channel, invoke } from "@tauri-apps/api/core";

type DownloadEvent =
  | { event: "started"; data: { url: string; total: number } }
  | { event: "progress"; data: { transferred: number; bps: number } }
  | { event: "done"; data: { path: string } }
  | { event: "failed"; data: { error: string } };

const ch = new Channel<DownloadEvent>();
ch.onmessage = msg => {
  switch (msg.event) {
    case "progress": setProgress(msg.data.transferred); break;
    case "done": toast.success("下载完成"); break;
    case "failed": toast.error(msg.data.error); break;
  }
};
await invoke("download", { url: "https://...", channel: ch });

对比 emit/listen 的优势:

  • 点对点:不会误广播到其他订阅者。
  • 生命周期清晰:命令结束就结束。
  • 类型清晰:通过 serde(tag) 联合枚举传递。

CloudTone 里「下载」「全库扫描」「导出」三大场景都用 Channel。「播放进度」因为是全局状态仍用 emit。

六、跨窗口通信

场景:主窗口按了「播放」,迷你播放器窗口也要同步 UI。

最佳实践:所有 player 状态放 Rust(一个 AppState.player),前端通过 emit 事件同步。

async fn on_player_state_change(app: &tauri::AppHandle, state: &PlayerState) {
    app.emit("player:state", state).ok();
}

两个窗口都 listen("player:state"),自然同步。

不要试图做「窗口 A 直接发消息给窗口 B」——两边走 Core 中转更清晰,也方便做 throttle 和去重。

七、性能注意

  • 高频事件 throttle:播放进度如果 1ms 发一次,前端 React 渲染 + JSON 序列化会拖慢整个 app。Rust 侧用 tokio::time::interval(Duration::from_millis(100))。
  • 事件 payload 小:别一次发几百 KB 的歌词 JSON;只发 id,前端自己 invoke("get_lyrics", id)。
  • unlisten 要做:组件卸载不 unlisten 会泄露。

常见陷阱

1. listen 的返回值是 Promise

const un = listen(...) 得到的是 Promise,不是 unlisten 函数。要 await。

2. payload 发了带 BigInt / Date,前端炸

JSON 不支持。Rust 侧用 i64 / ISO 字符串。

3. 多个实例同时 listen,卸载错

React 严格模式下 useEffect 跑两次,要确保每次都正确 cleanup。本章上面的 useTauriEvent 已处理。

4. Channel 在 command 结束后还发消息

会被 drop 掉。要么保留 Channel(例如放到 State 里),要么改用 emit。

本章小结

  • invoke = 请求/响应;emit/listen = 广播;Channel = 点对点流。
  • 进度类场景 Channel 比 emit 更精准。
  • 跨窗口用「Core 中转」最健壮。

动手时刻

  • 在 hello 项目里写一个 tick 命令:Rust 每秒发一次 "tick:time" 事件带当前时间,前端显示。
  • 写一个 download 命令用 Channel 模拟进度(用 tokio::time::sleep + 0..100 的循环)。
  • 在 React 用上一章的 useTauriEvent hook。

下一章,Tauri 2 的权限核弹:Capabilities / ACL。

第 13 章 Tauri 2.x 权限与能力系统(Capabilities/ACL)

本章目标

  • 彻底理解 Tauri 2.x 为什么把权限完全重构。
  • 掌握 Capabilities JSON 的写法、作用域、平台过滤。
  • 学会给自定义命令声明 ACL。
  • 解决初学者 80% 的 "not allowed by ACL" 报错。

这一章是 Tauri 2 和 1 最大的差异。读不懂这一章,后面每一章你都会被 ACL 卡住。

一、为什么需要 Capabilities

Tauri 1 的权限粒度很粗:一个 allowList 写在 tauri.conf.json 里,开一项整个应用都能用。这有两个问题:

  1. 权限泄露:某个 WebView 被 XSS 攻破,全部权限都在它手里。
  2. 第三方内容:如果你嵌入第三方网页(比如插件窗口),它和你主界面共享权限。

Tauri 2 的模型:

  • 权限 = 一个 permission 条目,例如「允许调用 fs:read-text-file」。
  • 作用域 = 允许访问哪些路径、哪些 URL。
  • 能力(Capability) = 把一组权限 + 作用域绑定到指定的窗口。

一个 Capability 就是一条规则:「main 窗口可以读 $APPDATA/cloudtone 下的文件」。

二、Capabilities 文件在哪

项目结构:

src-tauri/
├── capabilities/
│   ├── default.json        # 适用于所有窗口
│   └── main-window.json    # 仅 main 窗口

一个最小 default.json:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "默认能力集",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:event:default",
    "core:window:default"
  ]
}

windows 字段:Capability 作用于哪些窗口 label。支持通配符 "*"。

permissions 字段:要启用的权限条目。core: 前缀是 Tauri 内置,插件的前缀是它自己的名字(例如 fs:、shell:、dialog:)。

三、权限条目的命名规则

<plugin-name>:<action>

常见:

  • core:window:allow-set-title
  • core:window:allow-close
  • core:event:default
  • core:path:default
  • fs:allow-read-text-file
  • fs:allow-write-text-file
  • dialog:allow-open
  • shell:allow-open
  • sql:default
  • http:default

每个插件的权限列表在它的 Cargo 目录下 permissions/ 能找到。

四、作用域(Scope)

对涉及资源访问的权限(文件、URL),可以加 scope 限制:

{
  "identifier": "fs-limited",
  "windows": ["main"],
  "permissions": [
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [
        { "path": "$APPDATA/cloudtone/**" },
        { "path": "$HOME/Music/**" }
      ],
      "deny": [
        { "path": "$APPDATA/cloudtone/secrets/**" }
      ]
    }
  ]
}

支持的变量:$APPDATA、$APPCONFIG、$APPLOCALDATA、$APPCACHE、$HOME、$DOWNLOAD、$DESKTOP、$DOCUMENT 等。

安全原则:能用变量就不写绝对路径;** 是递归通配,必要时用 * 限制到单层。

五、给自定义命令声明 ACL

默认情况下,你自己写的 #[tauri::command] 不需要在 capability 里声明,因为它们注册在 invoke handler 里,权限由你代码控制。但如果你用 tauri-plugin-* 提供的功能(比如 fs、shell),必须声明。

如果你想对自定义命令也做精细化 ACL(例如第三方插件只能调 library::search 不能调 library::delete),用 #[tauri::command] 的 fn + 命名约定 + capability 的 core:app:* 权限。完整做法在第 42 章插件系统里演示。

六、动态创建 Capability

有时需要运行时新增能力(比如用户勾选了「允许 CloudTone 访问 ~/Music」):

use tauri_plugin_fs::FsExt;

app.fs_scope().allow_directory("/Users/me/Music", true)?;

这是 fs 插件提供的运行时 scope 扩展。不走 capabilities 文件。

七、错误诊断:「not allowed by ACL」

典型错误日志:

Command not allowed by ACL: fs:allow-read-text-file

诊断三步:

  1. 确认该命令的 permission 名字。查插件仓库或 src-tauri/gen/schemas/acl-manifests.json。
  2. 去 capabilities/*.json 对应 windows 里加上这条 permission。
  3. 重启 pnpm tauri dev(capabilities 是编译期读取的,热更不生效)。

八、CloudTone 的 Capabilities 设计(预告)

主窗口 main:

  • core:default、core:event:default、core:window:default、core:path:default
  • fs:default + scope 限制到 $APPDATA/cloudtone/** 和用户选择的音乐目录
  • dialog:default(选目录)
  • http:default(调用在线音源 API)
  • notification:default
  • sql:default
  • log:default

迷你播放器窗口 mini:

  • core:default、core:window:allow-set-focus、core:event:default
  • 只允许订阅 player:* 事件,不允许任意 IPC 调用(通过 remote 策略)

桌面歌词窗口 lyric-overlay:

  • 只有窗口控制和事件订阅,不能写文件、不能调 HTTP。

这种分层让即使某个窗口被注入恶意脚本,能造成的破坏也很有限。

第 22 章会把这份配置一次性写出来。

九、Remote 内容与 Isolation

如果你 iframe 嵌了外部网站,或者加载第三方 HTML,Tauri 会把它视为「remote 内容」。给它的 capabilities 要加 remote 字段:

{
  "identifier": "third-party",
  "windows": ["plugin-*"],
  "remote": { "urls": ["https://plugins.cloudtone.app/**"] },
  "permissions": ["core:event:default"]
}

更狠的:Isolation Pattern(tauri.conf.json 的 security.pattern)在 WebView 和 Core 之间插一层 JS,把每个 IPC 消息过一遍。适合装载不可信插件的场景。第 42 章讲。

常见陷阱

1. 改了 capability,没重启

必须重启 tauri dev。

2. 作用域 $APPDATA 找不到文件

Windows 和 macOS 的 APPDATA 不同。用 tauri::path::resolve 或 dirs::config_dir() 统一。

3. 权限带 deny 但 allow 更广

Tauri 的规则是 deny 优先,但要求 allow 必须先命中。所以模式是「先 allow 粗粒度,再 deny 细粒度」。

4. 多个 capability 冲突

同一 window 受多个 capability 加权:任意允许就算允许。小心「一个宽松的 capability 把另一个紧的盖掉」。

本章小结

  • Capability = 权限 + 作用域 + 窗口绑定。
  • Tauri 2 默认拒绝一切,显式开放。
  • 每个窗口按「最小权限」分配。
  • not allowed by ACL 99% 的问题都在 capability 里。

动手时刻

在你的 hello 项目里:

  • 创建一个 plugin-window(第 14 章会教,先看文档)。
  • 让它只能订阅事件,不能调用任何命令。试试 invoke 是否被阻止。

下一章,窗口、菜单、系统托盘。

第 14 章 窗口、菜单与系统托盘

本章目标

  • 动态创建、关闭、定位、隐藏窗口。
  • 做一个跨平台的「最小化到托盘」行为。
  • 搭建原生菜单(macOS 应用菜单、Windows/Linux 右键菜单)。

一、窗口 API

从 config 创建(静态)

tauri.conf.json:

"app": {
  "windows": [
    {
      "label": "main",
      "title": "CloudTone",
      "width": 1200, "height": 780, "minWidth": 960, "minHeight": 600,
      "center": true, "decorations": true, "resizable": true,
      "titleBarStyle": "Overlay"
    }
  ]
}

label 是窗口唯一 ID,代码里通过它拿到窗口实例。

运行时创建(动态)

use tauri::{WebviewUrl, WebviewWindowBuilder};

fn open_mini_player(app: &tauri::AppHandle) -> tauri::Result<()> {
    WebviewWindowBuilder::new(app, "mini", WebviewUrl::App("mini.html".into()))
        .title("CloudTone Mini")
        .inner_size(340.0, 140.0)
        .always_on_top(true)
        .decorations(false)
        .skip_taskbar(true)
        .transparent(true)
        .build()?;
    Ok(())
}

常用选项:

  • inner_size / min_inner_size / max_inner_size
  • position(x, y) / center()
  • decorations(false) 去掉系统边框
  • transparent(true) 透明窗
  • always_on_top(true) 置顶
  • skip_taskbar(true) 不在任务栏显示
  • visible(false) 创建时先不显示

获取已有窗口

use tauri::Manager;

let main = app.get_webview_window("main").unwrap();
main.hide()?;
main.show()?;
main.set_focus()?;
main.set_title("新标题")?;
main.set_size(tauri::PhysicalSize::new(800, 600))?;

前端调用:

import { getCurrentWindow } from "@tauri-apps/api/window";
const win = getCurrentWindow();
await win.setTitle("Hello");
await win.minimize();

拖拽区域(Drag Region)

无边框窗口需要你自己指定哪里可以拖:

<div data-tauri-drag-region className="h-10 bg-zinc-900 flex items-center px-3">
  CloudTone
</div>

CSS 层面可用 app-region: drag;,但 Tauri 推荐用 data-tauri-drag-region 属性,兼容好。

二、窗口事件

use tauri::WindowEvent;

window.on_window_event(move |ev| match ev {
    WindowEvent::CloseRequested { api, .. } => {
        api.prevent_close();
        window_clone.hide().ok();  // 改为隐藏
    }
    WindowEvent::Focused(true) => { /* ... */ }
    _ => {}
});

CloudTone 的 "关闭即隐藏到托盘" 就靠 prevent_close + 托盘菜单呼出。

前端:

import { getCurrentWindow } from "@tauri-apps/api/window";
const unlisten = await getCurrentWindow().onResized(e => console.log(e.payload));

三、系统托盘(System Tray)

Tauri 2.x 托盘是一等公民。示例:

use tauri::{
    menu::{Menu, MenuItem, PredefinedMenuItem, MenuBuilder},
    tray::{TrayIconBuilder, TrayIconEvent, MouseButton, MouseButtonState},
    Manager,
};

fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
    let show = MenuItem::with_id(app, "show", "显示主界面", true, None::<&str>)?;
    let play = MenuItem::with_id(app, "play", "播放/暂停", true, Some("Space"))?;
    let next = MenuItem::with_id(app, "next", "下一首", true, None::<&str>)?;
    let prev = MenuItem::with_id(app, "prev", "上一首", true, None::<&str>)?;
    let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
    let sep = PredefinedMenuItem::separator(app)?;

    let menu = Menu::with_items(app, &[&show, &sep, &prev, &play, &next, &sep, &quit])?;

    TrayIconBuilder::with_id("main-tray")
        .icon(app.default_window_icon().cloned().unwrap())
        .menu(&menu)
        .on_menu_event(|app, event| match event.id.as_ref() {
            "show" => { let _ = app.get_webview_window("main").unwrap().show(); }
            "play" => { app.emit("player:toggle", ()).ok(); }
            "next" => { app.emit("player:next", ()).ok(); }
            "prev" => { app.emit("player:prev", ()).ok(); }
            "quit" => { app.exit(0); }
            _ => {}
        })
        .on_tray_icon_event(|tray, event| {
            if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
                let app = tray.app_handle();
                if let Some(w) = app.get_webview_window("main") {
                    let _ = w.show(); let _ = w.set_focus();
                }
            }
        })
        .build(app)?;
    Ok(())
}

在 setup 里调用:

.setup(|app| {
    setup_tray(app.handle())?;
    Ok(())
})

Linux 提示:托盘图标需要桌面环境支持 StatusNotifierItem。GNOME 需要装 AppIndicator 扩展,KDE/Ubuntu 默认 ok。

四、窗口菜单(macOS 顶部菜单栏 / 菜单键)

use tauri::menu::{MenuBuilder, SubmenuBuilder};

fn build_menu(app: &tauri::AppHandle) -> tauri::Result<tauri::menu::Menu<tauri::Wry>> {
    let app_menu = SubmenuBuilder::new(app, "CloudTone")
        .about(Some(Default::default()))
        .separator()
        .services()
        .separator()
        .hide()
        .hide_others()
        .show_all()
        .separator()
        .quit()
        .build()?;

    let file_menu = SubmenuBuilder::new(app, "文件")
        .text("new-playlist", "新建歌单")
        .text("import", "导入音乐...")
        .separator()
        .close_window()
        .build()?;

    let menu = MenuBuilder::new(app).items(&[&app_menu, &file_menu]).build()?;
    Ok(menu)
}

// setup 时
let menu = build_menu(app.handle())?;
app.set_menu(menu)?;

菜单事件绑定:

.on_menu_event(|app, event| {
    match event.id.as_ref() {
        "new-playlist" => app.emit("ui:new-playlist", ()).ok(),
        "import" => app.emit("ui:import", ()).ok(),
        _ => None,
    };
})

五、右键上下文菜单(前端自实现)

原生 ContextMenu 目前 Tauri 只在桌面有限支持。推荐前端用 Radix/shadcn 的 ContextMenu 组件,体验更一致。除非你确实需要系统菜单(比如 macOS 的 Emoji & Symbols),那再考虑原生。

六、多窗口协作:CloudTone 的三窗口布局

main           (1200x780)
mini-player    (340x140, always_on_top, transparent)
lyric-overlay  (全屏宽, 固定高, always_on_top, ignore_cursor_events)

lyric-overlay 有个难点:鼠标穿透。让它不挡住后面的 UI:

lyric_window.set_ignore_cursor_events(true)?;

再加个 "显示/隐藏桌面歌词" 快捷键(下一章讲)。

常见陷阱

1. hide() 后 show() 不显示

macOS 有时需要先 set_focus() 或 show 后等一帧。Linux 上 hide 真正关闭子渲染进程,show 会重新创建。

2. 无边框窗口在 macOS 有黑边

titleBarStyle: "Overlay" + hiddenTitle: true 组合可达 native macOS 风格。

3. 托盘点击没反应

Linux 下左键可能被发行版当成弹菜单。右键弹菜单是最稳的行为。

4. 前端 getCurrentWindow() 拿到了别的窗口

每个窗口的 getCurrentWindow 都返回自己的实例,但 Window.getByLabel("main") 可能跨窗口。注意心智模型。

本章小结

  • 窗口 = WebviewWindow,可以 config 也可以 runtime 创建。
  • 托盘 + 菜单靠 tray / menu 模块。
  • CloudTone 规划三个窗口:主、迷你、桌面歌词。

动手时刻

在 hello 项目里:

  • 点击关闭按钮不退出,而是隐藏到托盘。
  • 托盘右键菜单「显示主界面」「退出」。
  • 新建一个无边框、置顶、透明的 mini 窗口。

下一章,全局快捷键与单实例。

第 15 章 全局快捷键与单实例

本章目标

  • 用 tauri-plugin-global-shortcut 注册系统级快捷键。
  • 用 tauri-plugin-single-instance 保证只有一个实例运行。
  • 设计 CloudTone 的默认快捷键表并做冲突处理。

一、全局快捷键(系统级)

"全局" 意味着 app 在后台也能响应。比如用户按 Media Play/Pause 键,CloudTone 应该切歌——即使当前焦点在浏览器。

安装插件

pnpm tauri add global-shortcut

等价于手动:

# src-tauri/Cargo.toml
tauri-plugin-global-shortcut = "2"
// src-tauri/src/lib.rs
.plugin(tauri_plugin_global_shortcut::Builder::new().build())

capability(默认可开):"global-shortcut:default"。

注册快捷键

use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState, Code, Modifiers};

.setup(|app| {
    let short = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::KeyP);
    app.global_shortcut().on_shortcut(short, move |app, _sc, event| {
        if event.state == ShortcutState::Pressed {
            app.emit("shortcut:toggle-play", ()).ok();
        }
    })?;
    Ok(())
})

或者传字符串:

app.global_shortcut().on_shortcut("CommandOrControl+Alt+P", |app, _, _| {
    app.emit("shortcut:toggle-play", ()).ok();
})?;

前端动态注册

import { register, unregister, isRegistered } from "@tauri-apps/plugin-global-shortcut";

await register("CommandOrControl+Shift+F", () => {
  invoke("focus_search");
});

// 解绑
await unregister("CommandOrControl+Shift+F");

CloudTone 默认快捷键表

功能macOSWin/Linux
播放/暂停Cmd+Alt+PCtrl+Alt+P
下一曲Cmd+Alt+RightCtrl+Alt+Right
上一曲Cmd+Alt+LeftCtrl+Alt+Left
音量+Cmd+Alt+UpCtrl+Alt+Up
音量-Cmd+Alt+DownCtrl+Alt+Down
显示/隐藏主窗口Cmd+Alt+SpaceCtrl+Alt+Space
切换桌面歌词Cmd+Alt+LCtrl+Alt+L
快速搜索Cmd+Alt+FCtrl+Alt+F

快捷键要可被用户自定义,第 47 章讲配置页。

冲突与异常

某些快捷键已被系统或其他 app 占用。注册会返回错误。良好做法:

  1. 尝试注册,失败就记日志。
  2. 在设置页列出 "冲突",让用户改键。
  3. 支持 Media Keys(MediaPlayPause, MediaNextTrack, MediaPrevTrack),但 macOS 需要特殊权限(见第 37 章)。

二、单实例(Single Instance)

用户双击图标两次,通常你希望只激活已有窗口,而不是再开一个 app。

安装

pnpm tauri add single-instance
// 注意:必须是第一个 plugin
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
    println!("another instance launched with {:?}, cwd {}", argv, cwd);
    if let Some(win) = app.get_webview_window("main") {
        let _ = win.show();
        let _ = win.set_focus();
    }
    // 如果 argv 里有音频文件路径,就当作"打开"
    for arg in argv.iter().skip(1) {
        app.emit("app:open-file", arg).ok();
    }
}))

双击音频文件启动

在 tauri.conf.json 里声明文件关联:

"bundle": {
  "fileAssociations": [
    {
      "ext": ["mp3", "flac", "m4a", "wav", "ogg"],
      "name": "CloudTone Audio",
      "role": "Editor"
    }
  ]
}

macOS 会把拖拽/双击的文件通过 argv[1] 传给已运行实例——上面的回调处理它。

三、快捷键 × 单实例 × 托盘 合流示例

.setup(|app| {
    // 1. 托盘
    setup_tray(app.handle())?;

    // 2. 全局快捷键
    let h = app.handle().clone();
    app.global_shortcut().on_shortcut("CommandOrControl+Alt+P", move |_, _, e| {
        if e.state == ShortcutState::Pressed {
            h.emit("shortcut:toggle-play", ()).ok();
        }
    })?;

    Ok(())
})

前端:

useTauriEvent("shortcut:toggle-play", () => {
  usePlayerStore.getState().toggle();
});

常见陷阱

1. macOS 上媒体键没触发

macOS 的 F7/F8/F9 是系统键,需要在系统设置 → 键盘 → 快捷键里把 "Use F1, F2 as function keys" 打开,或者你申请 Accessibility 权限(见第 37 章)。

2. single-instance 插件没装或注册顺序错

必须是第一个 plugin,否则不生效。

3. 动态注册快捷键后 app 重启失效

动态注册不会持久化。把用户设置存 DB/config,启动时再注册。

4. Linux 上全局快捷键不触发

Wayland 目前不提供全局键盘 hook。Tauri 可能通过 X11 / portal 工作。发行版差异较大,测 Ubuntu/Fedora 两套。

本章小结

  • 全局快捷键 + 单实例 = 合格的桌面应用体验。
  • Tauri 2 插件化让这些能力按需启用。
  • CloudTone 默认快捷键表要易用、不冲突、可自定义。

动手时刻

在 hello 项目里:

  • 加 global-shortcut 插件,绑 Ctrl+Alt+P 触发事件,前端 toast 一下。
  • 加 single-instance,验证双击 exe 只激活现有窗口。
  • 在 fileAssociations 里加 .mp3,试试双击能否传到 argv。

下一章,State 并发:Arc<Mutex> 的正确打开方式。

第 16 章 状态管理与并发:State + async + Mutex

本章目标

  • 设计 Tauri 应用的全局 AppState 结构。
  • 搞清 std::sync::Mutex 和 tokio::sync::Mutex 在 Tauri 里的取舍。
  • 用 watch / broadcast channel 做「内部事件总线」。
  • 避免最常见的死锁与 !Send 编译报错。

一、AppState 该长什么样

CloudTone 的全局状态集:

// src-tauri/src/state.rs
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock, broadcast};
use sqlx::SqlitePool;

use crate::core::{
    audio::Player,
    library::LibraryManager,
    providers::ProviderRegistry,
    settings::Settings,
};

pub struct AppState {
    pub db: SqlitePool,
    pub player: Arc<Mutex<Player>>,
    pub library: Arc<RwLock<LibraryManager>>,
    pub providers: Arc<ProviderRegistry>,
    pub settings: Arc<RwLock<Settings>>,
    pub events: broadcast::Sender<InternalEvent>,
}

#[derive(Clone, Debug)]
pub enum InternalEvent {
    PlayerStarted { song_id: i64 },
    PlayerPaused,
    PlayerResumed,
    PlayerStopped,
    PositionChanged(f64),
    LibraryUpdated,
    SettingsChanged,
}

impl AppState {
    pub async fn new(db: SqlitePool) -> Self {
        let (tx, _) = broadcast::channel(64);
        Self {
            db,
            player: Arc::new(Mutex::new(Player::new(tx.clone()))),
            library: Arc::new(RwLock::new(LibraryManager::new())),
            providers: Arc::new(ProviderRegistry::default()),
            settings: Arc::new(RwLock::new(Settings::load().await)),
            events: tx,
        }
    }
}

要点:

  1. 外面不要再套 Arc:Tauri 自己 manage(state) 时会包一层,内部字段用 Arc<Mutex> 就够。
  2. 读多写少用 RwLock:settings、library 都是。
  3. 内部事件 broadcast:audio 线程往里发,其它模块可以订阅。

二、std::sync::Mutex vs tokio::sync::Mutex

std::sync::Mutextokio::sync::Mutex
MutexGuard 是 Send❌(在 Linux/Windows 上)✅
阻塞调用者线程✅ 会 park OS 线程❌ 跑协程
锁持有期间能 .await❌(guard 不 Send)✅
性能无抢占时极快略慢
适用只在同步代码里短暂加锁需要跨 .await 持锁

口诀:

  • 如果你的锁只保护一小段非 async 代码,用 std::sync::Mutex(更快)。
  • 如果你需要在持锁期间做 I/O、调 command、.await,用 tokio::sync::Mutex。

踩坑:下面这段编译不过:

let mut p = state.player.lock().unwrap();  // std::sync
// p 持有 MutexGuard(!Send)
something.await;   // ❌ future not Send

换 tokio::sync::Mutex::lock().await 即可。

三、避免死锁

Rust 的锁不是魔法——依然会死锁。典型场景:

let mut p = state.player.lock().await;   // 锁 player
let mut l = state.library.write().await; // 再锁 library
// 另一个任务先锁 library,再试图锁 player → 死锁

规则:

  1. 固定加锁顺序。全局约定 player > library > settings。
  2. 锁作用域尽量小。别在锁里调用可能阻塞的东西。
  3. 别在持锁时 emit 事件(emit 可能同步触发监听者)。改用 broadcast channel 解耦。

四、watch 和 broadcast:解耦事件流

tokio::sync::broadcast

1 生产者,多消费者。每个消费者有自己的 queue。

let (tx, _) = broadcast::channel::<InternalEvent>(64);

// 生产者
tx.send(InternalEvent::PlayerPaused).ok();

// 消费者 1
let mut rx = tx.subscribe();
tokio::spawn(async move {
    while let Ok(ev) = rx.recv().await {
        match ev { InternalEvent::PlayerPaused => {...}, _ => {} }
    }
});

CloudTone 用它把 Audio 线程的事件广播给 Command 层、Library 层、Recent 历史记录等。

tokio::sync::watch

1 生产者,多消费者,但只保留最新值。适合「当前播放进度」这种。

let (tx, rx) = watch::channel(0.0f64);

// 播放线程
tx.send(1.23).ok();

// UI 订阅
let mut rx = rx.clone();
tokio::spawn(async move {
    while rx.changed().await.is_ok() {
        let v = *rx.borrow();
        // ...
    }
});

五、把后台工作留给 tokio::spawn

Tauri command 是跑在 tokio runtime 上的。但别把长任务直接塞进 command,前端会等死。

正确姿势:

#[tauri::command]
async fn start_scan(app: tauri::AppHandle, state: tauri::State<'_, AppState>, path: String) -> Result<(), AppError> {
    let lib = state.library.clone();
    let h = app.clone();
    tokio::spawn(async move {
        let result = lib.write().await.scan_folder(&path, |progress| {
            h.emit("scan:progress", progress).ok();
        }).await;
        h.emit("scan:done", result.map_err(|e| e.to_string())).ok();
    });
    Ok(())
}

立刻返回,让前端自由。用事件推进度。

六、CPU 密集任务:spawn_blocking 和 Rayon

音频 FFT、封面图解码这些,不走 tokio scheduler:

let handle = tokio::task::spawn_blocking(move || {
    // 同步的 CPU 密集
    compute_fft(buf)
});
let result = handle.await.unwrap();

大规模并行(全库扫描)可以用 rayon:

use rayon::prelude::*;

let metas: Vec<_> = paths.par_iter()
    .filter_map(|p| read_metadata(p).ok())
    .collect();

不要在 tokio async 里直接 par_iter 长时间运行——它会吃 tokio worker 线程。要么 spawn_blocking 包住,要么起独立 rayon thread pool。

七、让状态跨 #[tauri::command] 可变

三种常见模式:

模式 A:Arc<Mutex<T>>

pub struct AppState { pub counter: Arc<Mutex<i64>> }

#[tauri::command]
async fn inc(state: tauri::State<'_, AppState>) -> i64 {
    let mut c = state.counter.lock().await;
    *c += 1;
    *c
}

模式 B:内部可变性 Actor 模式

对 Player 这种有自己线程的模块,更好的方式是让它跑一个 tokio 任务,外部只通过 mpsc 通道给它发命令:

pub struct PlayerHandle {
    tx: tokio::sync::mpsc::Sender<PlayerCmd>,
}

enum PlayerCmd {
    Play(i64),
    Pause,
    Seek(f64),
}

// 外部调用
player_handle.tx.send(PlayerCmd::Play(id)).await?;

内部只有一个消费者,锁都不要。Actor 模式是生产项目里最推荐的。第 26 章详细讲。

模式 C:OnceCell 延迟初始化

use once_cell::sync::OnceCell;
static DB: OnceCell<SqlitePool> = OnceCell::new();

小心:全局静态和 #[tauri::command] 的 State 注入可以混用,但能用 State 就用 State,不要乱用全局。

常见陷阱

1. future cannot be sent between threads safely

锁 guard 跨 await。换 tokio::sync::Mutex 或提前 drop。

2. State<'_, T> 里 T 不是 Send + Sync + 'static

检查字段:有没有 Rc、RefCell、原生指针。

3. 两个 command 加锁顺序不同,死锁

文档化锁顺序,或者改 Actor 模式。

4. 频繁加锁导致性能下降

读多写少用 RwLock;热点数据放 DashMap。

本章小结

  • AppState = 应用的神经中枢,用 Arc<Mutex/RwLock> 或 Actor 模式。
  • 跨 await 持锁必须 tokio::sync::Mutex。
  • broadcast / watch / mpsc 是解耦神器。
  • Long task 要 spawn + 事件反馈。

动手时刻

在 hello 项目里:

  • 加一个 counter: Arc<Mutex<i64>> 到 AppState,前端点按钮 inc。
  • 加一个 start_long_task 命令,用 spawn + 事件发 0..10 的进度。
  • 尝试不用 spawn,直接在 command 里 sleep(5s),观察前端冻结。

下一章,文件系统与路径。

第 17 章 文件系统、路径与应用数据目录

本章目标

  • 理解 Tauri 里「应用数据目录」的 7 种预定义位置。
  • 学会跨平台地构造路径,避免硬编码。
  • 用 tauri-plugin-fs 给前端开放受控文件访问。
  • 用 tokio::fs / std::fs / walkdir 在 Rust 侧做扫描与读写。

一、跨平台目录是大坑的缩影

同一个「应用配置」在三个系统下的真实位置:

  • macOS: ~/Library/Application Support/dev.codecow.cloudtone/
  • Windows: C:\Users\<user>\AppData\Roaming\dev.codecow.cloudtone\
  • Linux: ~/.config/dev.codecow.cloudtone/

人肉拼路径是灾难。Tauri 提供了统一 API。

二、Tauri 预定义目录

变量用途示例 (macOS)
$APPDATA应用数据(跨端唯一的)~/Library/Application Support/<id>
$APPCONFIG配置同上
$APPLOCALDATA本机数据~/Library/Application Support/<id>
$APPCACHE缓存~/Library/Caches/<id>
$APPLOG日志~/Library/Logs/<id>
$HOME家目录~
$DESKTOP / $DOWNLOAD / $DOCUMENT / $MUSIC / $PICTURE用户目录—

Rust 侧

use tauri::{AppHandle, Manager};

let app_data: PathBuf = app.path().app_data_dir()?;
let cache_dir: PathBuf = app.path().app_cache_dir()?;
let music: PathBuf = app.path().audio_dir()?;  // 用户音乐目录

前端

import { appDataDir, audioDir } from "@tauri-apps/api/path";
const appData = await appDataDir();
const music = await audioDir();

三、tauri-plugin-fs:受控文件访问

Tauri 2 不再让前端任意读写磁盘。必须通过 fs 插件 + capability 里声明 scope。

装插件

pnpm tauri add fs

Capability

{
  "identifier": "main",
  "windows": ["main"],
  "permissions": [
    "fs:default",
    { "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$APPDATA/cloudtone/**" }] },
    { "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$APPDATA/cloudtone/**" }] },
    { "identifier": "fs:allow-exists", "allow": [{ "path": "$MUSIC/**" }] }
  ]
}

前端 API

import { readTextFile, writeTextFile, exists, BaseDirectory } from "@tauri-apps/plugin-fs";

const text = await readTextFile("cloudtone/config.json", { baseDir: BaseDirectory.AppData });
await writeTextFile("cloudtone/config.json", JSON.stringify(cfg), { baseDir: BaseDirectory.AppData });

原则:

  • 大部分文件操作应该在 Rust 里做,让前端只操心 UI。
  • 只有非常小、非常明确的场景才让前端直接 readTextFile。

四、Rust 侧读写 & 扫描

读写 JSON 配置

use tokio::fs;
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Default)]
pub struct Settings { pub volume: f32, pub theme: String }

pub async fn load_settings(app: &tauri::AppHandle) -> anyhow::Result<Settings> {
    let path = app.path().app_data_dir()?.join("settings.json");
    if !path.exists() { return Ok(Settings::default()); }
    let s = fs::read_to_string(&path).await?;
    Ok(serde_json::from_str(&s)?)
}

pub async fn save_settings(app: &tauri::AppHandle, s: &Settings) -> anyhow::Result<()> {
    let dir = app.path().app_data_dir()?;
    fs::create_dir_all(&dir).await?;
    fs::write(dir.join("settings.json"), serde_json::to_vec_pretty(s)?).await?;
    Ok(())
}

递归扫描音乐目录

use walkdir::WalkDir;

pub fn find_audio_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
    const EXT: [&str; 8] = ["mp3","flac","m4a","wav","ogg","aac","aiff","ape"];
    WalkDir::new(root)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.file_type().is_file())
        .filter(|e| e.path().extension()
            .and_then(|s| s.to_str())
            .map(|s| EXT.contains(&s.to_lowercase().as_str()))
            .unwrap_or(false))
        .map(|e| e.into_path())
        .collect()
}

流式读取

大文件要避免 read() 一把梭。用 tokio::io::AsyncReadExt:

use tokio::fs::File;
use tokio::io::AsyncReadExt;

let mut f = File::open(path).await?;
let mut buf = vec![0u8; 64 * 1024];
loop {
    let n = f.read(&mut buf).await?;
    if n == 0 { break; }
    // 处理 buf[..n]
}

五、拖拽文件进窗口

前端监听:

import { getCurrentWebview } from "@tauri-apps/api/webview";
const unlisten = await getCurrentWebview().onDragDropEvent(async e => {
  if (e.payload.type === "drop") {
    for (const path of e.payload.paths) {
      await invoke("library_import_files", { paths: [path] });
    }
  }
});

得到的 path 是本机绝对路径。注意 capability 允许后端处理即可,前端只传递字符串。

六、通知文件变化:notify crate

CloudTone 的「音乐库自动刷新」依赖监听目录变化。

notify = "6"
use notify::{Watcher, RecursiveMode, RecommendedWatcher, Config};

let (tx, mut rx) = tokio::sync::mpsc::channel(16);
let mut watcher = RecommendedWatcher::new(move |res| {
    let _ = tx.blocking_send(res);
}, Config::default())?;
watcher.watch(root, RecursiveMode::Recursive)?;

while let Some(Ok(event)) = rx.recv().await {
    // 按事件类型决定是否重新扫描
}

第 28 章完整接入。

七、跨平台路径

  • 不要硬编码 /,用 PathBuf::join。
  • 不要假设 UTF-8,Rust OsStr 才是真相。大部分场景 path.to_string_lossy() 足矣。
  • Windows 的 \\?\... 长路径前缀在某些 API 下会闹。dunce::canonicalize 消除之。

常见陷阱

1. 前端 readTextFile 报 forbidden path

capability 里没声明 scope 或路径越界。

2. 中文路径在 Windows 上打不开

99% 是编码问题。统一 UTF-8。fs::read 用 PathBuf 别用字符串拼。

3. app_data_dir() 返回 None

在某些 Linux headless 环境可能失败。设置 XDG_DATA_HOME 或提前 fs::create_dir_all。

4. notify 热更风暴

保存一个文件可能触发多次事件。用 debounce:notify-debouncer-full。

本章小结

  • Tauri path API 让跨平台路径变简单。
  • fs 插件前端侧受 scope 管控。
  • 真正的重活放 Rust,前端只传路径或调用命令。

动手时刻

  • 在 hello 项目里写一个 list_music 命令,返回 $MUSIC 下的全部音频文件路径。
  • 前端展示列表。
  • 加 notify watcher,音乐目录变化时前端 toast。

下一章,HTTP 客户端与 API 调用。

第 18 章 HTTP 客户端与 API 调用(reqwest + tokio)

本章目标

  • 在 Rust 侧用 reqwest 发 HTTP,并对前端暴露受控的网络能力。
  • 了解 tauri-plugin-http 与直接用 reqwest 的差别。
  • 打好 JSON 请求/响应、超时、重试、取消、代理、TLS 证书六大基本功。
  • 设计 CloudTone 的网络层抽象。

一、两条网络路线

  1. 前端直接用 fetch:受 WebView CSP + capability 管控,允许发送请求到白名单域名。
  2. Rust 后端用 reqwest:不受 CSP 限制,功能更强(代理、自定义 TLS、httpbin 低阶控制)。

推荐做法:第三方 API、爬虫、需要鉴权 token 的请求 —— Rust 侧做。简单的静态资源、CDN 拉图 —— 前端直连更省事。

二、Rust 侧 reqwest

# Cargo.toml
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tokio = { version = "1", features = ["full"] }

基本 GET / POST

use reqwest::Client;

pub async fn search_songs(keyword: &str) -> anyhow::Result<Vec<Song>> {
    let client = Client::new();
    let res: ApiResp<Vec<Song>> = client
        .get("https://api.example.com/search")
        .query(&[("q", keyword)])
        .header("User-Agent", "CloudTone/0.1")
        .timeout(std::time::Duration::from_secs(10))
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;
    Ok(res.data)
}

#[derive(serde::Deserialize)]
struct ApiResp<T> { code: i32, data: T }

复用 Client

Client 内部有连接池。应该复用,不要每次 new。放 AppState:

pub struct AppState {
    pub http: reqwest::Client,
    // ...
}

impl AppState {
    pub fn new(/*..*/) -> Self {
        let http = reqwest::Client::builder()
            .user_agent(concat!("CloudTone/", env!("CARGO_PKG_VERSION")))
            .timeout(std::time::Duration::from_secs(15))
            .build()
            .unwrap();
        Self { http, /* .. */ }
    }
}

POST JSON

let res = state.http.post(url)
    .json(&RequestBody { foo: "bar" })
    .send().await?
    .error_for_status()?;

流式下载

use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;

let mut resp = state.http.get(url).send().await?.error_for_status()?;
let total = resp.content_length().unwrap_or(0);
let mut file = tokio::fs::File::create(path).await?;
let mut downloaded: u64 = 0;
while let Some(chunk) = resp.chunk().await? {
    file.write_all(&chunk).await?;
    downloaded += chunk.len() as u64;
    channel.send(DownloadEvent::Progress { transferred: downloaded, total })?;
}

取消

reqwest 本身不直接支持取消;用 tokio::select! + tokio::sync::oneshot:

let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel::<()>();

tokio::select! {
    _ = &mut cancel_rx => {
        tracing::info!("cancelled");
    }
    res = do_request() => {
        // 处理结果
    }
}

把 cancel_tx 存到 AppState 里,另一个 command 调用时 .send(())。

三、tauri-plugin-http

这个插件给前端一个「受 capability 控制的 fetch」:

pnpm tauri add http
{
  "permissions": [
    {
      "identifier": "http:default",
      "allow": [{ "url": "https://api.example.com/**" }]
    }
  ]
}
import { fetch } from "@tauri-apps/plugin-http";
const res = await fetch("https://api.example.com/songs");

这比直接浏览器 fetch 的好处:

  • 绕过 CORS(真正到 Rust 层发请求)。
  • capability 统一管控目标域名。
  • 可以设置超时、代理。

四、为 CloudTone 设计网络层

// src-tauri/src/core/providers/netease.rs
use crate::core::providers::MusicProvider;

pub struct NeteaseProvider {
    http: reqwest::Client,
    base: String,
}

#[async_trait::async_trait]
impl MusicProvider for NeteaseProvider {
    fn name(&self) -> &'static str { "netease" }

    async fn search(&self, kw: &str) -> anyhow::Result<Vec<Song>> {
        let url = format!("{}/search?keywords={}", self.base, urlencoding::encode(kw));
        Ok(self.http.get(url).send().await?.error_for_status()?.json().await?)
    }

    async fn stream_url(&self, song_id: &str) -> anyhow::Result<String> {
        // 一般拿到临时签名 URL
        // ...
        Ok("https://...".into())
    }
}

Provider 抽象 + 注册表:

pub struct ProviderRegistry {
    pub inner: dashmap::DashMap<String, Box<dyn MusicProvider + Send + Sync>>,
}

impl ProviderRegistry {
    pub fn get(&self, name: &str) -> Option<&dyn MusicProvider> {
        self.inner.get(name).map(|r| r.value().as_ref())
    }
}

第 38 章我们会实际写两个 provider(网易 + 可插拔空壳)。本书不讨论具体商用 API 的使用合规问题——请使用公开可用、符合你所在地区法律的 API。

五、错误处理

统一把 reqwest::Error 映射到 AppError::Network(String)。避免把 reqwest 的内部类型泄露到前端。

#[derive(Debug, thiserror::Error)]
pub enum NetworkError {
    #[error("timeout")]
    Timeout,
    #[error("non-2xx: {0}")]
    Status(u16),
    #[error("decode: {0}")]
    Decode(String),
    #[error("other: {0}")]
    Other(String),
}

impl From<reqwest::Error> for NetworkError {
    fn from(e: reqwest::Error) -> Self {
        if e.is_timeout() { Self::Timeout }
        else if let Some(s) = e.status() { Self::Status(s.as_u16()) }
        else if e.is_decode() { Self::Decode(e.to_string()) }
        else { Self::Other(e.to_string()) }
    }
}

六、TLS / 代理 / 证书

  • rustls-tls 是纯 Rust 实现,强烈推荐(macOS/Windows/Linux 统一行为)。
  • native-tls 依赖系统库,有时老系统上闹别扭。
  • 代理:Client::builder().proxy(reqwest::Proxy::all("http://127.0.0.1:7890")?)。
  • 自签证书:Client::builder().add_root_certificate(cert)。

七、限流与重试

简单的指数退避:

use tokio::time::{sleep, Duration};

async fn get_with_retry(client: &Client, url: &str) -> anyhow::Result<String> {
    let mut delay = 500;
    for attempt in 1..=4 {
        match client.get(url).send().await.and_then(|r| r.error_for_status()) {
            Ok(r) => return Ok(r.text().await?),
            Err(e) if attempt == 4 => return Err(e.into()),
            Err(_) => { sleep(Duration::from_millis(delay)).await; delay *= 2; }
        }
    }
    unreachable!()
}

批量请求限流用 tokio::sync::Semaphore:

let sem = Arc::new(Semaphore::new(4));
for url in urls {
    let p = sem.clone().acquire_owned().await.unwrap();
    tokio::spawn(async move {
        let _p = p;
        fetch(url).await
    });
}

常见陷阱

1. WebView fetch 遇到 CORS

有些 API 不允许浏览器直连。走 Rust。

2. reqwest 在 macOS release build 卡死

偶见 native-tls 和 macOS 15 兼容问题。换 rustls-tls 99% 解决。

3. json() 解析大响应内存爆

用 .chunk() 流式处理,或者 serde_json::from_reader 接 .bytes_stream()。

4. 响应编码不是 UTF-8

reqwest::Response::text_with_charset(encoding) 手动指定。

本章小结

  • reqwest + rustls-tls 是 Tauri 后端网络的黄金组合。
  • 前端能用 tauri-plugin-http 就用,绕 CORS,统一管控。
  • Provider 抽象让第三方音源可插拔。

动手时刻

在 hello 项目里:

  • 写一个 fetch_ip 命令,请求 https://api.ipify.org?format=json,返回 IP。
  • 前端按钮触发,显示 IP。
  • 给 capability 里加 http:default 和 scope。

下一章,SQLite + sqlx。

第 19 章 数据库持久化:SQLite + sqlx + 迁移

本章目标

  • 在 Tauri 项目里集成 SQLite + sqlx。
  • 写迁移脚本,随应用启动自动执行。
  • 掌握 sqlx::query!、query_as!、连接池、事务、FTS5。
  • 为 CloudTone 设计一张完整的数据库 schema。

一、为什么 SQLite

  • 零服务:随 app 发布。
  • 文件数据库:方便备份、跨机迁移。
  • 支持 FTS5 全文搜索(CloudTone 本地搜索的底层)。
  • 支持触发器、视图、JSON1。

客户端库里 sqlx 是首选:async 原生、编译期 SQL 检查。

二、依赖

# Cargo.toml
sqlx = { version = "0.8", features = [
    "runtime-tokio",
    "sqlite",
    "macros",
    "migrate",
    "chrono"
]}

三、初始化与迁移

src-tauri/migrations/20260101_init.sql:

CREATE TABLE IF NOT EXISTS artists (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

CREATE TABLE IF NOT EXISTS albums (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
    year INTEGER,
    cover_path TEXT,
    UNIQUE(title, artist_id)
);

CREATE TABLE IF NOT EXISTS songs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    artist_id INTEGER REFERENCES artists(id),
    album_id INTEGER REFERENCES albums(id),
    path TEXT NOT NULL UNIQUE,
    duration_ms INTEGER NOT NULL DEFAULT 0,
    track_no INTEGER,
    disc_no INTEGER,
    file_size INTEGER NOT NULL DEFAULT 0,
    file_hash TEXT NOT NULL,
    format TEXT NOT NULL,
    bitrate INTEGER NOT NULL DEFAULT 0,
    sample_rate INTEGER NOT NULL DEFAULT 0,
    added_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    liked INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title);
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist_id);
CREATE INDEX IF NOT EXISTS idx_songs_album ON songs(album_id);

-- 全文搜索
CREATE VIRTUAL TABLE IF NOT EXISTS songs_fts USING fts5(
    title, artist, album,
    content='',
    tokenize = 'unicode61 remove_diacritics 2'
);

CREATE TABLE IF NOT EXISTS playlists (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    description TEXT,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

CREATE TABLE IF NOT EXISTS playlist_songs (
    playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
    song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
    position INTEGER NOT NULL,
    PRIMARY KEY (playlist_id, song_id)
);

CREATE TABLE IF NOT EXISTS play_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    song_id INTEGER NOT NULL REFERENCES songs(id),
    played_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    duration_played_ms INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS kv (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

Rust 侧启动

// src-tauri/src/core/db.rs
use sqlx::{sqlite::{SqlitePoolOptions, SqliteConnectOptions}, SqlitePool};
use std::path::Path;

pub async fn open_db(path: &Path) -> anyhow::Result<SqlitePool> {
    if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await.ok(); }
    let opts = SqliteConnectOptions::new()
        .filename(path)
        .create_if_missing(true)
        .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
        .synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
        .foreign_keys(true);
    let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?;
    // 内置迁移
    sqlx::migrate!("./migrations").run(&pool).await?;
    Ok(pool)
}

在 setup 里调用:

.setup(|app| {
    let db_path = app.path().app_data_dir()?.join("cloudtone.sqlite");
    let pool = tauri::async_runtime::block_on(open_db(&db_path))?;
    app.manage(AppState::new(pool));
    Ok(())
})

WAL 模式 非常重要:并发读不阻塞写,写不阻塞读。

四、查询

query!:编译期校验

let row = sqlx::query!(
    "SELECT title, duration_ms FROM songs WHERE id = ?",
    id
).fetch_one(&pool).await?;
println!("{} {}", row.title, row.duration_ms);

这需要 DATABASE_URL 在编译期指向一个真实 DB。CI 麻烦。推荐用 query_as + struct:

query_as!:结构体映射

#[derive(sqlx::FromRow)]
pub struct SongRow {
    pub id: i64,
    pub title: String,
    pub artist: Option<String>,
    pub duration_ms: i64,
    pub path: String,
}

pub async fn list_recent(pool: &SqlitePool, limit: i64) -> sqlx::Result<Vec<SongRow>> {
    sqlx::query_as::<_, SongRow>(
        "SELECT s.id, s.title, a.name AS artist, s.duration_ms, s.path
         FROM songs s LEFT JOIN artists a ON s.artist_id = a.id
         ORDER BY s.added_at DESC LIMIT ?"
    )
    .bind(limit)
    .fetch_all(pool)
    .await
}

事务

let mut tx = pool.begin().await?;
sqlx::query("INSERT INTO artists (name) VALUES (?)").bind(name).execute(&mut *tx).await?;
let artist_id = tx.last_insert_rowid();
sqlx::query("INSERT INTO albums (title, artist_id) VALUES (?, ?)")
    .bind(album).bind(artist_id).execute(&mut *tx).await?;
tx.commit().await?;

五、upsert:「不存在则插入」

INSERT INTO artists (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name=excluded.name
RETURNING id

Rust:

let id: i64 = sqlx::query_scalar(
    "INSERT INTO artists (name) VALUES (?) ON CONFLICT(name) DO UPDATE SET name=excluded.name RETURNING id"
).bind(name).fetch_one(&mut *tx).await?;

六、FTS5 全文搜索(中英文友好)

配合触发器维护:

CREATE TRIGGER songs_ai AFTER INSERT ON songs BEGIN
  INSERT INTO songs_fts(rowid, title, artist, album)
    VALUES (new.id, new.title,
            (SELECT name FROM artists WHERE id = new.artist_id),
            (SELECT title FROM albums WHERE id = new.album_id));
END;

CREATE TRIGGER songs_ad AFTER DELETE ON songs BEGIN
  DELETE FROM songs_fts WHERE rowid = old.id;
END;

CREATE TRIGGER songs_au AFTER UPDATE ON songs BEGIN
  UPDATE songs_fts SET title = new.title WHERE rowid = new.id;
END;

查询:

let rows = sqlx::query_as::<_, SongRow>(
    "SELECT s.id, s.title, a.name AS artist, s.duration_ms, s.path
     FROM songs_fts fts JOIN songs s ON s.id = fts.rowid
     LEFT JOIN artists a ON s.artist_id = a.id
     WHERE songs_fts MATCH ? ORDER BY rank LIMIT 50"
)
.bind(format!("{}*", keyword))  // 前缀匹配
.fetch_all(&pool).await?;

中文分词:FTS5 默认 unicode61 对中文只能按字拆分。如果需要词级分词,可以:

  • 集成 jieba(sqlite3_jieba 动态库),打包复杂。
  • 简化方案:前端/Rust 做 N-gram 预处理再存进 FTS。CloudTone 用 2-gram。

七、tauri-plugin-sql:在前端直接读 DB?

插件提供了前端直接执行 SQL 的能力:

pnpm tauri add sql
import Database from "@tauri-apps/plugin-sql";
const db = await Database.load("sqlite:cloudtone.sqlite");
const rows = await db.select<SongRow[]>("SELECT * FROM songs LIMIT 50");

不推荐 在大项目里用。原因:

  • SQL 在前端泄露表结构;权限控制细粒度难。
  • 难写类型安全;绕过业务校验。

CloudTone 一律走 Rust command + sqlx。

八、备份与迁移升级

  • 用户升级 app 时,sqlx::migrate! 会自动执行新文件。
  • 破坏性 schema 变更走 "拷贝 + 新建 + 迁移数据"。
  • 定期导出 VACUUM INTO 备份到用户 $APPDATA/cloudtone/backup/。

常见陷阱

1. migrate! 宏找不到 migrations 目录

migrate!("./migrations") 的路径相对 src-tauri/。

2. DB 文件锁

忘了 WAL + 其他进程打开 DB。要关 GUI 工具再调试。

3. i64 / number 精度

前端拿 duration_ms 是 number。上限 9e15 远超够。

4. 并发写冲突

多 writer 要用 BEGIN IMMEDIATE 或重试。CloudTone 写入大都串行化到单一 task。

本章小结

  • SQLite + sqlx + WAL + 迁移 = Tauri 本地持久化黄金组合。
  • FTS5 支撑搜索。
  • 前端不要直接读 DB。

动手时刻

  • 在 hello 项目里接入 sqlite,建一张 notes(id, text) 表。
  • 写 4 个 command:add_note / list_notes / delete_note / update_note。
  • 前端做一个简单 notes 页面,验证 CRUD。

下一章,日志 & 错误处理 & 崩溃上报。

第 20 章 日志、错误处理与崩溃上报

本章目标

  • 用 tracing 打结构化日志,按级别滚动写入。
  • 把 Rust 的 panic 捕获并落盘。
  • 前端错误(未捕获的 Promise、render 错误)收拢到统一上报点。
  • 为生产级 Tauri app 建立可运维的观测闭环。

一、tracing:结构化日志的现代选择

tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
tracing-appender = "0.2"

初始化:

// src-tauri/src/core/log.rs
use std::path::Path;
use tracing_appender::rolling;
use tracing_subscriber::{fmt, EnvFilter, prelude::*};

pub fn init(log_dir: &Path) -> tracing_appender::non_blocking::WorkerGuard {
    std::fs::create_dir_all(log_dir).ok();
    let file_appender = rolling::daily(log_dir, "cloudtone.log");
    let (nb, guard) = tracing_appender::non_blocking(file_appender);

    let env = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,cloudtone=debug,sqlx=warn"));

    let file_layer = fmt::layer()
        .with_writer(nb)
        .with_ansi(false)
        .with_target(true)
        .with_thread_ids(false)
        .json();

    let stdout_layer = fmt::layer().with_writer(std::io::stdout);

    tracing_subscriber::registry()
        .with(env)
        .with(file_layer)
        .with(stdout_layer)
        .init();

    guard
}

注意 返回 WorkerGuard 必须在 main 的生命周期里存活,否则日志 drop 掉。

setup:

.setup(|app| {
    let log_dir = app.path().app_log_dir()?;
    let guard = crate::core::log::init(&log_dir);
    app.manage(LogGuard(guard));   // 存进 State
    Ok(())
})

struct LogGuard(tracing_appender::non_blocking::WorkerGuard);

使用

use tracing::{info, warn, error, debug, trace, instrument};

#[instrument(skip(state))]
async fn play(state: tauri::State<'_, AppState>, song_id: i64) -> Result<(), AppError> {
    info!(song_id, "play requested");
    let mut p = state.player.lock().await;
    if let Err(e) = p.play(song_id).await {
        error!(error = %e, "play failed");
        return Err(e);
    }
    Ok(())
}

#[instrument] 会为函数自动建一个 span,日志里带上调用上下文。非常好用。

二、前端日志

用 tauri-plugin-log

pnpm tauri add log
.plugin(tauri_plugin_log::Builder::new()
    .target(tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: Some("front.log".into()) }))
    .level(log::LevelFilter::Info)
    .build())

前端:

import { info, warn, error, debug } from "@tauri-apps/plugin-log";
info("Hello");
error("Boom", { file: "App.tsx" });

和 Rust 日志写到同一目录,便于串联。

三、捕获 panic

默认 panic 会打印到 stderr,用户看不到。用 std::panic::set_hook:

pub fn install_panic_hook() {
    std::panic::set_hook(Box::new(|info| {
        tracing::error!(
            target: "panic",
            message = %info,
            location = ?info.location(),
            "Rust panic"
        );
    }));
}

setup 里第一个调用。

四、AppError 的上报

给 AppError 派生 serde::Serialize(第 11 章)之后,前端能拿到 {kind, message}。在前端统一包装 invoke:

// src/lib/ipc.ts
import { invoke as rawInvoke } from "@tauri-apps/api/core";
import { error as logError } from "@tauri-apps/plugin-log";

export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
  try {
    return await rawInvoke<T>(cmd, args);
  } catch (e) {
    logError(`invoke(${cmd}) failed: ${JSON.stringify(e)}`);
    throw e;
  }
}

搭配 React 错误边界:

import { Component, ReactNode } from "react";
import { error as logError } from "@tauri-apps/plugin-log";

export class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(err: Error, info: unknown) {
    logError(`React error: ${err.message}\n${(info as any).componentStack}`);
  }
  render() {
    if (this.state.hasError) return <div className="p-8 text-red-400">出错了,请重启 CloudTone</div>;
    return this.props.children;
  }
}

全局未捕获错误:

window.addEventListener("unhandledrejection", ev => {
  logError(`unhandledrejection: ${String(ev.reason)}`);
});

五、崩溃上报

生产 app 可选接入:

  • 自建 HTTP 收集:最简单。命中 panic / ErrorBoundary 发 POST 到你的服务器。
  • Sentry(sentry-rust + @sentry/react):功能完备,付费。
  • 本地 crash/*.json:先落盘,下次启动时带上报。

CloudTone 默认本地收集 + 用户同意后上报。尊重用户隐私,开源项目特别注意。

六、运维工具:命令行 tail 日志

设置页加一个 "打开日志目录" 按钮:

import { open } from "@tauri-apps/plugin-opener";
await open(await appLogDir());

工程师用户会感谢你。

七、日志级别分层建议

级别场景
trace音频 buffer 填充、每帧渲染(调试用)
debug一次 command 调用、DB 查询耗时
info扫描开始/结束、模块初始化、用户动作
warn可降级的错误(在线音源 fail 但已切备源)
error不可恢复、需要关注

生产默认 info,设置里提供「详细日志」开关。

常见陷阱

1. WorkerGuard 被 drop,日志消失

返回值必须持久保存。

2. 日志文件过大

tracing-appender 只按日期滚动;大小限制用 tracing-appender-rolling::RollingFileAppender 2.x 或自己压缩归档。

3. panic hook 不生效

注册要早。另外 #[tokio::main] 和 Tauri 初始化的先后顺序会影响。

4. 前端 log 卡 UI

tauri-plugin-log 自己在后台写。别在 hot render path 里狂 log。

本章小结

  • tracing + tauri-plugin-log 是前后端日志标配。
  • panic hook 让崩溃有迹可循。
  • AppError + 错误边界让前端也可观测。

动手时刻

  • 在 hello 项目里接入 tracing + tauri-plugin-log。
  • 写一个 raise_panic 命令触发 panic,观察日志落盘。
  • 前端故意写一个会抛异常的组件,用 ErrorBoundary 兜住。

第一部分完结。下一页,我们正式开始 CloudTone 项目的设计与实现。

第 21 章 产品设计与架构:CloudTone 的全貌

本章目标

  • 明确 CloudTone 的产品定位、用户故事、核心流程。
  • 画出系统架构图与模块边界。
  • 敲定前后端职责划分、通信模式、数据流。
  • 制定里程碑 + 每章交付物。

在动手写一行代码前,先花一章把设计讲透。工程能力里这一步最拉开差距。

一、产品定位

一句话:CloudTone 是一款仿「网易云音乐」风格、以本地音乐为核心、可插拔在线音源、面向重度听歌用户的跨平台桌面播放器。

不做的事:

  • 不做 UGC 社区(评论、动态)。
  • 不做复杂推荐算法(初版用"心动 FM"的简单策略)。
  • 不做云端同步(本地优先,后期再考虑自建同步)。

做到的事:

  • 本地音乐库完善管理(歌单、专辑、艺人、评分、收藏)。
  • 在线音源以 Provider 形式可扩展(用户自行选择合规来源)。
  • 媲美网易云的 UI 体验(深色主题、封面动画、歌词同步)。

二、用户故事 (User Stories)

As a 重度听歌用户 I want 选择多个本地音乐目录自动导入 so that 打开 app 就能随点随播。

As a 用户 I want 看到同步滚动的歌词 so that 不用切到手机。

As a 用户 I want 按关键字搜索本地曲库 so that 快速找到某首歌。

As a 用户 I want 建立自己的歌单,拖拽排序、删除、合并 so that 我的音乐库有序。

As a 用户 I want 按 Ctrl+Alt+P 在任何窗口暂停/继续 so that 不用切回 CloudTone。

As a 插件开发者 I want 写一个插件从 X 音源拉歌 so that 我不必 fork 整个项目。

三、模块划分

CloudTone
├── UI 层 (React)
│   ├── 页面:Home / Library / Playlist / Artist / Album / Search / Settings
│   ├── 组件:Player Bar / Sidebar / Now Playing / Lyrics Panel / Toasts
│   └── 子窗口:Mini Player / Lyric Overlay
├── 状态层 (Zustand + TanStack Query)
│   ├── playerStore: 当前播放歌曲、队列、模式、进度
│   ├── libraryStore: 选中过滤、视图模式
│   ├── uiStore: 主题、侧栏折叠、对话框
│   └── Query: 歌曲列表、歌单、搜索结果(由 Rust 提供)
├── IPC 层 (Tauri invoke / emit)
│   ├── 命令:player::*, library::*, playlist::*, settings::*, provider::*
│   ├── 事件:player:*, library:*, download:*, scan:*
│   └── Channel:scan 流、download 流
└── 核心层 (Rust)
    ├── Audio Engine  — symphonia 解码 + cpal 输出 + 自研队列
    ├── Library       — walkdir + lofty + SQLite 入库
    ├── Lyrics        — LRC 解析 + 时间匹配
    ├── Providers     — MusicProvider trait + 注册表
    ├── Downloader    — 并发下载 + 断点续传
    ├── EQ            — biquad filter 链
    └── DB            — sqlx + migration

四、架构示意

 ┌──────────────────────────┐
 │        UI (React)        │
 │  pages / components      │
 └───────────┬──────────────┘
             │ Hooks (Zustand / useQuery)
 ┌───────────▼──────────────┐
 │        State Store        │
 └───────────┬──────────────┘
             │ invoke / listen
 ┌───────────▼──────────────┐
 │       Tauri Bridge        │
 │   (Commands / Events)     │
 └───────────┬──────────────┘
             │
 ┌───────────▼──────────────┐     ┌──────────────────┐
 │     Core Services (Rust) │◄────┤  Audio Engine     │
 │  Library  Playlist  Lyrics│     │  cpal + symphonia │
 │  Providers Downloader EQ │     └──────────────────┘
 └───────────┬──────────────┘
             │
 ┌───────────▼──────────────┐
 │   SQLite (sqlx + WAL)     │
 └───────────────────────────┘

五、数据流(以"播放一首歌"为例)

  1. 用户双击歌曲列表里的一首歌(UI)。
  2. usePlayerStore.getState().play(song)(前端)。
  3. Zustand 内部调用 invoke("player_play", { songId })。
  4. Rust command 从 DB 拿到 path,Audio Engine 加载并解码。
  5. Audio Engine 启动 cpal 输出 + 定时发出 player:progress 事件。
  6. player:state 广播给所有窗口(主、迷你、桌面歌词)。
  7. 前端根据事件刷新 UI。
  8. DB 写入 play_history。

六、UI 草图(文字版)

┌──────────────────────────────────────────────────────────────┐
│ [≡] CloudTone          [搜索框]              [设置 账号]     │
├─────────────┬────────────────────────────┬───────────────────┤
│ 发现        │                            │                   │
│ 音乐馆      │                            │   [封面大图]       │
│ 每日推荐    │      中间主内容区           │                   │
│ ─────────  │   (歌单/歌曲列表/艺人)      │   歌词滚动        │
│ 我的音乐    │                            │                   │
│   收藏      │                            │                   │
│   歌单 ▸    │                            │                   │
│   最近播放   │                            │                   │
│ ─────────  │                            │                   │
│ 本地音乐    │                            │                   │
└─────────────┴────────────────────────────┴───────────────────┘
│ ◀◀  ▶ / ⏸  ▶▶   [00:12 ━━━━━━━ 03:45]  ♡ 🎚  🔀 📃  ♪    │
└──────────────────────────────────────────────────────────────┘

七、里程碑

里程碑章节产出
M0 骨架22–25UI 框架 + 路由 + 状态
M1 音频核心26–27能播放任意本地音频
M2 音乐库28–30扫描、入库、队列
M3 歌词 + 元数据31–32歌词同步、封面、自定义协议
M4 搜索 + 歌单33–35本地搜索、歌单 CRUD
M5 桌面体验36–37迷你播放器、媒体控制
M6 在线扩展38–39Provider 架构、下载
M7 进阶40–42EQ、i18n、插件系统
M8 发布43–47自动更新、测试、CI/CD、上架

八、非功能需求

  • 冷启动:< 1.5 秒到可用(主窗口渲染完)。
  • 内存:空闲 < 80MB;播放中 < 120MB。
  • 稳定:连续播放 12 小时不崩。
  • 音频延迟:按暂停/下一曲到发声 < 200ms。
  • 兼容:macOS 12+、Windows 10+、Ubuntu 22+。

后面的章节会用具体测试验证每一项。

本章小结

  • 产品设计决定工程走向。CloudTone 的核心是"本地优先 + 可扩展"。
  • 模块清晰划分,IPC 走 command + event。
  • 里程碑明确,每一章都有交付物。

下一章,把项目真正搭起来。

第 22 章 搭建项目骨架(Vite + React + Tauri 2 + Tailwind)

本章目标

  • 创建 cloudtone 项目目录。
  • 集成 Tailwind、shadcn/ui、tauri-specta 类型自动生成。
  • 配置路径别名、ESLint、Prettier。
  • 跑起 Hello CloudTone。

一、创建项目

pnpm create tauri-app@latest cloudtone
# 选 pnpm, React, TypeScript
cd cloudtone
pnpm install

按第 10 章的目录规范整理:

mkdir -p src/{app,components/ui,components/player,features,lib,types}
mkdir -p src-tauri/{capabilities,migrations}
mkdir -p src-tauri/src/{cmds,core,core/audio,core/library,core/lyrics,core/providers,core/db}

二、依赖

pnpm add react-router-dom zustand @tanstack/react-query lucide-react
pnpm add clsx tailwind-merge
pnpm add -D tailwindcss@3 postcss autoprefixer
pnpm add -D @types/node vite-plugin-svgr
pnpm exec tailwindcss init -p

shadcn/ui(可选但推荐):

pnpm dlx shadcn@latest init
# 选 "default","slate"
pnpm dlx shadcn@latest add button dialog dropdown-menu slider tooltip input

三、Tailwind 配置

tailwind.config.js:

export default {
  darkMode: "class",
  content: ["./index.html", "./src/**/*.{ts,tsx}"],
  theme: {
    extend: {
      colors: {
        brand: { 500: "#ec4899", 600: "#db2777" },
        surface: {
          bg: "#0f0f10",
          card: "#17171a",
          elevated: "#202024",
          border: "rgba(255,255,255,0.06)",
        },
      },
      fontFamily: {
        sans: ["Inter", "PingFang SC", "Microsoft YaHei", "sans-serif"],
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
};

src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

html, body, #root { height: 100%; margin: 0; }
body { background: #0a0a0a; color: #e5e7eb; -webkit-font-smoothing: antialiased; }
.scrollbar-thin::-webkit-scrollbar { width: 8px; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }

四、Vite 配置

vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";

export default defineConfig({
  plugins: [react()],
  clearScreen: false,
  server: { port: 1420, strictPort: true },
  envPrefix: ["VITE_", "TAURI_"],
  build: {
    target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
    minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
    sourcemap: !!process.env.TAURI_ENV_DEBUG,
  },
  resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});

五、基础 Rust 后端

src-tauri/Cargo.toml 加依赖:

[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-log = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-notification = "2"
tauri-plugin-http = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }

serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
thiserror = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter","fmt","json"] }
tracing-appender = "0.2"

sqlx = { version = "0.8", features = ["runtime-tokio","sqlite","macros","migrate","chrono"] }
reqwest = { version = "0.12", features = ["json","stream","rustls-tls"] }
url = "2"
urlencoding = "2"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4","serde"] }
dirs = "5"
walkdir = "2"
notify = "6"

# 音频
symphonia = { version = "0.5", features = ["all"] }
cpal = "0.15"
rubato = "0.15"      # 采样率转换
lofty = "0.21"       # 元数据

specta = { version = "2.0.0-rc", features = ["serde","derive"] }
tauri-specta = { version = "=2.0.0-rc", features = ["derive","typescript"] }
specta-typescript = "0.0.7"

src-tauri/src/lib.rs 初版

mod cmds;
mod core;
mod error;
mod events;
mod state;

use tauri::Manager;
use tauri_specta::{collect_commands, Builder};

use crate::state::AppState;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let specta = Builder::<tauri::Wry>::new().commands(collect_commands![
        cmds::misc::ping,
    ]);

    #[cfg(debug_assertions)]
    specta.export(
        specta_typescript::Typescript::default(),
        "../src/lib/ipc.ts",
    ).expect("failed to export TS bindings");

    tauri::Builder::default()
        .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
            if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); }
        }))
        .plugin(tauri_plugin_log::Builder::new().build())
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_opener::init())
        .plugin(tauri_plugin_notification::init())
        .plugin(tauri_plugin_http::init())
        .plugin(tauri_plugin_global_shortcut::Builder::new().build())
        .plugin(tauri_plugin_sql::Builder::default().build())
        .setup(|app| {
            let db_path = app.path().app_data_dir()?.join("cloudtone.sqlite");
            let pool = tauri::async_runtime::block_on(core::db::open_db(&db_path))?;
            app.manage(AppState::new(pool));
            Ok(())
        })
        .invoke_handler(specta.invoke_handler())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

cmds/misc.rs:

#[tauri::command]
#[specta::specta]
pub async fn ping() -> &'static str { "pong" }

cmds/mod.rs:

pub mod misc;

六、tauri.conf.json

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "CloudTone",
  "version": "0.1.0",
  "identifier": "dev.codecow.cloudtone",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeDevCommand": "pnpm dev",
    "beforeBuildCommand": "pnpm build"
  },
  "app": {
    "windows": [
      {
        "label": "main",
        "title": "CloudTone",
        "width": 1200,
        "height": 780,
        "minWidth": 960,
        "minHeight": 600,
        "center": true,
        "decorations": false,
        "transparent": false,
        "resizable": true,
        "titleBarStyle": "Overlay"
      }
    ],
    "security": {
      "csp": "default-src 'self'; img-src 'self' data: https: http: asset: custom:; media-src 'self' asset: custom: https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http: https:"
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": ["icons/32x32.png","icons/128x128.png","icons/icon.icns","icons/icon.ico"],
    "category": "Music",
    "shortDescription": "CloudTone 音乐播放器",
    "longDescription": "跨平台本地音乐播放器",
    "fileAssociations": [
      { "ext": ["mp3","flac","m4a","wav","ogg","aac","aiff","ape"], "name": "CloudTone Audio", "role": "Viewer" }
    ]
  }
}

七、Capability

src-tauri/capabilities/default.json:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:event:default",
    "core:window:default",
    "core:path:default",
    "core:webview:default",
    "log:default",
    "dialog:default",
    "notification:default",
    "opener:default",
    "global-shortcut:default",
    "sql:default",
    "http:default",
    "fs:default",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [{ "path": "$APPDATA/cloudtone/**" }]
    },
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [{ "path": "$APPDATA/cloudtone/**" }]
    },
    {
      "identifier": "fs:allow-exists",
      "allow": [{ "path": "$MUSIC/**" }, { "path": "$HOME/**" }]
    }
  ]
}

八、最小化 React App

src/main.tsx:

import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "@/router";
import "./index.css";

const qc = new QueryClient({ defaultOptions: { queries: { staleTime: 5000 } } });

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <QueryClientProvider client={qc}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </React.StrictMode>,
);

src/router.tsx(骨架):

import { createBrowserRouter } from "react-router-dom";
import AppShell from "@/app/AppShell";
import HomePage from "@/app/home/HomePage";

export const router = createBrowserRouter([
  {
    path: "/",
    element: <AppShell />,
    children: [
      { index: true, element: <HomePage /> },
    ],
  },
]);

src/app/AppShell.tsx:

import { Outlet } from "react-router-dom";

export default function AppShell() {
  return (
    <div className="h-screen flex flex-col bg-surface-bg text-gray-100">
      <div data-tauri-drag-region className="h-10 flex items-center px-3 border-b border-white/5">
        CloudTone
      </div>
      <div className="flex-1 overflow-auto">
        <Outlet />
      </div>
      <div className="h-20 border-t border-white/5 flex items-center px-4">
        (播放控制条 - 下一章实现)
      </div>
    </div>
  );
}

src/app/home/HomePage.tsx:

import { useEffect, useState } from "react";
import { commands } from "@/lib/ipc";

export default function HomePage() {
  const [pong, setPong] = useState("");
  useEffect(() => { commands.ping().then(setPong); }, []);
  return (
    <div className="p-6">
      <h1 className="text-2xl font-semibold mb-4">欢迎使用 CloudTone</h1>
      <p className="text-gray-400">后端应答:{pong || "..."}</p>
    </div>
  );
}

九、跑起来

pnpm tauri dev

等几分钟,一个窗口亮起:上面显示 "欢迎使用 CloudTone",下面显示 "后端应答:pong"。


常见陷阱

1. ipc.ts 生成失败

检查 tauri-specta 版本和 specta-typescript 匹配。Builder 的 export 调用必须在 tauri::Builder 之前。

2. AppShell 的 Drag Region 整个屏幕都能拖

给其他组件 data-tauri-drag-region={false} 或 class="!region-no-drag"。

本章小结

骨架已立。后面每一章都在此之上增量。本章交付的目录就是 CloudTone 的"钢筋"。

动手时刻

  • 跑通 pnpm tauri dev。
  • 看到欢迎界面 + pong。
  • src/lib/ipc.ts 被自动生成。
  • 打一个 dev build:pnpm tauri build --debug。

下一章,设计系统与三栏布局。

第 23 章 设计系统与布局:仿网易云的三栏结构

本章目标

  • 搭好 CloudTone 三栏主界面:侧栏 / 主区 / 歌词面板。
  • 建立色板、字号、间距、圆角、阴影令牌,写成 Tailwind 主题扩展。
  • 做出原生感的无边框窗口 + 自定义窗口控制按钮。

一、设计令牌 (Design Tokens)

设计令牌是一套"抽象变量":你命名 bg-surface-card 而不是 #17171a。视觉统一,主题切换容易。

追加到 tailwind.config.js:

theme: {
  extend: {
    colors: {
      brand: { 50: "#fff1f2", 500: "#ec4899", 600: "#db2777", 700: "#be185d" },
      surface: {
        bg: "#0f0f10",
        sidebar: "#0a0a0b",
        card: "#17171a",
        elevated: "#202024",
        hover: "rgba(255,255,255,0.05)",
        border: "rgba(255,255,255,0.06)",
      },
      text: {
        primary: "#e5e7eb",
        secondary: "#9ca3af",
        tertiary: "#6b7280",
      },
    },
    fontSize: {
      xxs: ["11px","14px"],
    },
    borderRadius: {
      xl: "14px",
    },
    boxShadow: {
      card: "0 4px 10px rgba(0,0,0,0.25)",
      pop: "0 10px 40px rgba(0,0,0,0.45)",
    },
  },
}

二、三栏 Shell

┌──────────────────────────────────────────────────────────────┐
│ TitleBar (h-10)                                              │
├─────────────┬────────────────────────────┬───────────────────┤
│  Sidebar    │   Main content (Outlet)    │   NowPlaying      │
│  w-60       │   flex-1                   │   w-80            │
│             │                            │   (可隐藏)         │
├─────────────┴────────────────────────────┴───────────────────┤
│ PlayerBar (h-20)                                             │
└──────────────────────────────────────────────────────────────┘

src/app/AppShell.tsx:

import { Outlet } from "react-router-dom";
import { TitleBar } from "@/components/shell/TitleBar";
import { Sidebar } from "@/components/shell/Sidebar";
import { NowPlayingPanel } from "@/components/shell/NowPlayingPanel";
import { PlayerBar } from "@/components/player/PlayerBar";

export default function AppShell() {
  return (
    <div className="h-screen flex flex-col bg-surface-bg text-text-primary overflow-hidden">
      <TitleBar />
      <div className="flex-1 flex min-h-0">
        <Sidebar />
        <main className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
          <Outlet />
        </main>
        <NowPlayingPanel />
      </div>
      <PlayerBar />
    </div>
  );
}

三、TitleBar:自定义窗口控制

// src/components/shell/TitleBar.tsx
import { Minus, Square, X } from "lucide-react";
import { getCurrentWindow } from "@tauri-apps/api/window";

function WinButton({ onClick, children, danger }: { onClick: () => void; children: React.ReactNode; danger?: boolean }) {
  return (
    <button
      onClick={onClick}
      className={`h-10 w-10 inline-flex items-center justify-center hover:bg-white/5 ${danger ? "hover:bg-red-500/80" : ""}`}
    >{children}</button>
  );
}

export function TitleBar() {
  const win = getCurrentWindow();
  return (
    <div data-tauri-drag-region className="h-10 flex items-center select-none border-b border-surface-border bg-surface-bg">
      <div data-tauri-drag-region className="pl-4 text-sm font-medium">CloudTone</div>
      <div data-tauri-drag-region className="flex-1" />
      <WinButton onClick={() => win.minimize()}><Minus className="w-4 h-4" /></WinButton>
      <WinButton onClick={() => win.toggleMaximize()}><Square className="w-3.5 h-3.5" /></WinButton>
      <WinButton onClick={() => win.hide()} danger><X className="w-4 h-4" /></WinButton>
    </div>
  );
}

四、Sidebar

// src/components/shell/Sidebar.tsx
import { NavLink } from "react-router-dom";
import { Home, Music, ListMusic, Heart, Clock, Settings } from "lucide-react";
import { cn } from "@/lib/cn";

const sections = [
  { title: "推荐", items: [
    { to: "/", label: "发现", icon: Home },
    { to: "/recommend", label: "每日推荐", icon: Music },
  ]},
  { title: "我的", items: [
    { to: "/library", label: "本地音乐", icon: Music },
    { to: "/playlists", label: "歌单", icon: ListMusic },
    { to: "/favorites", label: "收藏", icon: Heart },
    { to: "/recent", label: "最近播放", icon: Clock },
  ]},
  { title: "其他", items: [
    { to: "/settings", label: "设置", icon: Settings },
  ]},
];

export function Sidebar() {
  return (
    <aside className="w-60 shrink-0 bg-surface-sidebar border-r border-surface-border py-3 overflow-y-auto scrollbar-thin">
      {sections.map(sec => (
        <div key={sec.title} className="mb-4">
          <div className="px-4 py-2 text-xxs uppercase tracking-wide text-text-tertiary">{sec.title}</div>
          <ul className="space-y-0.5 px-2">
            {sec.items.map(it => (
              <li key={it.to}>
                <NavLink
                  to={it.to}
                  end={it.to === "/"}
                  className={({ isActive }) => cn(
                    "flex items-center gap-3 px-3 py-2 rounded text-sm",
                    "hover:bg-surface-hover",
                    isActive && "bg-white/10 text-white font-medium",
                  )}
                >
                  <it.icon className="w-4 h-4" />
                  {it.label}
                </NavLink>
              </li>
            ))}
          </ul>
        </div>
      ))}
    </aside>
  );
}

五、NowPlayingPanel(骨架)

// src/components/shell/NowPlayingPanel.tsx
export function NowPlayingPanel() {
  return (
    <aside className="w-80 shrink-0 border-l border-surface-border p-5 bg-surface-bg overflow-y-auto scrollbar-thin">
      <div className="aspect-square rounded-lg bg-surface-card" />
      <h2 className="mt-4 text-lg font-semibold">(未播放)</h2>
      <p className="text-sm text-text-secondary">选一首歌开始</p>
      <div className="mt-6 text-sm text-text-secondary leading-7">
        歌词区(下一章接入)
      </div>
    </aside>
  );
}

六、PlayerBar(占位)

// src/components/player/PlayerBar.tsx
import { Play, SkipForward, SkipBack, Volume2 } from "lucide-react";

export function PlayerBar() {
  return (
    <div className="h-20 border-t border-surface-border bg-surface-bg flex items-center px-4 gap-4">
      <div className="flex items-center gap-3 w-72">
        <div className="w-12 h-12 bg-surface-card rounded" />
        <div className="min-w-0">
          <div className="text-sm truncate">(未播放)</div>
          <div className="text-xs text-text-secondary truncate">—</div>
        </div>
      </div>
      <div className="flex-1 flex flex-col items-center gap-2">
        <div className="flex items-center gap-4">
          <button className="p-2 hover:bg-surface-hover rounded"><SkipBack className="w-4 h-4" /></button>
          <button className="p-3 bg-white text-black rounded-full"><Play className="w-5 h-5" /></button>
          <button className="p-2 hover:bg-surface-hover rounded"><SkipForward className="w-4 h-4" /></button>
        </div>
        <div className="w-full max-w-2xl flex items-center gap-2">
          <span className="text-xs text-text-secondary">00:00</span>
          <div className="flex-1 h-1 bg-white/10 rounded">
            <div className="h-1 bg-brand-500 rounded" style={{ width: "0%" }} />
          </div>
          <span className="text-xs text-text-secondary">00:00</span>
        </div>
      </div>
      <div className="w-72 flex justify-end items-center gap-3">
        <Volume2 className="w-4 h-4 text-text-secondary" />
        <div className="w-24 h-1 bg-white/10 rounded"><div className="h-1 bg-white/40 rounded w-1/2" /></div>
      </div>
    </div>
  );
}

七、窗口无边框 + 圆角

macOS 无装饰窗可以自然圆角。Windows 11 系统自带圆角(需启用)。Linux 需要自己加 border-radius 到 body。我们在 index.css 里加:

body {
  border-radius: 12px;
  overflow: hidden;
}

八、首页内容(占位)

// src/app/home/HomePage.tsx
export default function HomePage() {
  return (
    <div className="p-6">
      <h1 className="text-2xl font-semibold mb-4">发现</h1>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="aspect-square bg-surface-card rounded-lg" />
        ))}
      </div>
    </div>
  );
}

本章小结

三栏布局、标题栏、侧栏、播放条、右侧面板已经搭好。本章的 UI 还没有逻辑,但骨架稳了。

动手时刻

  • 让侧栏点击能切换路由(先添加空白 LibraryPage 等)。
  • 窗口 hide/close 后如何呼出?试着用第 14 章的托盘。
  • 放一张你喜欢的封面到 public/cover.jpg,在 NowPlayingPanel 显示它。

下一章:路由、主题、暗色模式。

第 24 章 路由、主题、暗色模式

本章目标

  • 补齐 CloudTone 全部页面的路由映射。
  • 实现主题切换(light / dark / system),持久化到本地。
  • 做出平滑的页面过渡动画(可选)。

一、路由表

// src/router.tsx
import { createBrowserRouter } from "react-router-dom";
import AppShell from "@/app/AppShell";
import HomePage from "@/app/home/HomePage";
import LibraryPage from "@/app/library/LibraryPage";
import RecommendPage from "@/app/recommend/RecommendPage";
import PlaylistsPage from "@/app/playlists/PlaylistsPage";
import PlaylistDetail from "@/app/playlists/PlaylistDetail";
import FavoritesPage from "@/app/favorites/FavoritesPage";
import RecentPage from "@/app/recent/RecentPage";
import SearchPage from "@/app/search/SearchPage";
import ArtistPage from "@/app/artist/ArtistPage";
import AlbumPage from "@/app/album/AlbumPage";
import SettingsPage from "@/app/settings/SettingsPage";

export const router = createBrowserRouter([
  {
    path: "/", element: <AppShell />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "recommend", element: <RecommendPage /> },
      { path: "library", element: <LibraryPage /> },
      { path: "playlists", element: <PlaylistsPage /> },
      { path: "playlists/:id", element: <PlaylistDetail /> },
      { path: "favorites", element: <FavoritesPage /> },
      { path: "recent", element: <RecentPage /> },
      { path: "search", element: <SearchPage /> },
      { path: "artists/:id", element: <ArtistPage /> },
      { path: "albums/:id", element: <AlbumPage /> },
      { path: "settings", element: <SettingsPage /> },
    ],
  },
]);

每个页面组件先占位(一个 <div>页面名</div>),后面章节填内容。

二、主题切换

src/features/ui/themeStore.ts:

import { create } from "zustand";
import { persist } from "zustand/middleware";

type Theme = "light" | "dark" | "system";

interface ThemeStore {
  theme: Theme;
  effective: "light" | "dark";
  setTheme: (t: Theme) => void;
  _syncSystem: () => void;
}

const media = matchMedia("(prefers-color-scheme: dark)");

export const useThemeStore = create<ThemeStore>()(
  persist(
    (set, get) => ({
      theme: "dark",
      effective: "dark",
      setTheme: t => {
        set({ theme: t });
        get()._syncSystem();
      },
      _syncSystem: () => {
        const t = get().theme;
        const effective = t === "system" ? (media.matches ? "dark" : "light") : t;
        document.documentElement.classList.toggle("dark", effective === "dark");
        document.documentElement.classList.toggle("light", effective === "light");
        set({ effective });
      },
    }),
    { name: "cloudtone.theme", onRehydrateStorage: () => (s) => s?._syncSystem() },
  ),
);

media.addEventListener("change", () => useThemeStore.getState()._syncSystem());

在 main.tsx 初始化:

import { useThemeStore } from "@/features/ui/themeStore";
useThemeStore.getState()._syncSystem();

三、设置页切换主题

// src/app/settings/SettingsPage.tsx
import { useThemeStore } from "@/features/ui/themeStore";

export default function SettingsPage() {
  const { theme, setTheme } = useThemeStore();
  return (
    <div className="p-6 max-w-2xl">
      <h1 className="text-2xl font-semibold mb-6">设置</h1>
      <section>
        <h2 className="text-sm text-text-secondary mb-2">外观</h2>
        <div className="flex gap-2">
          {(["light","dark","system"] as const).map(t => (
            <button
              key={t}
              onClick={() => setTheme(t)}
              className={`px-4 py-2 rounded border ${theme === t ? "bg-brand-500 border-brand-500" : "border-surface-border hover:bg-surface-hover"}`}
            >{t === "light" ? "浅色" : t === "dark" ? "深色" : "跟随系统"}</button>
          ))}
        </div>
      </section>
    </div>
  );
}

四、浅色主题

Tailwind 默认 dark: 前缀切换。写组件时,默认样式为浅色,dark: 覆盖为深色:

<div className="bg-white text-black dark:bg-surface-bg dark:text-text-primary">

CloudTone 主题以深色为主,浅色作为次要,细节要重新设计(色差、阴影、边框),第 32 章整理主题系统。

五、页面过渡

轻量实现:用 React Router 的 useLocation + Tailwind transition:

import { useLocation } from "react-router-dom";
import { useEffect, useState } from "react";

function FadeRoute({ children }: { children: React.ReactNode }) {
  const loc = useLocation();
  const [show, setShow] = useState(true);
  useEffect(() => {
    setShow(false);
    const t = setTimeout(() => setShow(true), 80);
    return () => clearTimeout(t);
  }, [loc.pathname]);
  return <div className={`transition-opacity duration-150 ${show ? "opacity-100" : "opacity-0"}`}>{children}</div>;
}

在 AppShell 的 <Outlet> 外包一层。生产环境追求高级可以用 framer-motion。

本章小结

路由和主题搭好。CloudTone 现在已经是一个可以「假装」可用的 app 框架。

动手时刻

  • 切换主题并刷新,验证持久化。
  • 每个路由都加一个占位组件。
  • 在 Sidebar 显示当前活动路由的高亮。

下一章:状态管理全貌。

第 25 章 状态管理:Zustand + TanStack Query

本章目标

  • 明确 Zustand 管"客户端状态"、TanStack Query 管"服务端状态"的分工。
  • 建立 playerStore、libraryStore、uiStore 的骨架。
  • 封装统一的 invoke 调用习惯。

一、两分法

状态种类例子工具
客户端状态当前主题、侧栏折叠、modal 开关、播放器状态Zustand
服务端状态歌曲列表、歌单数据、搜索结果TanStack Query

Zustand 负责保存 + 订阅;Query 负责"从 Rust 拉数据 + 缓存 + 失效"。

二、PlayerStore(核心)

// src/features/player/playerStore.ts
import { create } from "zustand";
import { commands } from "@/lib/ipc";
import type { Song } from "@/types/song";

type PlayMode = "sequence" | "list-loop" | "single-loop" | "shuffle";

interface PlayerState {
  current?: Song;
  queue: Song[];
  index: number;
  playing: boolean;
  position: number; // seconds
  duration: number;
  volume: number;
  mode: PlayMode;

  playSong: (song: Song, queue?: Song[]) => Promise<void>;
  toggle: () => Promise<void>;
  next: () => Promise<void>;
  prev: () => Promise<void>;
  seek: (pos: number) => Promise<void>;
  setVolume: (v: number) => Promise<void>;
  setMode: (m: PlayMode) => void;

  _onProgress: (p: number, d: number) => void;
  _onStateChange: (playing: boolean) => void;
}

export const usePlayer = create<PlayerState>((set, get) => ({
  queue: [], index: -1, playing: false, position: 0, duration: 0, volume: 1,
  mode: "sequence",

  async playSong(song, queue) {
    const q = queue ?? [song];
    const idx = q.findIndex(s => s.id === song.id);
    set({ current: song, queue: q, index: idx });
    await commands.playerPlay(song.id);
  },

  async toggle() {
    if (get().playing) await commands.playerPause();
    else await commands.playerResume();
  },

  async next() {
    const { queue, index, mode } = get();
    if (queue.length === 0) return;
    let nextIdx = index + 1;
    if (mode === "shuffle") nextIdx = Math.floor(Math.random() * queue.length);
    if (nextIdx >= queue.length) nextIdx = mode === "list-loop" ? 0 : queue.length - 1;
    await get().playSong(queue[nextIdx], queue);
  },

  async prev() {
    const { queue, index } = get();
    const prevIdx = index <= 0 ? queue.length - 1 : index - 1;
    if (queue[prevIdx]) await get().playSong(queue[prevIdx], queue);
  },

  async seek(pos) {
    set({ position: pos });
    await commands.playerSeek(pos);
  },

  async setVolume(v) {
    set({ volume: v });
    await commands.playerSetVolume(v);
  },

  setMode(m) { set({ mode: m }); },

  _onProgress(p, d) { set({ position: p, duration: d }); },
  _onStateChange(playing) { set({ playing }); },
}));

三、连接后端事件

// src/features/player/usePlayerSync.ts
import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { usePlayer } from "./playerStore";

export function usePlayerSync() {
  useEffect(() => {
    const un1 = listen<{ position: number; duration: number }>("player:progress", e => {
      usePlayer.getState()._onProgress(e.payload.position, e.payload.duration);
    });
    const un2 = listen<{ playing: boolean }>("player:state", e => {
      usePlayer.getState()._onStateChange(e.payload.playing);
    });
    const un3 = listen<number>("player:ended", () => { usePlayer.getState().next(); });
    return () => { un1.then(f => f()); un2.then(f => f()); un3.then(f => f()); };
  }, []);
}

在 AppShell 里调用 usePlayerSync()。

四、Query:列歌曲

// src/features/library/useSongs.ts
import { useQuery } from "@tanstack/react-query";
import { commands } from "@/lib/ipc";

export function useSongs(args: { q?: string; limit?: number; offset?: number }) {
  return useQuery({
    queryKey: ["songs", args],
    queryFn: () => commands.librarySearch(args.q ?? "", args.limit ?? 200, args.offset ?? 0),
    staleTime: 10 * 1000,
  });
}

配合列表页:

export default function LibraryPage() {
  const [q, setQ] = useState("");
  const { data, isLoading } = useSongs({ q });
  return (
    <div className="p-6">
      <input value={q} onChange={e => setQ(e.target.value)} />
      {isLoading ? <div>加载中</div> : <SongList songs={data ?? []} />}
    </div>
  );
}

五、UI Store

export const useUI = create<{
  sidebarCollapsed: boolean;
  nowPlayingOpen: boolean;
  toggleSidebar: () => void;
  toggleNowPlaying: () => void;
}>((set) => ({
  sidebarCollapsed: false,
  nowPlayingOpen: true,
  toggleSidebar: () => set(s => ({ sidebarCollapsed: !s.sidebarCollapsed })),
  toggleNowPlaying: () => set(s => ({ nowPlayingOpen: !s.nowPlayingOpen })),
}));

六、失效缓存

写操作后使相关 Query 失效:

const qc = useQueryClient();
await commands.playlistCreate(name);
qc.invalidateQueries({ queryKey: ["playlists"] });

本章小结

  • Zustand 轻量,适合客户端 UI 状态。
  • Query 做远端缓存,减少重复 invoke。
  • 把副作用放到 Rust,前端只显示。

动手时刻

  • 把 usePlayer 接入 PlayerBar 组件,按钮真能 toggle。
  • 列表页用 useQuery 拉 songs。

下一章:Audio Engine 第一弹。

第 26 章 Rust 音频引擎 1:symphonia 解码 + cpal 输出

本章目标

  • 理解音频解码 + 输出管线。
  • 用 symphonia 解码 MP3/FLAC/AAC/OGG/WAV。
  • 用 cpal 把 PCM 数据送到声卡。
  • 做一个 Actor 模式的 Player,可接受 Play/Pause/Stop 命令。

一、管线总览

文件 → Symphonia::Reader → Decoder → AudioBuffer(f32)
                                        │
                                        ▼
                                   Rubato 重采样 (目标 44.1/48kHz)
                                        │
                                        ▼
                        Ring Buffer (producer / consumer)
                                        │
                                        ▼
                                   cpal Stream (实时输出)

二、核心数据结构

// src-tauri/src/core/audio/mod.rs
pub mod player;
pub mod decoder;
pub mod output;
pub mod queue;

pub use player::{Player, PlayerHandle};

消息定义

// src-tauri/src/core/audio/player.rs
use std::path::PathBuf;
use tokio::sync::{mpsc, oneshot, broadcast};

pub enum PlayerCmd {
    Load(PathBuf, oneshot::Sender<anyhow::Result<u64>>), // 返回总 duration ms
    Play,
    Pause,
    Stop,
    Seek(f64),            // seconds
    SetVolume(f32),
    Shutdown,
}

#[derive(Clone, Debug)]
pub enum PlayerEvent {
    State { playing: bool, position: f64, duration: f64 },
    Ended,
}

#[derive(Clone)]
pub struct PlayerHandle {
    cmd_tx: mpsc::Sender<PlayerCmd>,
    pub events: broadcast::Sender<PlayerEvent>,
}

impl PlayerHandle {
    pub async fn load(&self, path: PathBuf) -> anyhow::Result<u64> {
        let (tx, rx) = oneshot::channel();
        self.cmd_tx.send(PlayerCmd::Load(path, tx)).await?;
        rx.await?
    }
    pub async fn play(&self)  -> anyhow::Result<()> { self.cmd_tx.send(PlayerCmd::Play).await?; Ok(()) }
    pub async fn pause(&self) -> anyhow::Result<()> { self.cmd_tx.send(PlayerCmd::Pause).await?; Ok(()) }
    pub async fn stop(&self)  -> anyhow::Result<()> { self.cmd_tx.send(PlayerCmd::Stop).await?; Ok(()) }
    pub async fn seek(&self, pos: f64) -> anyhow::Result<()> { self.cmd_tx.send(PlayerCmd::Seek(pos)).await?; Ok(()) }
    pub async fn set_volume(&self, v: f32) -> anyhow::Result<()> { self.cmd_tx.send(PlayerCmd::SetVolume(v)).await?; Ok(()) }
}

启动 Actor

pub fn spawn_player() -> PlayerHandle {
    let (cmd_tx, mut cmd_rx) = mpsc::channel::<PlayerCmd>(32);
    let (ev_tx, _) = broadcast::channel::<PlayerEvent>(64);
    let ev = ev_tx.clone();

    std::thread::spawn(move || {
        let mut player = InnerPlayer::new(ev);
        // blocking thread,不跑 async runtime
        while let Some(cmd) = cmd_rx.blocking_recv() {
            if let Err(e) = player.handle(cmd) {
                tracing::error!("player error: {}", e);
            }
            if player.should_exit { break; }
        }
    });

    PlayerHandle { cmd_tx, events: ev_tx }
}

三、Decoder:用 Symphonia

// src-tauri/src/core/audio/decoder.rs
use std::fs::File;
use symphonia::core::{
    audio::{AudioBufferRef, SignalSpec},
    codecs::{Decoder as _, DecoderOptions},
    formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
    io::MediaSourceStream,
    meta::MetadataOptions,
    probe::Hint,
    units::Time,
};

pub struct Decoder {
    reader: Box<dyn FormatReader>,
    decoder: Box<dyn symphonia::core::codecs::Decoder>,
    track_id: u32,
    pub spec: SignalSpec,
    pub total_frames: Option<u64>,
    pub sample_rate: u32,
    pub channels: usize,
}

impl Decoder {
    pub fn open(path: &std::path::Path) -> anyhow::Result<Self> {
        let file = File::open(path)?;
        let mss = MediaSourceStream::new(Box::new(file), Default::default());
        let mut hint = Hint::new();
        if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); }

        let probed = symphonia::default::get_probe().format(
            &hint, mss, &FormatOptions::default(), &MetadataOptions::default()
        )?;
        let reader = probed.format;
        let track = reader.default_track().ok_or_else(|| anyhow::anyhow!("no default track"))?;
        let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
        let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2);
        let total_frames = track.codec_params.n_frames;
        let decoder = symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?;

        Ok(Self {
            reader, decoder, track_id: track.id,
            spec: SignalSpec::new(sample_rate, track.codec_params.channels.unwrap_or(symphonia::core::audio::Channels::FRONT_LEFT | symphonia::core::audio::Channels::FRONT_RIGHT)),
            total_frames, sample_rate, channels,
        })
    }

    pub fn next_packet(&mut self) -> anyhow::Result<Option<Vec<f32>>> {
        loop {
            let packet = match self.reader.next_packet() {
                Ok(p) => p,
                Err(symphonia::core::errors::Error::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
                Err(e) => return Err(e.into()),
            };
            if packet.track_id() != self.track_id { continue; }

            let decoded = self.decoder.decode(&packet)?;
            let mut interleaved = Vec::with_capacity(decoded.frames() * self.channels);
            copy_to_interleaved(&decoded, &mut interleaved);
            return Ok(Some(interleaved));
        }
    }

    pub fn seek(&mut self, secs: f64) -> anyhow::Result<()> {
        let time = Time::from(secs);
        self.reader.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: Some(self.track_id) })?;
        Ok(())
    }
}

fn copy_to_interleaved(buf: &AudioBufferRef<'_>, out: &mut Vec<f32>) {
    use symphonia::core::audio::Signal;
    match buf {
        AudioBufferRef::F32(b) => {
            let planes = b.planes();
            let planes = planes.planes();
            let frames = b.frames();
            let ch = planes.len();
            for i in 0..frames { for c in 0..ch { out.push(planes[c][i]); } }
        }
        _ => {
            // 实际要处理 U8/S16/S32/F64。Symphonia 自带 convert。这里演示 F32。
            let mut tmp = buf.make_equivalent::<f32>();
            buf.convert(&mut tmp);
            let planes = tmp.planes();
            let planes = planes.planes();
            let frames = tmp.frames();
            let ch = planes.len();
            for i in 0..frames { for c in 0..ch { out.push(planes[c][i]); } }
        }
    }
}

四、Output:cpal Stream + Ring Buffer

// src-tauri/src/core/audio/output.rs
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, StreamConfig};
use ringbuf::{HeapRb, HeapConsumer, HeapProducer};
use std::sync::{Arc, Mutex};

pub struct Output {
    _stream: cpal::Stream,
    pub producer: HeapProducer<f32>,
    pub config: StreamConfig,
    pub channels: u16,
}

pub fn make_output(channels: u16, target_rate: u32, volume: Arc<Mutex<f32>>) -> anyhow::Result<Output> {
    let host = cpal::default_host();
    let device = host.default_output_device().ok_or_else(|| anyhow::anyhow!("no output device"))?;

    let config: StreamConfig = cpal::StreamConfig {
        channels,
        sample_rate: cpal::SampleRate(target_rate),
        buffer_size: cpal::BufferSize::Default,
    };

    let rb = HeapRb::<f32>::new(target_rate as usize * channels as usize); // 1s buffer
    let (producer, mut consumer) = rb.split();

    let stream = device.build_output_stream(
        &config,
        move |out: &mut [f32], _| {
            let vol = *volume.lock().unwrap();
            for v in out.iter_mut() {
                *v = consumer.pop().unwrap_or(0.0) * vol;
            }
        },
        |err| tracing::error!("cpal stream error: {}", err),
        None,
    )?;
    stream.play()?;

    Ok(Output { _stream: stream, producer, config, channels })
}

五、InnerPlayer:串起 Decoder 和 Output

// player.rs 续
use super::{decoder::Decoder, output::{make_output, Output}};
use tokio::sync::broadcast;
use std::sync::{Arc, Mutex};
use std::time::{Instant, Duration};

pub struct InnerPlayer {
    ev: broadcast::Sender<PlayerEvent>,
    decoder: Option<Decoder>,
    output: Option<Output>,
    volume: Arc<Mutex<f32>>,
    duration_sec: f64,
    position_sec: f64,
    playing: bool,
    pub should_exit: bool,
    last_tick: Instant,
}

impl InnerPlayer {
    pub fn new(ev: broadcast::Sender<PlayerEvent>) -> Self {
        Self {
            ev, decoder: None, output: None,
            volume: Arc::new(Mutex::new(1.0)),
            duration_sec: 0.0, position_sec: 0.0,
            playing: false, should_exit: false,
            last_tick: Instant::now(),
        }
    }

    pub fn handle(&mut self, cmd: PlayerCmd) -> anyhow::Result<()> {
        match cmd {
            PlayerCmd::Load(path, rx) => {
                let dec = Decoder::open(&path);
                match dec {
                    Ok(d) => {
                        self.duration_sec = d.total_frames.map(|f| f as f64 / d.sample_rate as f64).unwrap_or(0.0);
                        self.position_sec = 0.0;
                        let out = make_output(d.channels as u16, d.sample_rate, self.volume.clone())?;
                        self.decoder = Some(d); self.output = Some(out);
                        let ms = (self.duration_sec * 1000.0) as u64;
                        let _ = rx.send(Ok(ms));
                    }
                    Err(e) => { let _ = rx.send(Err(e)); }
                }
            }
            PlayerCmd::Play => { self.playing = true; }
            PlayerCmd::Pause => { self.playing = false; }
            PlayerCmd::Stop => { self.playing = false; self.decoder = None; self.output = None; self.position_sec = 0.0; }
            PlayerCmd::Seek(s) => { if let Some(d) = &mut self.decoder { d.seek(s)?; self.position_sec = s; } }
            PlayerCmd::SetVolume(v) => { *self.volume.lock().unwrap() = v.clamp(0.0, 1.5); }
            PlayerCmd::Shutdown => { self.should_exit = true; }
        }
        self.pump()?;
        Ok(())
    }

    fn pump(&mut self) -> anyhow::Result<()> {
        let (Some(dec), Some(out)) = (self.decoder.as_mut(), self.output.as_mut()) else { return Ok(()); };
        if !self.playing { return Ok(()); }

        // 把几帧 decoded samples 推到 producer,直到 buffer 满
        while out.producer.free_len() > 8192 {
            match dec.next_packet()? {
                Some(samples) => {
                    for &s in &samples { out.producer.push(s).ok(); }
                    self.position_sec += samples.len() as f64 / (dec.sample_rate as f64 * dec.channels as f64);
                }
                None => {
                    self.playing = false;
                    let _ = self.ev.send(PlayerEvent::Ended);
                    return Ok(());
                }
            }
        }

        // 每 100ms 发一次状态
        if self.last_tick.elapsed() >= Duration::from_millis(100) {
            self.last_tick = Instant::now();
            let _ = self.ev.send(PlayerEvent::State {
                playing: self.playing,
                position: self.position_sec,
                duration: self.duration_sec,
            });
        }
        Ok(())
    }
}

注意:生产版的 pump 要跑在独立循环里(下一章我们会把"持续喂数据"改成 watchdog 线程)。

六、暴露到 Command

// src-tauri/src/cmds/player.rs
use crate::state::AppState;

#[tauri::command]
#[specta::specta]
pub async fn player_play(state: tauri::State<'_, AppState>, song_id: i64) -> Result<(), String> {
    let song = state.library.read().await.get_by_id(song_id).map_err(|e| e.to_string())?;
    state.player.load(song.path.into()).await.map_err(|e| e.to_string())?;
    state.player.play().await.map_err(|e| e.to_string())?;
    Ok(())
}

#[tauri::command] #[specta::specta]
pub async fn player_pause(state: tauri::State<'_, AppState>) -> Result<(), String> { state.player.pause().await.map_err(|e| e.to_string()) }

#[tauri::command] #[specta::specta]
pub async fn player_resume(state: tauri::State<'_, AppState>) -> Result<(), String> { state.player.play().await.map_err(|e| e.to_string()) }

#[tauri::command] #[specta::specta]
pub async fn player_seek(state: tauri::State<'_, AppState>, pos: f64) -> Result<(), String> { state.player.seek(pos).await.map_err(|e| e.to_string()) }

#[tauri::command] #[specta::specta]
pub async fn player_set_volume(state: tauri::State<'_, AppState>, v: f32) -> Result<(), String> { state.player.set_volume(v).await.map_err(|e| e.to_string()) }

七、事件透传到前端

在 setup 里:

let mut rx = state.player.events.subscribe();
let app_handle = app.handle().clone();
tokio::spawn(async move {
    while let Ok(ev) = rx.recv().await {
        match ev {
            PlayerEvent::State { playing, position, duration } => {
                app_handle.emit("player:progress", serde_json::json!({ "position": position, "duration": duration })).ok();
                app_handle.emit("player:state", serde_json::json!({ "playing": playing })).ok();
            }
            PlayerEvent::Ended => { app_handle.emit("player:ended", ()).ok(); }
        }
    }
});

本章小结

  • Actor + mpsc 让 Player 解耦。
  • Symphonia 解码统一各格式。
  • cpal + ring buffer 实现低延迟输出。

动手时刻

  • 把上述代码接入项目。
  • 用一首本地 MP3 测试:手动调用 player_play 传 songId,监听 player:progress。

下一章:完善 seek / 淡入淡出 / 无缝切歌。

第 27 章 Rust 音频引擎 2:播放控制、seek、音量、淡入淡出

本章目标

  • 完善 seek 行为(跳转时清空缓冲避免杂音)。
  • 实现音量渐变 / 淡入淡出。
  • 实现无缝切歌(gapless playback)。
  • 加入后台独立喂数据线程。

一、独立喂数据线程

第 26 章的 pump 是 command-triggered 的。但 cpal 是实时消费,需要持续喂。改用独立线程:

pub fn spawn_player() -> PlayerHandle {
    let (cmd_tx, mut cmd_rx) = mpsc::channel::<PlayerCmd>(32);
    let (ev_tx, _) = broadcast::channel::<PlayerEvent>(64);

    let ev_c = ev_tx.clone();
    std::thread::spawn(move || {
        let mut p = InnerPlayer::new(ev_c);
        loop {
            // 非阻塞处理命令
            while let Ok(cmd) = cmd_rx.try_recv() {
                p.handle_cmd(cmd).ok();
                if p.should_exit { return; }
            }
            // 喂数据
            p.pump_once();
            // 每 10ms tick
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
    });

    PlayerHandle { cmd_tx, events: ev_tx }
}

二、精确 Seek

Symphonia 的 seek 是按 packet 对齐的。跳转后 producer 里还有旧样本 → 杂音。

PlayerCmd::Seek(s) => {
    if let (Some(d), Some(out)) = (self.decoder.as_mut(), self.output.as_mut()) {
        d.seek(s)?;
        self.position_sec = s;
        // 清空 ring buffer
        while out.producer.pop().is_some() {}
    }
}

因为 producer 的 pop 是一次一个 sample,上面需要在 cpal 的 consumer 侧清。改法:用 HeapRb::clear 或者把 producer 侧加个 flag "正在 seek",consumer 侧读到 flag 就静音若干毫秒。

完整工业做法是换 HeapRb 为支持 reset 的 buffer(或者直接重建 output)。

三、淡入淡出 (Fade)

避免 pause/resume 的"咔哒"声:

struct Fader {
    target: f32,      // 目标音量
    current: f32,
    step: f32,        // 每帧递增
}

impl Fader {
    fn tick(&mut self, samples: &mut [f32]) {
        for s in samples.iter_mut() {
            if (self.current - self.target).abs() > f32::EPSILON {
                self.current += (self.target - self.current).signum() * self.step.min((self.target - self.current).abs());
            }
            *s *= self.current;
        }
    }
}

把 Fader 的 tick 放到 cpal callback 里:

let fader = Arc::new(Mutex::new(Fader { target: 1.0, current: 0.0, step: 0.01 }));
// cpal callback
|out: &mut [f32], _| {
    for v in out.iter_mut() { *v = consumer.pop().unwrap_or(0.0); }
    fader.lock().unwrap().tick(out);
}

Pause 时 target = 0,Resume 时 target = volume。几百 ms 内听不见突变。

四、无缝切歌(Gapless)

传统做法:当前歌快结束时(差 500ms),预加载下一首解码到 secondary buffer,在最后一帧对齐后无缝切换 producer 源。

CloudTone 简化版:

  1. 当前歌 "position >= duration - 0.5" 时,emit PlayerEvent::NearEnd。
  2. 前端决定下一首并 invoke("player_preload", nextId)。
  3. Rust 预加载第二个 decoder + buffer。
  4. 当前 decoder 结束,切换。
PlayerCmd::Preload(path) => { self.next_decoder = Some(Decoder::open(&path)?); }

fn pump_once(&mut self) {
    if let Some(dec) = self.decoder.as_mut() {
        if dec.next_packet().ok().flatten().is_none() {
            if let Some(n) = self.next_decoder.take() {
                self.decoder = Some(n);
                self.position_sec = 0.0;
                // 不发 Ended,而是发 TrackChanged
                let _ = self.ev.send(PlayerEvent::TrackChanged);
                return;
            }
            self.playing = false;
            let _ = self.ev.send(PlayerEvent::Ended);
        }
    }
}

五、ReplayGain / 响度归一化(可选)

音乐文件的响度差异大。lofty 读 ID3 的 REPLAYGAIN_TRACK_GAIN 和 REPLAYGAIN_TRACK_PEAK,换算为线性系数喂给 Fader。CloudTone 设置里可开关「响度统一」。

let gain_db: f32 = meta.get("REPLAYGAIN_TRACK_GAIN").and_then(|s| s.strip_suffix(" dB").unwrap_or(s).parse().ok()).unwrap_or(0.0);
let linear = 10f32.powf(gain_db / 20.0);
fader.lock().unwrap().target *= linear;

六、对前端暴露设置

#[tauri::command] #[specta::specta]
pub async fn player_set_fade_ms(state: tauri::State<'_, AppState>, ms: u32) -> Result<(), String> { /*...*/ Ok(()) }

#[tauri::command] #[specta::specta]
pub async fn player_set_gapless(state: tauri::State<'_, AppState>, on: bool) -> Result<(), String> { /*...*/ Ok(()) }

七、前端:seek 滑块

// src/components/player/PlayerBar.tsx
import * as Slider from "@radix-ui/react-slider";
import { usePlayer } from "@/features/player/playerStore";

function ProgressSlider() {
  const { position, duration, seek } = usePlayer();
  const [dragging, setDragging] = useState(false);
  const [local, setLocal] = useState(0);
  const value = dragging ? local : position;
  return (
    <Slider.Root
      value={[value]}
      max={duration || 1}
      step={0.1}
      onValueChange={v => { setDragging(true); setLocal(v[0]); }}
      onValueCommit={v => { setDragging(false); seek(v[0]); }}
      className="flex items-center w-full h-4"
    >
      <Slider.Track className="bg-white/10 h-1 rounded w-full"><Slider.Range className="bg-brand-500 h-1 rounded" /></Slider.Track>
      <Slider.Thumb className="block w-3 h-3 rounded-full bg-white" />
    </Slider.Root>
  );
}

本章小结

  • 独立线程保证 cpal 持续有数据。
  • Fade/gapless 让播放体验接近商业软件。
  • 前端滑块只管 UX,真逻辑在 Rust。

动手时刻

  • 接入 Fader,快速暂停/继续没有咔哒声。
  • 写一个两首歌队列测 gapless,应该听不到空隙。
  • 手动 seek 验证不爆音。

下一章:本地音乐库扫描。

第 28 章 本地音乐库扫描与元数据(lofty / walkdir)

本章目标

  • 扫描用户选定目录,读取音频文件元数据。
  • 用 lofty 抽取标题、艺人、专辑、时长、封面。
  • 通过 Channel<ScanEvent> 把进度流式发给前端。
  • 写入数据库 + 封面存到 $APPCACHE/covers/。

一、模块结构

src-tauri/src/core/library/
├── mod.rs
├── scanner.rs       # 目录遍历 + 元数据提取
├── importer.rs      # 入库逻辑
└── manager.rs       # 外部门面

二、Scanner

// scanner.rs
use std::path::{Path, PathBuf};
use lofty::{Accessor, AudioFile, ItemKey, TaggedFileExt};
use walkdir::WalkDir;

pub const AUDIO_EXT: [&str; 8] = ["mp3","flac","m4a","wav","ogg","aac","aiff","ape"];

#[derive(Debug, Clone)]
pub struct RawTrack {
    pub path: PathBuf,
    pub title: String,
    pub artist: Option<String>,
    pub album: Option<String>,
    pub track_no: Option<u32>,
    pub disc_no: Option<u32>,
    pub year: Option<u32>,
    pub duration_ms: u64,
    pub bitrate: u32,
    pub sample_rate: u32,
    pub format: String,
    pub file_size: u64,
    pub cover_bytes: Option<Vec<u8>>,
    pub cover_mime: Option<String>,
}

pub fn list_audio(root: &Path) -> Vec<PathBuf> {
    WalkDir::new(root).into_iter().filter_map(Result::ok)
        .filter(|e| e.file_type().is_file())
        .filter(|e| e.path().extension().and_then(|s| s.to_str())
            .map(|s| AUDIO_EXT.contains(&s.to_lowercase().as_str())).unwrap_or(false))
        .map(|e| e.into_path()).collect()
}

pub fn read_tags(path: &Path) -> anyhow::Result<RawTrack> {
    let tagged = lofty::read_from_path(path)?;
    let props = tagged.properties();
    let tag = tagged.primary_tag().or_else(|| tagged.first_tag());

    let title = tag.and_then(|t| t.title().map(|c| c.to_string()))
        .unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("Unknown").to_string());
    let artist = tag.and_then(|t| t.artist().map(|c| c.to_string()));
    let album = tag.and_then(|t| t.album().map(|c| c.to_string()));
    let track_no = tag.and_then(|t| t.track());
    let disc_no = tag.and_then(|t| t.disk());
    let year = tag.and_then(|t| t.year());

    let (cover_bytes, cover_mime) = tag.and_then(|t| t.pictures().first())
        .map(|p| (Some(p.data().to_vec()), Some(p.mime_type().map(|m| m.to_string()).unwrap_or_default())))
        .unwrap_or((None, None));

    let size = std::fs::metadata(path)?.len();

    Ok(RawTrack {
        path: path.to_path_buf(),
        title, artist, album, track_no, disc_no, year,
        duration_ms: props.duration().as_millis() as u64,
        bitrate: props.audio_bitrate().unwrap_or(0),
        sample_rate: props.sample_rate().unwrap_or(0),
        format: path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string(),
        file_size: size,
        cover_bytes, cover_mime,
    })
}

三、Importer:入库

// importer.rs
use sqlx::{SqlitePool, Transaction, Sqlite};
use std::path::PathBuf;
use super::scanner::RawTrack;

pub async fn upsert_artist(tx: &mut Transaction<'_, Sqlite>, name: &str) -> sqlx::Result<i64> {
    sqlx::query_scalar::<_, i64>(
        "INSERT INTO artists (name) VALUES (?) ON CONFLICT(name) DO UPDATE SET name=excluded.name RETURNING id"
    ).bind(name).fetch_one(&mut **tx).await
}

pub async fn upsert_album(tx: &mut Transaction<'_, Sqlite>, title: &str, artist_id: i64, year: Option<u32>) -> sqlx::Result<i64> {
    sqlx::query_scalar::<_, i64>(
        "INSERT INTO albums (title, artist_id, year) VALUES (?, ?, ?)
         ON CONFLICT(title, artist_id) DO UPDATE SET year=COALESCE(albums.year, excluded.year) RETURNING id"
    ).bind(title).bind(artist_id).bind(year.map(|y| y as i64)).fetch_one(&mut **tx).await
}

pub async fn insert_song(tx: &mut Transaction<'_, Sqlite>, r: &RawTrack, artist_id: Option<i64>, album_id: Option<i64>, hash: String, cover_path: Option<PathBuf>) -> sqlx::Result<i64> {
    sqlx::query_scalar::<_, i64>(
        "INSERT INTO songs (title, artist_id, album_id, path, duration_ms, track_no, disc_no, file_size, file_hash, format, bitrate, sample_rate)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
         ON CONFLICT(path) DO UPDATE SET
           title=excluded.title, artist_id=excluded.artist_id, album_id=excluded.album_id,
           duration_ms=excluded.duration_ms, bitrate=excluded.bitrate, sample_rate=excluded.sample_rate
         RETURNING id"
    )
    .bind(&r.title).bind(artist_id).bind(album_id)
    .bind(r.path.to_string_lossy())
    .bind(r.duration_ms as i64)
    .bind(r.track_no.map(|t| t as i64))
    .bind(r.disc_no.map(|t| t as i64))
    .bind(r.file_size as i64)
    .bind(hash).bind(&r.format)
    .bind(r.bitrate as i64).bind(r.sample_rate as i64)
    .fetch_one(&mut **tx).await
}

pub async fn save_cover(cache_dir: &std::path::Path, hash: &str, bytes: &[u8], mime: Option<&str>) -> anyhow::Result<PathBuf> {
    let ext = match mime {
        Some("image/png") => "png", Some("image/webp") => "webp", _ => "jpg",
    };
    let path = cache_dir.join(format!("{}.{}", hash, ext));
    if !path.exists() { tokio::fs::write(&path, bytes).await?; }
    Ok(path)
}

四、Manager & 命令

// manager.rs
use super::{scanner, importer};
use sqlx::SqlitePool;
use std::path::PathBuf;
use tauri::ipc::Channel;
use serde::Serialize;

#[derive(Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
pub enum ScanEvent {
    Started { total: usize },
    Progress { done: usize, current: String },
    Done { added: usize, updated: usize, skipped: usize, ms: u64 },
    Failed { error: String },
}

pub async fn scan_folder(pool: &SqlitePool, cache_dir: &std::path::Path, root: PathBuf, ch: Channel<ScanEvent>) -> anyhow::Result<()> {
    let start = std::time::Instant::now();
    let paths = scanner::list_audio(&root);
    ch.send(ScanEvent::Started { total: paths.len() })?;

    let (mut added, mut updated, mut skipped) = (0, 0, 0);
    for (i, p) in paths.iter().enumerate() {
        ch.send(ScanEvent::Progress { done: i, current: p.to_string_lossy().to_string() })?;
        match scanner::read_tags(p) {
            Ok(raw) => {
                let mut tx = pool.begin().await?;
                let artist_id = if let Some(a) = &raw.artist {
                    Some(importer::upsert_artist(&mut tx, a).await?)
                } else { None };
                let album_id = if let (Some(al), Some(aid)) = (&raw.album, artist_id) {
                    Some(importer::upsert_album(&mut tx, al, aid, raw.year).await?)
                } else { None };
                let hash = blake3::hash(&std::fs::read(p)?).to_hex().to_string();
                let cover_path = if let Some(b) = &raw.cover_bytes {
                    Some(importer::save_cover(cache_dir, &hash, b, raw.cover_mime.as_deref()).await?)
                } else { None };
                let _id = importer::insert_song(&mut tx, &raw, artist_id, album_id, hash, cover_path).await?;
                tx.commit().await?;
                added += 1;
            }
            Err(e) => { tracing::warn!("skip {}: {}", p.display(), e); skipped += 1; }
        }
    }

    ch.send(ScanEvent::Done { added, updated, skipped, ms: start.elapsed().as_millis() as u64 })?;
    Ok(())
}

五、Command

// cmds/library.rs
use crate::{state::AppState, core::library::manager};

#[tauri::command] #[specta::specta]
pub async fn library_scan(app: tauri::AppHandle, state: tauri::State<'_, AppState>, root: String, channel: tauri::ipc::Channel<manager::ScanEvent>) -> Result<(), String> {
    let pool = state.db.clone();
    let cache = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("covers");
    std::fs::create_dir_all(&cache).ok();
    tokio::spawn(async move {
        if let Err(e) = manager::scan_folder(&pool, &cache, root.into(), channel.clone()).await {
            let _ = channel.send(manager::ScanEvent::Failed { error: e.to_string() });
        }
    });
    Ok(())
}

六、前端调用

// src/app/library/LibraryPage.tsx
import { Channel } from "@tauri-apps/api/core";
import { commands } from "@/lib/ipc";
import { open } from "@tauri-apps/plugin-dialog";

function ImportButton() {
  const [progress, setProgress] = useState({ done: 0, total: 0 });
  async function importFolder() {
    const picked = await open({ directory: true });
    if (!picked) return;
    const ch = new Channel();
    ch.onmessage = (msg: any) => {
      if (msg.event === "started") setProgress({ done: 0, total: msg.data.total });
      if (msg.event === "progress") setProgress(p => ({ ...p, done: msg.data.done }));
      if (msg.event === "done") toast.success(`扫描完成:新增 ${msg.data.added}`);
    };
    await commands.libraryScan(picked as string, ch);
  }
  return (
    <div>
      <button onClick={importFolder} className="px-4 py-2 bg-brand-500 rounded">导入音乐文件夹</button>
      {progress.total > 0 && <div>{progress.done}/{progress.total}</div>}
    </div>
  );
}

七、实时监听变化

use notify_debouncer_full::{new_debouncer, notify::*};
use std::time::Duration;

pub fn watch_library(root: PathBuf, pool: SqlitePool, app: tauri::AppHandle) {
    let mut debouncer = new_debouncer(Duration::from_secs(3), None, move |res| {
        // 收到 change 后重新扫描对应子目录
    }).unwrap();
    debouncer.watcher().watch(&root, RecursiveMode::Recursive).ok();
    // 把 debouncer 放 state 里防止 drop
}

本章小结

扫描 + 元数据 + 入库 + 前端进度串完。CloudTone 现在已经能识别你整个音乐目录。

动手时刻

  • 导入一个 500+ 首的目录,观察进度。
  • 让 read_tags 失败的歌也进入库(占位 title = 文件名)。

下一章:数据库 Schema 与查询层细化。

第 29 章 数据库 Schema:歌曲 / 专辑 / 艺人 / 歌单 / 播放历史

本章目标

  • 完善 schema:补上 favorites、settings、metadata 索引。
  • 给出常用查询:按专辑聚合、最近添加、热度榜、搜索联接。
  • 封装 Rust 查询层 core::db::queries。

一、补完 schema

在第 19 章的基础上追加:

-- 20260201_addons.sql
CREATE TABLE IF NOT EXISTS favorites (
    song_id INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,
    added_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

CREATE TABLE IF NOT EXISTS library_roots (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    path TEXT NOT NULL UNIQUE,
    enabled INTEGER NOT NULL DEFAULT 1
);

CREATE INDEX IF NOT EXISTS idx_history_song ON play_history(song_id);
CREATE INDEX IF NOT EXISTS idx_history_played_at ON play_history(played_at DESC);

二、DTO

// src-tauri/src/core/db/models.rs
use serde::Serialize;
use specta::Type;

#[derive(Serialize, Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Song {
    pub id: i64,
    pub title: String,
    pub artist: Option<String>,
    pub album: Option<String>,
    pub artist_id: Option<i64>,
    pub album_id: Option<i64>,
    pub path: String,
    pub duration_ms: i64,
    pub track_no: Option<i64>,
    pub liked: bool,
    pub cover_path: Option<String>,
}

#[derive(Serialize, Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Album {
    pub id: i64,
    pub title: String,
    pub artist: String,
    pub year: Option<i64>,
    pub cover_path: Option<String>,
    pub song_count: i64,
}

三、查询

// src-tauri/src/core/db/queries.rs
use sqlx::SqlitePool;
use super::models::*;

pub async fn list_songs(pool: &SqlitePool, limit: i64, offset: i64) -> sqlx::Result<Vec<Song>> {
    sqlx::query_as::<_, Song>(
        "SELECT s.id, s.title, a.name AS artist, al.title AS album,
                s.artist_id, s.album_id, s.path, s.duration_ms, s.track_no,
                EXISTS(SELECT 1 FROM favorites f WHERE f.song_id = s.id) AS liked,
                al.cover_path
         FROM songs s
         LEFT JOIN artists a ON s.artist_id = a.id
         LEFT JOIN albums al ON s.album_id = al.id
         ORDER BY s.added_at DESC LIMIT ? OFFSET ?"
    ).bind(limit).bind(offset).fetch_all(pool).await
}

pub async fn list_albums(pool: &SqlitePool) -> sqlx::Result<Vec<Album>> {
    sqlx::query_as::<_, Album>(
        "SELECT al.id, al.title, COALESCE(a.name, '未知艺人') AS artist,
                al.year, al.cover_path,
                (SELECT COUNT(*) FROM songs s WHERE s.album_id = al.id) AS song_count
         FROM albums al LEFT JOIN artists a ON al.artist_id = a.id
         ORDER BY al.title"
    ).fetch_all(pool).await
}

pub async fn toggle_favorite(pool: &SqlitePool, song_id: i64) -> sqlx::Result<bool> {
    let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM favorites WHERE song_id = ?").bind(song_id).fetch_optional(pool).await?;
    if exists.is_some() {
        sqlx::query("DELETE FROM favorites WHERE song_id = ?").bind(song_id).execute(pool).await?;
        Ok(false)
    } else {
        sqlx::query("INSERT INTO favorites (song_id) VALUES (?)").bind(song_id).execute(pool).await?;
        Ok(true)
    }
}

pub async fn record_play(pool: &SqlitePool, song_id: i64, ms: u64) -> sqlx::Result<()> {
    sqlx::query("INSERT INTO play_history (song_id, duration_played_ms) VALUES (?, ?)")
        .bind(song_id).bind(ms as i64).execute(pool).await?;
    Ok(())
}

pub async fn top_played(pool: &SqlitePool, limit: i64) -> sqlx::Result<Vec<(i64, i64)>> {
    sqlx::query_as::<_, (i64, i64)>(
        "SELECT song_id, COUNT(*) AS c FROM play_history GROUP BY song_id ORDER BY c DESC LIMIT ?"
    ).bind(limit).fetch_all(pool).await
}

四、暴露命令

// cmds/library.rs
#[tauri::command] #[specta::specta]
pub async fn library_list_songs(state: tauri::State<'_, AppState>, limit: i64, offset: i64) -> Result<Vec<Song>, String> {
    queries::list_songs(&state.db, limit, offset).await.map_err(|e| e.to_string())
}

#[tauri::command] #[specta::specta]
pub async fn library_toggle_favorite(state: tauri::State<'_, AppState>, song_id: i64) -> Result<bool, String> {
    queries::toggle_favorite(&state.db, song_id).await.map_err(|e| e.to_string())
}

五、前端调用

// src/features/library/queries.ts
import { useQuery } from "@tanstack/react-query";
import { commands } from "@/lib/ipc";

export function useLibrarySongs(limit = 200, offset = 0) {
  return useQuery({
    queryKey: ["library","songs", limit, offset],
    queryFn: () => commands.libraryListSongs(limit, offset),
  });
}

本章小结

  • 查询层集中管理,避免 SQL 散落。
  • 列表页高效联表一次拿全。
  • 收藏、播放统计等都有专用索引。

动手时刻

  • 在 Library 页展示扫描结果。
  • 心形按钮切换收藏,立即反映在 UI。

下一章:播放队列与各种循环模式。

第 30 章 播放队列、随机、循环、记忆播放

本章目标

  • 让播放队列成为一等公民。
  • 实现四种模式:顺序、列表循环、单曲循环、随机。
  • 做"上次播放位置记忆"。

一、队列位于前后端哪一端

两派:

  • 纯前端:Zustand store 存队列,Rust 只知道当前歌。好处:灵活;坏处:多窗口同步要 emit。
  • 后端同步:Rust 维护队列,前端 subscribe。好处:多窗口一致;坏处:IPC 频繁。

CloudTone 采用后端主导 + 事件广播。

二、Rust Queue

// src-tauri/src/core/audio/queue.rs
use rand::seq::SliceRandom;

#[derive(Clone, Copy, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum PlayMode { Sequence, ListLoop, SingleLoop, Shuffle }

pub struct Queue {
    pub items: Vec<i64>,     // song ids
    pub index: usize,
    pub mode: PlayMode,
    shuffle_history: Vec<usize>,
}

impl Queue {
    pub fn replace(&mut self, songs: Vec<i64>, start_id: Option<i64>) {
        self.items = songs;
        self.index = start_id.and_then(|id| self.items.iter().position(|&x| x == id)).unwrap_or(0);
    }
    pub fn current(&self) -> Option<i64> { self.items.get(self.index).copied() }
    pub fn next(&mut self) -> Option<i64> {
        if self.items.is_empty() { return None; }
        match self.mode {
            PlayMode::SingleLoop => self.current(),
            PlayMode::Shuffle => {
                let mut rng = rand::thread_rng();
                let idxs: Vec<_> = (0..self.items.len()).filter(|i| *i != self.index).collect();
                self.shuffle_history.push(self.index);
                self.index = *idxs.choose(&mut rng).unwrap_or(&self.index);
                self.current()
            }
            PlayMode::Sequence => {
                if self.index + 1 < self.items.len() { self.index += 1; self.current() } else { None }
            }
            PlayMode::ListLoop => {
                self.index = (self.index + 1) % self.items.len();
                self.current()
            }
        }
    }
    pub fn prev(&mut self) -> Option<i64> {
        if self.items.is_empty() { return None; }
        if self.mode == PlayMode::Shuffle {
            if let Some(last) = self.shuffle_history.pop() { self.index = last; }
        } else if self.index > 0 { self.index -= 1; }
        else if self.mode == PlayMode::ListLoop { self.index = self.items.len() - 1; }
        self.current()
    }
}

三、Player 接入 Queue

把 Queue 放到 InnerPlayer,或者同级 AppState 里。Command:

#[tauri::command] #[specta::specta]
pub async fn queue_set(state: tauri::State<'_, AppState>, ids: Vec<i64>, start_id: Option<i64>) -> Result<(), String> {
    let mut q = state.queue.lock().await;
    q.replace(ids, start_id);
    if let Some(id) = q.current() {
        drop(q);
        player_play_internal(state, id).await?;
    }
    Ok(())
}

#[tauri::command] #[specta::specta]
pub async fn queue_next(state: tauri::State<'_, AppState>) -> Result<(), String> {
    let mut q = state.queue.lock().await;
    if let Some(id) = q.next() { drop(q); player_play_internal(state, id).await?; }
    Ok(())
}

#[tauri::command] #[specta::specta]
pub async fn queue_set_mode(state: tauri::State<'_, AppState>, mode: PlayMode) -> Result<(), String> {
    state.queue.lock().await.mode = mode; Ok(())
}

当播放 Ended 事件到来,后端自动调用 queue_next_internal 并 emit。

四、记忆播放

启动时从 DB kv 表读上次状态:

kv:
  last_song_id  -> "42"
  last_position -> "123.4"
  last_mode     -> "shuffle"

setup 里:

let last = sqlx::query_scalar::<_, String>("SELECT value FROM kv WHERE key = 'last_song_id'").fetch_optional(&pool).await.ok().flatten();
// 根据 last 加载并 pause

播放每 10 秒把当前 position 写回 kv,下次启动时 seek 到上次位置。

五、UI:队列面板

// NowPlayingPanel.tsx 加 Queue Tab
const { queue, index } = usePlayer();

<div>
  {queue.map((song, i) => (
    <div key={song.id} className={cn("p-2 rounded flex items-center gap-2", i === index && "bg-white/10")}>
      <span className="w-5 text-text-tertiary">{i + 1}</span>
      <div className="flex-1 truncate">{song.title}</div>
      <span className="text-text-secondary text-xs">{song.artist}</span>
    </div>
  ))}
</div>

拖拽排序:用 @dnd-kit/core。第 34 章详讲。

本章小结

  • Queue 主导在 Rust,一致性强。
  • 四种模式覆盖常见场景。
  • 记忆位置让回归用户零成本继续。

动手时刻

  • 实现 queue_set / queue_next,UI 按钮触发。
  • 切到随机模式,试试手感。

下一章:歌词。

第 31 章 歌词:LRC 解析与滚动同步

本章目标

  • 写一个纯 Rust 的 LRC 解析器。
  • 按播放进度定位当前行。
  • 前端实现"居中、平滑滚动、高亮当前行"。

一、LRC 格式速览

[ti:起风了]
[ar:买辣椒也用券]
[al:起风了]
[00:12.30]我曾将青春翻涌成她
[00:17.45]也曾指尖弹出盛夏

时间格式 [mm:ss.xx],一个时间可以跟多行(同词多时间戳)。

二、Rust 解析器

// src-tauri/src/core/lyrics/mod.rs
use serde::Serialize;
use specta::Type;

#[derive(Serialize, Type, Clone, Debug)]
pub struct LyricLine {
    pub ts_ms: i64,
    pub text: String,
}

pub fn parse(raw: &str) -> Vec<LyricLine> {
    let mut out = Vec::new();
    for line in raw.lines() {
        let line = line.trim_start_matches('\u{feff}').trim();
        if line.is_empty() { continue; }

        // 提取所有前缀 [..]
        let mut rest = line;
        let mut stamps: Vec<i64> = Vec::new();
        while rest.starts_with('[') {
            if let Some(end) = rest.find(']') {
                let body = &rest[1..end];
                if let Some(ms) = parse_ts(body) { stamps.push(ms); }
                rest = rest[end+1..].trim();
            } else { break; }
        }
        if stamps.is_empty() { continue; }
        for ts in stamps {
            out.push(LyricLine { ts_ms: ts, text: rest.to_string() });
        }
    }
    out.sort_by_key(|l| l.ts_ms);
    out
}

fn parse_ts(s: &str) -> Option<i64> {
    // "mm:ss.xx" or "mm:ss.xxx"
    let (mm, rest) = s.split_once(':')?;
    let m: i64 = mm.parse().ok()?;
    let (ss, ms) = rest.split_once('.').unwrap_or((rest, "0"));
    let s: i64 = ss.parse().ok()?;
    let ms_len = ms.len();
    let ms: i64 = ms.parse().ok()?;
    let ms = match ms_len { 1 => ms * 100, 2 => ms * 10, 3 => ms, _ => return None };
    Some(m * 60000 + s * 1000 + ms)
}

单元测试:

#[test]
fn basic() {
    let s = "[00:12.30]hello\n[01:05.000]world";
    let r = parse(s);
    assert_eq!(r[0].ts_ms, 12_300);
    assert_eq!(r[1].ts_ms, 65_000);
}

三、歌词来源策略

  1. 同名 .lrc(/music/起风了.lrc)。
  2. ID3 里的 USLT(不同步)或 SYLT(同步)。
  3. 在线音源 Provider(第 38 章)。
pub async fn load_lyrics_for_song(pool: &SqlitePool, providers: &ProviderRegistry, song_id: i64) -> anyhow::Result<Vec<LyricLine>> {
    let song = queries::get_song(pool, song_id).await?;
    // 1. 同名文件
    let lrc_path = std::path::Path::new(&song.path).with_extension("lrc");
    if lrc_path.exists() {
        let raw = tokio::fs::read_to_string(lrc_path).await?;
        return Ok(parse(&raw));
    }
    // 2. ID3(lofty tag USLT)
    if let Ok(tag) = lofty::read_from_path(&song.path) {
        if let Some(t) = tag.primary_tag() {
            for item in t.items() {
                if matches!(item.key(), lofty::ItemKey::Lyrics) {
                    if let Some(val) = item.value().text() {
                        let p = parse(val);
                        if !p.is_empty() { return Ok(p); }
                    }
                }
            }
        }
    }
    // 3. 在线
    if let Some(provider) = providers.primary() {
        if let Ok(raw) = provider.fetch_lyrics(&song).await {
            return Ok(parse(&raw));
        }
    }
    Ok(vec![])
}

四、当前行定位

pub fn find_line(lines: &[LyricLine], pos_ms: i64) -> Option<usize> {
    let mut lo = 0i32; let mut hi = lines.len() as i32 - 1;
    let mut ans = None;
    while lo <= hi {
        let mid = ((lo + hi) / 2) as usize;
        if lines[mid].ts_ms <= pos_ms { ans = Some(mid); lo = mid as i32 + 1; }
        else { hi = mid as i32 - 1; }
    }
    ans
}

前端拿到完整 lines 后,随着 player:progress 事件本地计算当前行(省去 IPC 频次)。

五、前端渲染

// src/components/shell/LyricsView.tsx
import { useEffect, useRef } from "react";
import { cn } from "@/lib/cn";

interface Line { tsMs: number; text: string; }

export function LyricsView({ lines, positionSec }: { lines: Line[]; positionSec: number }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const posMs = positionSec * 1000;
  const active = findActive(lines, posMs);

  useEffect(() => {
    if (active < 0) return;
    const el = containerRef.current?.querySelector<HTMLDivElement>(`[data-line="${active}"]`);
    el?.scrollIntoView({ block: "center", behavior: "smooth" });
  }, [active]);

  return (
    <div ref={containerRef} className="h-full overflow-y-auto scrollbar-none space-y-3 py-16 text-center">
      {lines.map((l, i) => (
        <div key={i} data-line={i} className={cn(
          "transition-all duration-300",
          i === active ? "text-white text-base scale-105" : "text-text-tertiary text-sm",
        )}>{l.text}</div>
      ))}
    </div>
  );
}

function findActive(lines: Line[], posMs: number) {
  let lo = 0, hi = lines.length - 1, ans = -1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    if (lines[mid].tsMs <= posMs) { ans = mid; lo = mid + 1; } else hi = mid - 1;
  }
  return ans;
}

六、加载歌词 Hook

export function useLyrics(songId?: number) {
  return useQuery({
    queryKey: ["lyrics", songId],
    queryFn: () => songId ? commands.lyricsLoad(songId) : Promise.resolve([]),
    enabled: !!songId,
    staleTime: Infinity,
  });
}

本章小结

  • LRC 解析器不复杂,掌握细节稳。
  • 歌词定位前端做,效率最高。
  • 平滑滚动 + 高亮大幅提升体验。

动手时刻

  • 准备一首歌 + 同名 .lrc,验证加载。
  • 拖动进度条测试高亮行立即跟随。

下一章:封面、缓存与自定义 custom:// 协议。

第 32 章 专辑封面、缓存与 custom:// 协议

本章目标

  • 注册 cover://<hash> 协议,让前端 <img> 直接渲染 cache 里的封面。
  • 明白 Tauri 2.x 注册自定义协议的两种方式。
  • 处理缩略图、懒加载、CSP 白名单。

一、为什么要 custom 协议

前端 <img src="..."> 只能走:

  • http:// / https://:需要 CORS。
  • data:...:base64 过大。
  • asset:// / Tauri 内置:只能访问打包进去的资源。
  • 本地绝对路径:不安全且会被 CSP 拒绝。

所以我们自定义 cover://<song_or_hash>,Rust 侧读磁盘返回字节,前端直接 src="cover://abc123"。

二、注册 URI Scheme

// src-tauri/src/lib.rs
use tauri::{http::Response, Manager};

tauri::Builder::default()
    // ... plugins, setup
    .register_uri_scheme_protocol("cover", |app, req| {
        let handle = app.clone();
        let uri = req.uri().clone();
        tauri::async_runtime::block_on(async move {
            let path = serve_cover(&handle, &uri).await;
            match path {
                Ok((bytes, mime)) => Response::builder()
                    .status(200)
                    .header("Content-Type", mime)
                    .header("Access-Control-Allow-Origin", "*")
                    .body(bytes).unwrap(),
                Err(_) => Response::builder().status(404).body(Vec::new()).unwrap(),
            }
        })
    })

serve_cover:

async fn serve_cover(app: &tauri::AppHandle, uri: &tauri::http::Uri) -> anyhow::Result<(Vec<u8>, &'static str)> {
    // cover://fallback 或 cover://<hash>
    let host = uri.host().unwrap_or("fallback");
    if host == "fallback" {
        return Ok((include_bytes!("../../icons/placeholder-cover.png").to_vec(), "image/png"));
    }
    let cache = app.path().app_cache_dir()?.join("covers");
    for ext in ["jpg","png","webp"] {
        let p = cache.join(format!("{}.{}", host, ext));
        if p.exists() {
            let bytes = tokio::fs::read(&p).await?;
            let mime = match ext { "png" => "image/png", "webp" => "image/webp", _ => "image/jpeg" };
            return Ok((bytes, mime));
        }
    }
    anyhow::bail!("not found")
}

三、CSP 修改

tauri.conf.json 的 security.csp:

"csp": "default-src 'self'; img-src 'self' data: blob: cover: https: http: asset:; media-src 'self' asset: cover: https: http:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http: https:"

img-src 和 media-src 中加入 cover: scheme。

四、前端用法

<img src={`cover://${song.fileHash}`} className="w-12 h-12 rounded" />
<img src="cover://fallback" />

简单、快速、零 IPC。

五、缩略图生成(可选优化)

大封面(2000×2000 JPG)每次加载可能 500KB+。生成 128×128 缩略图:

use image::imageops::FilterType;

pub async fn ensure_thumb(cache: &Path, hash: &str) -> anyhow::Result<()> {
    let src = cache.join(format!("{}.jpg", hash));
    let thumb = cache.join(format!("{}-thumb.webp", hash));
    if thumb.exists() { return Ok(()); }
    let img = image::open(&src)?;
    let small = img.resize(128, 128, FilterType::Triangle);
    small.save_with_format(&thumb, image::ImageFormat::WebP)?;
    Ok(())
}

协议中按 host 前缀判断:cover://thumb/abc123。

六、懒加载 & 并发限制

列表一屏可能有几十张封面。浏览器 loading="lazy" + IntersectionObserver 控制。Rust 侧对 serve_cover 用 semaphore 限流。

<img loading="lazy" src={`cover://${song.fileHash}`} />

七、另一个坑:本地音频文件能不能也用协议?

能。原理一样:注册 track:// 协议,返回 audio/mp3 bytes。但大文件推荐走 asset://(Tauri 内置,支持 Range 请求),或者前端只做列表,实际播放在 Rust 完成(CloudTone 即如此)。

本章小结

  • register_uri_scheme_protocol 是 Tauri 最强大的扩展点之一。
  • CSP 必须同步更新。
  • 封面缓存 + 协议 = 前端零成本显示。

动手时刻

  • 实现 cover:// 协议。
  • Library 页每一行显示真实封面。
  • 切换到深色/浅色主题,fallback 封面也跟着换。

下一章:搜索。

第 33 章 搜索:本地全文索引与在线搜索

本章目标

  • 用 SQLite FTS5 做本地搜索(标题 / 艺人 / 专辑)。
  • 处理中文分词:2-gram。
  • 前端搜索框:防抖、键盘快捷键、结果分组。
  • 融合在线 Provider 搜索结果。

一、FTS5 回顾

第 19 章已建:

CREATE VIRTUAL TABLE songs_fts USING fts5(
    title, artist, album, content='', tokenize='unicode61'
);

unicode61 对英文、拉丁字母很好,但对中文"无助"——它只能整行匹配。方案:自己做 2-gram(把 "起风了" → "起风 风了")。

二、触发器保持同步

CREATE TRIGGER songs_ai AFTER INSERT ON songs BEGIN
    INSERT INTO songs_fts(rowid, title, artist, album)
    VALUES (new.id,
            gram2(new.title),
            gram2((SELECT name FROM artists WHERE id = new.artist_id)),
            gram2((SELECT title FROM albums WHERE id = new.album_id)));
END;

gram2 不是 SQLite 内置函数,要通过 sqlx 注册自定义函数,或者在 Rust 层构造好字符串再 insert(更简单)。CloudTone 选后者:

pub fn gram2(s: &str) -> String {
    let chars: Vec<char> = s.chars().collect();
    if chars.len() <= 1 { return s.to_string(); }
    let mut out = String::new();
    for w in chars.windows(2) {
        out.push(w[0]); out.push(w[1]); out.push(' ');
    }
    out.push_str(s); // 也保留原词,便于英文匹配
    out
}

三、重建索引

// core/db/search.rs
pub async fn rebuild_fts(pool: &SqlitePool) -> sqlx::Result<()> {
    sqlx::query("DELETE FROM songs_fts").execute(pool).await?;
    let rows = sqlx::query_as::<_, (i64, String, Option<String>, Option<String>)>(
        "SELECT s.id, s.title, a.name, al.title
         FROM songs s LEFT JOIN artists a ON s.artist_id = a.id
         LEFT JOIN albums al ON s.album_id = al.id"
    ).fetch_all(pool).await?;
    for (id, t, ar, al) in rows {
        sqlx::query("INSERT INTO songs_fts(rowid, title, artist, album) VALUES (?, ?, ?, ?)")
            .bind(id)
            .bind(gram2(&t))
            .bind(ar.as_deref().map(gram2).unwrap_or_default())
            .bind(al.as_deref().map(gram2).unwrap_or_default())
            .execute(pool).await?;
    }
    Ok(())
}

四、查询

pub async fn search_local(pool: &SqlitePool, q: &str, limit: i64) -> sqlx::Result<Vec<Song>> {
    let q_gram = gram2(q);
    sqlx::query_as::<_, Song>(
        "SELECT s.id, s.title, a.name AS artist, al.title AS album, s.artist_id, s.album_id,
                s.path, s.duration_ms, s.track_no,
                EXISTS(SELECT 1 FROM favorites WHERE song_id = s.id) AS liked, al.cover_path
         FROM songs_fts f
         JOIN songs s ON s.id = f.rowid
         LEFT JOIN artists a ON s.artist_id = a.id
         LEFT JOIN albums al ON s.album_id = al.id
         WHERE songs_fts MATCH ?
         ORDER BY rank LIMIT ?"
    ).bind(q_gram).bind(limit).fetch_all(pool).await
}

FTS5 的 rank 是 bm25 相关度,越小越相关(注意是"越小越好")。

五、命令 & 前端 Hook

#[tauri::command] #[specta::specta]
pub async fn search(state: tauri::State<'_, AppState>, q: String) -> Result<SearchResult, String> {
    let q = q.trim().to_string();
    if q.is_empty() { return Ok(SearchResult::default()); }
    let local = search_local(&state.db, &q, 50).await.map_err(|e| e.to_string())?;
    Ok(SearchResult { local, online: vec![] })
}
// src/features/search/useSearch.ts
import { useQuery } from "@tanstack/react-query";
import { commands } from "@/lib/ipc";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";

export function useSearch(q: string) {
  const debounced = useDebouncedValue(q, 200);
  return useQuery({
    queryKey: ["search", debounced],
    queryFn: () => commands.search(debounced),
    enabled: debounced.length > 0,
  });
}

useDebouncedValue 自己写(第 44 章展开):

import { useEffect, useState } from "react";
export function useDebouncedValue<T>(v: T, ms = 200) {
  const [d, setD] = useState(v);
  useEffect(() => { const t = setTimeout(() => setD(v), ms); return () => clearTimeout(t); }, [v, ms]);
  return d;
}

六、UI

// src/components/shell/SearchOverlay.tsx
import { useHotkeys } from "@/hooks/useHotkeys";
import { useState } from "react";
import { useSearch } from "@/features/search/useSearch";

export function SearchOverlay() {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");
  const { data } = useSearch(q);

  useHotkeys("mod+k", () => setOpen(true));
  useHotkeys("escape", () => setOpen(false), { enabled: open });

  if (!open) return null;
  return (
    <div className="fixed inset-0 bg-black/60 z-50 flex items-start justify-center pt-24" onClick={() => setOpen(false)}>
      <div className="w-[620px] bg-surface-2 rounded-xl shadow-2xl" onClick={e => e.stopPropagation()}>
        <input autoFocus value={q} onChange={e => setQ(e.target.value)}
               placeholder="搜索歌曲、专辑、艺人"
               className="w-full px-4 py-3 bg-transparent border-b border-white/10 outline-none" />
        <div className="max-h-96 overflow-y-auto">
          {data?.local.map(s => (
            <SongRow key={s.id} song={s} onClick={() => { playSong(s.id); setOpen(false); }} />
          ))}
        </div>
      </div>
    </div>
  );
}

七、在线 Provider(预留)

第 38 章会实现 Provider 接口:

#[async_trait::async_trait]
pub trait MusicProvider: Send + Sync {
    async fn search(&self, q: &str) -> anyhow::Result<Vec<SongMeta>>;
    async fn fetch_lyrics(&self, song: &Song) -> anyhow::Result<String>;
    async fn fetch_cover(&self, song: &Song) -> anyhow::Result<Vec<u8>>;
}

search 命令可并发调用本地 + 若干 provider,tokio::join!,合并去重后返回。

八、高亮匹配片段

前端渲染时,把 query 的每个字符拆成 <mark> 包裹。简化版:

function Highlight({ text, q }: { text: string; q: string }) {
  if (!q) return <>{text}</>;
  const re = new RegExp(q.split("").map(escape).join(".*?"), "i");
  const m = text.match(re);
  if (!m) return <>{text}</>;
  const i = text.indexOf(m[0]);
  return <>{text.slice(0, i)}<mark className="bg-brand-500/40">{m[0]}</mark>{text.slice(i + m[0].length)}</>;
}

本章小结

  • FTS5 + 2-gram 是中文小数据量下最省心的方案。
  • 搜索体验的关键:快捷键、防抖、结果分组。
  • 本地优先、在线补足,体验一致。

动手时刻

  • 扫描 500 首后搜一个关键词,验证耗时。
  • Cmd+K 打开搜索弹窗,Enter 播放第一条。

下一章:歌单与拖拽排序。

第 34 章 歌单 CRUD 与拖拽排序

本章目标

  • 歌单的增删改查。
  • 歌单内歌曲的顺序管理(position 字段)。
  • 用 @dnd-kit 实现拖拽排序。
  • 右键菜单:添加到歌单、从歌单移除。

一、Schema 回顾

CREATE TABLE playlists (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    cover_path TEXT,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

CREATE TABLE playlist_songs (
    playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
    song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
    position INTEGER NOT NULL,
    PRIMARY KEY (playlist_id, song_id)
);
CREATE INDEX idx_ps_order ON playlist_songs(playlist_id, position);

position 用整数浮点策略:插入 / 移动时给新位置一个介于前后 position 中点的值,避免全表重排。例如 10、20、30 之间插入就是 15、25。

二、Rust 查询层

// core/db/queries.rs
pub async fn create_playlist(pool: &SqlitePool, name: &str) -> sqlx::Result<i64> {
    sqlx::query_scalar::<_, i64>(
        "INSERT INTO playlists (name) VALUES (?) RETURNING id"
    ).bind(name).fetch_one(pool).await
}

pub async fn list_playlists(pool: &SqlitePool) -> sqlx::Result<Vec<Playlist>> {
    sqlx::query_as::<_, Playlist>(
        "SELECT p.id, p.name, p.cover_path,
                (SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = p.id) AS song_count
         FROM playlists p ORDER BY p.created_at DESC"
    ).fetch_all(pool).await
}

pub async fn add_to_playlist(pool: &SqlitePool, pid: i64, sid: i64) -> sqlx::Result<()> {
    let max: Option<i64> = sqlx::query_scalar(
        "SELECT MAX(position) FROM playlist_songs WHERE playlist_id = ?"
    ).bind(pid).fetch_optional(pool).await?.flatten();
    let pos = max.unwrap_or(0) + 1000;
    sqlx::query("INSERT OR IGNORE INTO playlist_songs (playlist_id, song_id, position) VALUES (?, ?, ?)")
        .bind(pid).bind(sid).bind(pos).execute(pool).await?;
    Ok(())
}

pub async fn move_in_playlist(pool: &SqlitePool, pid: i64, sid: i64, new_prev: Option<i64>, new_next: Option<i64>) -> sqlx::Result<()> {
    let prev_pos: i64 = match new_prev {
        Some(id) => sqlx::query_scalar("SELECT position FROM playlist_songs WHERE playlist_id = ? AND song_id = ?")
            .bind(pid).bind(id).fetch_one(pool).await?,
        None => 0,
    };
    let next_pos: i64 = match new_next {
        Some(id) => sqlx::query_scalar("SELECT position FROM playlist_songs WHERE playlist_id = ? AND song_id = ?")
            .bind(pid).bind(id).fetch_one(pool).await?,
        None => prev_pos + 2000,
    };
    let new_pos = (prev_pos + next_pos) / 2;
    // 冲突时 rebalance
    if (next_pos - prev_pos).abs() < 2 {
        rebalance(pool, pid).await?;
        return Ok(());
    }
    sqlx::query("UPDATE playlist_songs SET position = ? WHERE playlist_id = ? AND song_id = ?")
        .bind(new_pos).bind(pid).bind(sid).execute(pool).await?;
    Ok(())
}

async fn rebalance(pool: &SqlitePool, pid: i64) -> sqlx::Result<()> {
    let ids: Vec<i64> = sqlx::query_scalar(
        "SELECT song_id FROM playlist_songs WHERE playlist_id = ? ORDER BY position"
    ).bind(pid).fetch_all(pool).await?;
    let mut tx = pool.begin().await?;
    for (i, id) in ids.iter().enumerate() {
        sqlx::query("UPDATE playlist_songs SET position = ? WHERE playlist_id = ? AND song_id = ?")
            .bind(((i + 1) * 1000) as i64).bind(pid).bind(id).execute(&mut *tx).await?;
    }
    tx.commit().await?;
    Ok(())
}

三、命令

#[tauri::command] #[specta::specta]
pub async fn playlist_create(state: tauri::State<'_, AppState>, name: String) -> Result<i64, String> {
    queries::create_playlist(&state.db, &name).await.map_err(|e| e.to_string())
}

#[tauri::command] #[specta::specta]
pub async fn playlist_add(state: tauri::State<'_, AppState>, pid: i64, sid: i64) -> Result<(), String> {
    queries::add_to_playlist(&state.db, pid, sid).await.map_err(|e| e.to_string())
}

#[tauri::command] #[specta::specta]
pub async fn playlist_move(state: tauri::State<'_, AppState>, pid: i64, sid: i64, prev: Option<i64>, next: Option<i64>) -> Result<(), String> {
    queries::move_in_playlist(&state.db, pid, sid, prev, next).await.map_err(|e| e.to_string())
}

四、前端拖拽

pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
// src/app/playlist/PlaylistSongs.tsx
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, arrayMove, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { commands } from "@/lib/ipc";

export function PlaylistSongs({ playlistId, songs, refetch }: Props) {
  const [items, setItems] = useState(songs);
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));

  async function onDragEnd(e: any) {
    const { active, over } = e;
    if (!over || active.id === over.id) return;
    const oldIdx = items.findIndex(s => s.id === active.id);
    const newIdx = items.findIndex(s => s.id === over.id);
    const next = arrayMove(items, oldIdx, newIdx);
    setItems(next);
    const prev = next[newIdx - 1]?.id ?? null;
    const after = next[newIdx + 1]?.id ?? null;
    await commands.playlistMove(playlistId, active.id, prev, after);
    refetch();
  }

  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
      <SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
        {items.map((s, i) => <SortableRow key={s.id} song={s} index={i} />)}
      </SortableContext>
    </DndContext>
  );
}

function SortableRow({ song, index }: { song: Song; index: number }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: song.id });
  const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 };
  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}
         className="flex items-center gap-3 px-3 py-2 hover:bg-white/5 rounded">
      <span className="w-6 text-text-tertiary">{index + 1}</span>
      <div className="flex-1 truncate">{song.title}</div>
      <span className="text-text-secondary">{song.artist}</span>
    </div>
  );
}

五、右键菜单

Tauri 2.x 的 WebView 支持原生右键菜单,但最灵活还是用纯前端 @radix-ui/react-context-menu:

import * as Ctx from "@radix-ui/react-context-menu";

<Ctx.Root>
  <Ctx.Trigger asChild>{children}</Ctx.Trigger>
  <Ctx.Portal>
    <Ctx.Content className="bg-surface-2 rounded p-1 shadow-xl">
      <Ctx.Item onClick={playNext}>下一首播放</Ctx.Item>
      <Ctx.Sub>
        <Ctx.SubTrigger>添加到歌单</Ctx.SubTrigger>
        <Ctx.SubContent>
          {playlists.map(p => <Ctx.Item key={p.id} onClick={() => addTo(p.id)}>{p.name}</Ctx.Item>)}
          <Ctx.Separator />
          <Ctx.Item onClick={createNew}>新建歌单…</Ctx.Item>
        </Ctx.SubContent>
      </Ctx.Sub>
      <Ctx.Item onClick={toggleLike}>{liked ? "取消喜欢" : "喜欢"}</Ctx.Item>
    </Ctx.Content>
  </Ctx.Portal>
</Ctx.Root>

本章小结

  • 中点插入 + 偶尔 rebalance 是稳定高效的顺序方案。
  • @dnd-kit 是 React 里拖拽体验最好的库。
  • 右键菜单用 Radix 即可,跨平台一致。

动手时刻

  • 新建一个"我的收藏"歌单,拖动排序。
  • 右键歌曲 → 添加到歌单。

下一章:收藏、最近播放、每日推荐。

第 35 章 收藏、最近播放、每日推荐

本章目标

  • 把"喜欢"做成一等公民:心形按钮、单独页面。
  • 最近播放:24 小时、7 天、30 天切换。
  • 每日推荐:基于播放历史的"离线算法"。

一、喜欢 / 收藏

pub async fn liked_songs(pool: &SqlitePool) -> sqlx::Result<Vec<Song>> {
    sqlx::query_as::<_, Song>(
        "SELECT s.id, s.title, a.name AS artist, al.title AS album, s.artist_id, s.album_id,
                s.path, s.duration_ms, s.track_no, 1 AS liked, al.cover_path
         FROM favorites f JOIN songs s ON s.id = f.song_id
         LEFT JOIN artists a ON s.artist_id = a.id
         LEFT JOIN albums al ON s.album_id = al.id
         ORDER BY f.added_at DESC"
    ).fetch_all(pool).await
}

前端:

export function LikeButton({ song }: { song: Song }) {
  const qc = useQueryClient();
  const mutate = useMutation({
    mutationFn: () => commands.libraryToggleFavorite(song.id),
    onSuccess: () => qc.invalidateQueries(["library"]),
  });
  return (
    <button onClick={() => mutate.mutate()} className={cn("transition-colors",
      song.liked ? "text-brand-500" : "text-text-tertiary hover:text-white")}>
      <Heart fill={song.liked ? "currentColor" : "none"} />
    </button>
  );
}

二、最近播放

pub async fn recent_played(pool: &SqlitePool, hours: i64, limit: i64) -> sqlx::Result<Vec<Song>> {
    sqlx::query_as::<_, Song>(
        "SELECT DISTINCT s.id, s.title, a.name AS artist, al.title AS album, s.artist_id, s.album_id,
                s.path, s.duration_ms, s.track_no,
                EXISTS(SELECT 1 FROM favorites WHERE song_id = s.id) AS liked, al.cover_path
         FROM play_history h
         JOIN songs s ON s.id = h.song_id
         LEFT JOIN artists a ON s.artist_id = a.id
         LEFT JOIN albums al ON s.album_id = al.id
         WHERE h.played_at > strftime('%s','now') - ? * 3600
         ORDER BY h.played_at DESC LIMIT ?"
    ).bind(hours).bind(limit).fetch_all(pool).await
}

UI 用 Tabs:

<Tabs defaultValue="24">
  <TabsList><TabsTrigger value="24">24 小时</TabsTrigger><TabsTrigger value="168">7 天</TabsTrigger><TabsTrigger value="720">30 天</TabsTrigger></TabsList>
  <TabsContent value="24"><RecentList hours={24} /></TabsContent>
  ...
</Tabs>

三、每日推荐:离线算法

完整推荐系统需要向量化、ANN。离线一个够用的版本:从你听过的歌中挑喜欢度高的 → 找同艺人 / 同专辑的未听 → 打分排序。

pub async fn daily_recommend(pool: &SqlitePool, limit: i64) -> sqlx::Result<Vec<Song>> {
    // 1. 候选:同艺人/同专辑且最近 30 天未播放
    sqlx::query_as::<_, Song>(r#"
        WITH loved AS (
          SELECT s.artist_id, s.album_id
          FROM play_history h JOIN songs s ON s.id = h.song_id
          GROUP BY s.id
          HAVING COUNT(*) >= 3 OR EXISTS(SELECT 1 FROM favorites WHERE song_id = s.id)
        ),
        unheard AS (
          SELECT id FROM songs
          WHERE id NOT IN (
            SELECT DISTINCT song_id FROM play_history
            WHERE played_at > strftime('%s','now') - 30 * 86400
          )
        )
        SELECT s.id, s.title, a.name AS artist, al.title AS album, s.artist_id, s.album_id,
               s.path, s.duration_ms, s.track_no,
               EXISTS(SELECT 1 FROM favorites WHERE song_id = s.id) AS liked, al.cover_path
        FROM songs s
        LEFT JOIN artists a ON s.artist_id = a.id
        LEFT JOIN albums al ON s.album_id = al.id
        WHERE s.id IN unheard
          AND (s.artist_id IN (SELECT artist_id FROM loved)
               OR s.album_id IN (SELECT album_id FROM loved))
        ORDER BY RANDOM() LIMIT ?
    "#).bind(limit).fetch_all(pool).await
}

为确保"每日"不变,种子用日期:

use chrono::Local;
let seed = Local::now().format("%Y%m%d").to_string().parse::<u64>().unwrap();

然后用 rand_chacha::ChaCha8Rng::seed_from_u64(seed) 打乱候选,取前 20。

四、首页 "每日三十首"

export function HomePage() {
  const daily = useQuery(["daily"], () => commands.homeDaily(30));
  const recent = useQuery(["recent", 24], () => commands.homeRecent(24, 20));
  return (
    <div className="grid grid-cols-2 gap-6">
      <Section title="每日推荐" songs={daily.data} />
      <Section title="最近播放" songs={recent.data} />
    </div>
  );
}

五、播放计数精细化

play_history 粒度 = 一次完整播放。但很多人会切歌。改进:

  • 记录 duration_played_ms(已在 schema)。
  • 只把 duration_played_ms >= 30s 或 >= 50% 时长 视作"一次播放"。
  • 查询时 WHERE duration_played_ms >= MIN(30000, duration_ms / 2)。

本章小结

  • 收藏 / 最近播放 / 推荐是用户黏性的核心。
  • 简单规则的离线推荐也很好用,先跑起来再换复杂算法。
  • 日期种子让"每日"有仪式感。

动手时刻

  • 首页展示"每日推荐"卡片,点播放。
  • 最近播放切换时间范围。

下一章:迷你播放器与桌面歌词悬浮窗。

第 36 章 迷你播放器、桌面歌词悬浮窗

本章目标

  • 新建第二个 WebviewWindow:mini-player,340×140,透明+无边框。
  • 新建第三个窗口:lyric-overlay,透明、置顶、所有工作区可见。
  • 窗口间通信:事件广播 + 共享 AppState。

一、窗口配置

tauri.conf.json 的 app.windows 可以声明多个,但更灵活的是在 Rust 里 WebviewWindowBuilder 按需建。

// core/window/mini.rs
use tauri::{WebviewWindowBuilder, WebviewUrl, AppHandle};

pub fn open_mini(app: &AppHandle) -> tauri::Result<()> {
    if let Some(w) = app.get_webview_window("mini") { w.set_focus()?; return Ok(()); }
    let win = WebviewWindowBuilder::new(app, "mini", WebviewUrl::App("mini.html".into()))
        .title("CloudTone Mini")
        .inner_size(340.0, 140.0)
        .decorations(false)
        .transparent(true)
        .always_on_top(true)
        .resizable(false)
        .shadow(false)
        .skip_taskbar(true)
        .build()?;
    // 让迷你窗口默认右下角
    if let Some(monitor) = win.current_monitor()? {
        let size = monitor.size();
        win.set_position(tauri::PhysicalPosition::new(
            (size.width as i32) - 360,
            (size.height as i32) - 180,
        ))?;
    }
    Ok(())
}

pub fn open_lyric(app: &AppHandle) -> tauri::Result<()> {
    if let Some(w) = app.get_webview_window("lyric") { w.set_focus()?; return Ok(()); }
    WebviewWindowBuilder::new(app, "lyric", WebviewUrl::App("lyric.html".into()))
        .title("桌面歌词")
        .inner_size(1000.0, 120.0)
        .decorations(false)
        .transparent(true)
        .always_on_top(true)
        .skip_taskbar(true)
        .position(100.0, 50.0)
        .build()?;
    Ok(())
}

二、Vite 多入口

vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      input: {
        main: resolve(__dirname, "index.html"),
        mini: resolve(__dirname, "mini.html"),
        lyric: resolve(__dirname, "lyric.html"),
      },
    },
  },
});

根目录新增 mini.html / lyric.html,各自引用不同的 entry TSX:

<!-- mini.html -->
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Mini</title></head>
<body><div id="root"></div><script type="module" src="/src/mini/main.tsx"></script></body></html>
// src/mini/main.tsx
import ReactDOM from "react-dom/client";
import "../index.css";
import { MiniApp } from "./MiniApp";
ReactDOM.createRoot(document.getElementById("root")!).render(<MiniApp />);

三、迷你播放器 UI

// src/mini/MiniApp.tsx
import { usePlayerSync, usePlayer } from "@/features/player/player";

export function MiniApp() {
  usePlayerSync();
  const { currentSong, isPlaying, toggle, next, prev } = usePlayer();
  return (
    <div data-tauri-drag-region
         className="h-screen w-screen bg-[rgba(20,20,30,0.85)] backdrop-blur-xl rounded-xl p-3 flex items-center gap-3 text-white">
      <img src={`cover://${currentSong?.fileHash ?? 'fallback'}`} className="w-14 h-14 rounded-lg" />
      <div className="flex-1 min-w-0">
        <div className="truncate font-medium">{currentSong?.title ?? "未在播放"}</div>
        <div className="truncate text-text-secondary text-xs">{currentSong?.artist}</div>
      </div>
      <div className="flex items-center gap-2">
        <button onClick={prev}><SkipBack size={18} /></button>
        <button onClick={toggle}>{isPlaying ? <Pause size={22} /> : <Play size={22} />}</button>
        <button onClick={next}><SkipForward size={18} /></button>
      </div>
    </div>
  );
}

data-tauri-drag-region 是 Tauri 的拖动提示:任何设置了它的元素,用户拖动窗口就能移动。

四、让所有窗口共享播放状态

播放器后端通过 broadcast::Sender<PlayerEvent> 发事件,Tauri 层做桥接:

// setup 里
let handle = app.handle().clone();
let mut rx = player.subscribe();
tokio::spawn(async move {
    while let Ok(ev) = rx.recv().await {
        let _ = handle.emit("player:event", &ev);
    }
});

前端 (所有窗口) 用同一个 usePlayerSync 监听 player:event 即可,无需区分窗口。

五、歌词悬浮窗

lyric.html 只渲染一行/两行歌词:

export function LyricOverlay() {
  usePlayerSync();
  const { currentSong, progressSec } = usePlayer();
  const { data: lines = [] } = useLyrics(currentSong?.id);
  const line = useMemo(() => findActive(lines, progressSec * 1000), [lines, progressSec]);
  const next = lines[Math.max(line + 1, 0)];
  return (
    <div data-tauri-drag-region
         className="h-screen w-screen flex flex-col items-center justify-center text-center select-none">
      <div className="text-4xl font-bold text-white drop-shadow-lg">{lines[line]?.text}</div>
      <div className="text-xl text-white/60 mt-1">{next?.text}</div>
    </div>
  );
}

六、点击穿透(仅桌面歌词)

用户希望鼠标穿透歌词悬浮窗。Tauri 2.x:

if let Some(w) = app.get_webview_window("lyric") {
    w.set_ignore_cursor_events(true)?;
}

进入"锁定模式"时开启,前端切换状态时调用 command。

七、托盘菜单控制

在 Tray 里加入:

Submenu::with_items(app, "窗口", true, &[
    &MenuItem::with_id(app, "open_mini", "打开迷你播放器", true, None::<&str>)?,
    &MenuItem::with_id(app, "open_lyric", "打开桌面歌词", true, None::<&str>)?,
])?

本章小结

  • Tauri 多窗口是它对 Electron 的一大优势:单进程多 WebView,资源共享。
  • 透明 + 无边框 + always_on_top + 拖动区域 = 漂亮的悬浮窗。
  • 状态靠后端事件广播保持同步。

动手时刻

  • 打开迷你播放器,拖到屏幕角落。
  • 打开桌面歌词,切换穿透模式。

下一章:全局媒体键与系统媒体中心。

第 37 章 全局媒体键与 OS 媒体中心

本章目标

  • 响应全局媒体键(▶︎ ⏸ ⏭ ⏮ ⏹),哪怕你在别的应用里。
  • 接入各平台的"当前播放"系统:
    • macOS: MediaPlayer.framework(Now Playing / Touch Bar)。
    • Windows: SMTC(System Media Transport Controls)。
    • Linux: MPRIS via D-Bus。

一个库覆盖三平台:souvlaki。

一、依赖

# Cargo.toml
souvlaki = "0.7"
raw-window-handle = "0.6"

二、初始化 MediaControls

// core/audio/media_controls.rs
use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig};
use std::time::Duration;

pub struct OsMedia {
    controls: MediaControls,
}

impl OsMedia {
    pub fn new(app: &tauri::AppHandle) -> anyhow::Result<Self> {
        // Windows 需要 HWND
        #[cfg(target_os = "windows")]
        let hwnd = {
            use raw_window_handle::{HasWindowHandle, RawWindowHandle};
            let win = app.get_webview_window("main").unwrap();
            let handle = win.window_handle()?;
            match handle.as_raw() {
                RawWindowHandle::Win32(h) => Some(h.hwnd.get() as _),
                _ => None,
            }
        };
        #[cfg(not(target_os = "windows"))]
        let hwnd = None;

        let config = PlatformConfig {
            dbus_name: "com.cloudtone.player",
            display_name: "CloudTone",
            hwnd,
        };
        let controls = MediaControls::new(config)?;
        Ok(Self { controls })
    }

    pub fn attach(&mut self, tx: tokio::sync::mpsc::Sender<PlayerCmd>) -> anyhow::Result<()> {
        use souvlaki::MediaControlEvent as E;
        self.controls.attach(move |ev| {
            let tx = tx.clone();
            tokio::spawn(async move {
                let cmd = match ev {
                    E::Play => Some(PlayerCmd::Play),
                    E::Pause => Some(PlayerCmd::Pause),
                    E::Toggle => Some(PlayerCmd::Toggle),
                    E::Next => Some(PlayerCmd::Next),
                    E::Previous => Some(PlayerCmd::Prev),
                    E::Stop => Some(PlayerCmd::Stop),
                    E::SetPosition(MediaPosition(d)) => Some(PlayerCmd::Seek(d.as_secs_f64())),
                    _ => None,
                };
                if let Some(c) = cmd { let _ = tx.send(c).await; }
            });
        })?;
        Ok(())
    }

    pub fn update_song(&mut self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, duration: Duration) {
        let _ = self.controls.set_metadata(MediaMetadata {
            title: Some(title),
            artist: Some(artist),
            album: Some(album),
            cover_url,
            duration: Some(duration),
        });
    }

    pub fn update_state(&mut self, playing: bool, progress: Duration) {
        let mp = if playing { MediaPlayback::Playing { progress: Some(MediaPosition(progress)) } }
                 else       { MediaPlayback::Paused  { progress: Some(MediaPosition(progress)) } };
        let _ = self.controls.set_playback(mp);
    }
}

三、集成到 Player

// InnerPlayer 有 Option<OsMedia>
impl InnerPlayer {
    pub async fn init_os_media(&mut self, app: &tauri::AppHandle, tx: mpsc::Sender<PlayerCmd>) -> anyhow::Result<()> {
        let mut media = OsMedia::new(app)?;
        media.attach(tx)?;
        self.os_media = Some(media);
        Ok(())
    }
}

播放状态变化时同步:

// 每次 Load 成功
if let Some(m) = &mut self.os_media {
    m.update_song(&song.title, song.artist.as_deref().unwrap_or(""), song.album.as_deref().unwrap_or(""),
                  cover_url.as_deref(), Duration::from_millis(song.duration_ms as u64));
}
// 每秒
if let Some(m) = &mut self.os_media {
    m.update_state(self.playing, Duration::from_secs_f64(self.progress));
}

四、媒体键快捷键(回退方案)

有些 Linux 桌面环境不转发媒体键到 MPRIS。用 tauri-plugin-global-shortcut 注册:

use tauri_plugin_global_shortcut::{Code, Modifiers, ShortcutState};

app.global_shortcut().on_shortcut("MediaPlayPause", |app, _, state| {
    if state.state() == ShortcutState::Pressed {
        let _ = app.emit("media:toggle", ());
    }
}).ok();

app.global_shortcut().on_shortcut("MediaTrackNext", |app, _, _| {
    let _ = app.emit("media:next", ());
}).ok();

五、封面在系统卡片里显示

macOS / Windows 的 Now Playing 需要一个 URL 或本地路径:

  • 本地:file:///path/to/cover.jpg
  • 网络:https://.../cover.jpg

通常先写到 $APPCACHE/covers/<hash>.jpg,用 file:// 传入:

let cover_url = cache_dir.join(format!("{}.jpg", hash));
let url_str = url::Url::from_file_path(&cover_url).unwrap().to_string();

六、权限说明

macOS:App 首次调用 MediaPlayer 需要"媒体与 Apple Music"权限,系统会自动弹出。签名后才能稳定生效,调试期常见"显示不出来",第 47 章打包签名后解决。

Windows:SMTC 只在 UWP / 签名桌面应用里保证稳定,未签名的 dev build 偶尔不出现。

本章小结

  • souvlaki 统一了三平台的媒体中心 API。
  • 保持 update_state / update_song 与真实播放状态同步至关重要。
  • 签名之后功能更稳定。

动手时刻

  • macOS 上打开控制中心,看到 CloudTone 歌曲信息。
  • 按笔记本键盘的 F8(播放/暂停),切换播放。

下一章:在线音源的可插拔 Provider 架构。

第 38 章 在线音源适配层(可插拔 Provider)

本章目标

  • 定义统一的 MusicProvider trait。
  • 实现一个示例 Provider(假数据 / demo)。
  • Provider 注册表:多源并行搜索、降级策略。
  • 遵守版权与服务条款。

本章不提供任何绕过 DRM 或盗版的具体接口。重点在架构与工程实践。

一、设计原则

  • 异步:所有方法 async fn。
  • 可扩展:Provider 只关心 "我能提供什么",核心模块不感知具体来源。
  • 降级:某个 Provider 失败不影响整体。
  • 并发限流:每个 Provider 维护自己的限流器。

二、Trait 定义

// core/providers/mod.rs
use async_trait::async_trait;
use serde::Serialize;
use specta::Type;

#[derive(Serialize, Type, Debug, Clone)]
pub struct RemoteSong {
    pub provider: String,
    pub id: String,         // Provider 内唯一 id
    pub title: String,
    pub artist: String,
    pub album: Option<String>,
    pub duration_ms: i64,
    pub cover_url: Option<String>,
}

#[async_trait]
pub trait MusicProvider: Send + Sync {
    fn id(&self) -> &'static str;
    fn display_name(&self) -> &'static str;

    async fn search(&self, query: &str, limit: usize) -> anyhow::Result<Vec<RemoteSong>>;
    async fn fetch_lyrics(&self, song: &RemoteSong) -> anyhow::Result<Option<String>>;
    async fn fetch_cover(&self, song: &RemoteSong) -> anyhow::Result<Option<Vec<u8>>>;
    /// 返回可播放的 stream URL(你负责它的合法性)
    async fn stream_url(&self, song: &RemoteSong) -> anyhow::Result<String>;
}

三、Demo Provider

// core/providers/demo.rs
use super::*;

pub struct DemoProvider {
    client: reqwest::Client,
}

impl DemoProvider {
    pub fn new() -> Self {
        Self { client: reqwest::Client::builder().user_agent("CloudTone/1.0").build().unwrap() }
    }
}

#[async_trait]
impl MusicProvider for DemoProvider {
    fn id(&self) -> &'static str { "demo" }
    fn display_name(&self) -> &'static str { "示例音源" }

    async fn search(&self, q: &str, limit: usize) -> anyhow::Result<Vec<RemoteSong>> {
        let _ = q; let _ = limit;
        Ok(vec![RemoteSong {
            provider: "demo".into(),
            id: "1".into(),
            title: "Demo Song".into(),
            artist: "Demo Artist".into(),
            album: None,
            duration_ms: 180_000,
            cover_url: None,
        }])
    }
    async fn fetch_lyrics(&self, _: &RemoteSong) -> anyhow::Result<Option<String>> { Ok(None) }
    async fn fetch_cover(&self, _: &RemoteSong) -> anyhow::Result<Option<Vec<u8>>> { Ok(None) }
    async fn stream_url(&self, _: &RemoteSong) -> anyhow::Result<String> {
        Ok("https://example.com/demo.mp3".into())
    }
}

四、Registry

// core/providers/registry.rs
use std::sync::Arc;
use tokio::sync::RwLock;

pub struct ProviderRegistry {
    providers: RwLock<Vec<Arc<dyn MusicProvider>>>,
}

impl ProviderRegistry {
    pub fn new() -> Self { Self { providers: RwLock::new(vec![]) } }
    pub async fn register(&self, p: Arc<dyn MusicProvider>) {
        self.providers.write().await.push(p);
    }
    pub async fn primary(&self) -> Option<Arc<dyn MusicProvider>> {
        self.providers.read().await.first().cloned()
    }

    /// 并发搜索,聚合结果;单个失败不影响整体。
    pub async fn search_all(&self, q: &str, limit: usize) -> Vec<RemoteSong> {
        let provs = self.providers.read().await.clone();
        let futs = provs.into_iter().map(|p| {
            let q = q.to_string();
            async move { p.search(&q, limit).await.unwrap_or_default() }
        });
        let results = futures::future::join_all(futs).await;
        let mut flat: Vec<RemoteSong> = results.into_iter().flatten().collect();
        // 简单去重:同 title+artist 合并
        flat.sort_by(|a, b| (a.title.to_lowercase(), a.artist.to_lowercase())
            .cmp(&(b.title.to_lowercase(), b.artist.to_lowercase())));
        flat.dedup_by(|a, b| a.title.eq_ignore_ascii_case(&b.title) && a.artist.eq_ignore_ascii_case(&b.artist));
        flat
    }
}

五、状态接入

AppState 追加字段:

pub struct AppState {
    pub db: SqlitePool,
    pub player: PlayerHandle,
    pub queue: Arc<Mutex<Queue>>,
    pub providers: Arc<ProviderRegistry>,
    // ...
}

setup:

let registry = ProviderRegistry::new();
registry.register(Arc::new(DemoProvider::new())).await;

六、搜索命令融合

#[tauri::command] #[specta::specta]
pub async fn search(state: tauri::State<'_, AppState>, q: String, include_online: bool) -> Result<SearchResult, String> {
    let local = search_local(&state.db, &q, 50).await.map_err(|e| e.to_string())?;
    let online = if include_online { state.providers.search_all(&q, 30).await } else { vec![] };
    Ok(SearchResult { local, online })
}

前端切 Tab:本地 / 在线。

七、缓存策略

  • 搜索结果短缓存:TanStack Query staleTime: 5min。
  • 封面:下载到 $APPCACHE/covers/,以 URL hash 为文件名。
  • 歌词:$APPCACHE/lyrics/<provider>/<id>.lrc。
  • 不缓存音频流本身(版权)。

八、错误反馈

Provider 错误统一 tracing::warn!,前端用 toast 展示非致命提示。

本章小结

  • Trait + Registry 让"换音源"成为插件级操作。
  • 并发搜索 + 去重 = 用户看到统一结果。
  • 不要把法律风险留给用户:合规是第一原则。

动手时刻

  • 注册 Demo Provider,搜索返回结果。
  • 失败时显示 toast,而非崩溃。

下一章:下载管理与断点续传。

第 39 章 下载管理、断点续传、缓存策略

本章目标

  • 设计一个任务式下载队列(可暂停、恢复、取消)。
  • 断点续传:用 HTTP Range 头。
  • 进度推送:Channel。
  • 下载目录、并发上限、失败重试。

一、数据模型

// core/download/mod.rs
use std::path::PathBuf;

#[derive(Clone, Debug)]
pub struct DownloadTask {
    pub id: String,
    pub url: String,
    pub target: PathBuf,
    pub total: u64,
    pub downloaded: u64,
    pub status: Status,
}

#[derive(Clone, Debug, Copy, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum Status { Pending, Downloading, Paused, Done, Failed }

二、下载器核心

use futures::StreamExt;
use reqwest::header::{RANGE, CONTENT_LENGTH};
use tokio::io::AsyncWriteExt;
use tokio::fs::OpenOptions;

pub async fn download_with_resume(client: &reqwest::Client, task: &mut DownloadTask, mut on_progress: impl FnMut(u64, u64)) -> anyhow::Result<()> {
    // 目标文件已有多少字节
    let already = if task.target.exists() {
        tokio::fs::metadata(&task.target).await?.len()
    } else { 0 };
    task.downloaded = already;

    let mut req = client.get(&task.url);
    if already > 0 {
        req = req.header(RANGE, format!("bytes={}-", already));
    }
    let resp = req.send().await?.error_for_status()?;

    // total = 已有 + Content-Length
    if let Some(len) = resp.headers().get(CONTENT_LENGTH).and_then(|v| v.to_str().ok()).and_then(|s| s.parse::<u64>().ok()) {
        task.total = already + len;
    }

    let mut file = OpenOptions::new().create(true).append(true).open(&task.target).await?;
    let mut stream = resp.bytes_stream();
    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        file.write_all(&chunk).await?;
        task.downloaded += chunk.len() as u64;
        on_progress(task.downloaded, task.total);
    }
    file.flush().await?;
    task.status = Status::Done;
    Ok(())
}

三、任务管理器

use tokio::sync::{Mutex, Semaphore};
use dashmap::DashMap;
use std::sync::Arc;
use tauri::ipc::Channel;

#[derive(Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
pub enum DownloadEvent {
    Progress { id: String, done: u64, total: u64 },
    Done { id: String, path: String },
    Failed { id: String, error: String },
}

pub struct DownloadManager {
    pub tasks: DashMap<String, Arc<Mutex<DownloadTask>>>,
    pub client: reqwest::Client,
    pub sem: Arc<Semaphore>,
}

impl DownloadManager {
    pub fn new(parallel: usize) -> Self {
        Self {
            tasks: DashMap::new(),
            client: reqwest::Client::new(),
            sem: Arc::new(Semaphore::new(parallel)),
        }
    }

    pub async fn start(self: Arc<Self>, task: DownloadTask, ch: Channel<DownloadEvent>) {
        let id = task.id.clone();
        let task = Arc::new(Mutex::new(task));
        self.tasks.insert(id.clone(), task.clone());

        let sem = self.sem.clone();
        let client = self.client.clone();
        tokio::spawn(async move {
            let _permit = sem.acquire_owned().await.unwrap();
            let mut t = task.lock().await;
            t.status = Status::Downloading;
            let id2 = id.clone();
            let ch_clone = ch.clone();
            let res = download_with_resume(&client, &mut t, |d, total| {
                let _ = ch_clone.send(DownloadEvent::Progress { id: id2.clone(), done: d, total });
            }).await;
            match res {
                Ok(()) => { let _ = ch.send(DownloadEvent::Done { id, path: t.target.to_string_lossy().into() }); }
                Err(e) => { t.status = Status::Failed; let _ = ch.send(DownloadEvent::Failed { id, error: e.to_string() }); }
            }
        });
    }

    pub async fn pause(&self, _id: &str) { /* 简化:关闭 HTTP 请求; 状态标记 */ }
    pub async fn cancel(&self, id: &str) {
        if let Some((_, t)) = self.tasks.remove(id) {
            let tt = t.lock().await;
            let _ = tokio::fs::remove_file(&tt.target).await;
        }
    }
}

pause 的严谨实现:持有一个 CancellationToken(tokio-util::sync::CancellationToken),任务内部每次写 chunk 前检查;pause 触发 cancel,下次 resume 时重新调度。

四、命令

#[tauri::command] #[specta::specta]
pub async fn download_start(state: tauri::State<'_, AppState>, id: String, url: String, target: String, ch: Channel<DownloadEvent>) -> Result<(), String> {
    let task = DownloadTask {
        id, url, target: target.into(), total: 0, downloaded: 0, status: Status::Pending,
    };
    state.downloader.clone().start(task, ch).await;
    Ok(())
}

#[tauri::command] #[specta::specta]
pub async fn download_cancel(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> {
    state.downloader.cancel(&id).await;
    Ok(())
}

五、前端 UI

export function DownloadPanel() {
  const [tasks, setTasks] = useState<Map<string, Task>>(new Map());
  useEffect(() => {
    // 监听全局事件(每次 download_start 用不同 Channel 更好)
  }, []);

  async function addDownload(song: RemoteSong) {
    const ch = new Channel<DownloadEvent>();
    ch.onmessage = (e) => {
      if (e.event === "progress") {
        setTasks(prev => new Map(prev).set(e.data.id, { ...prev.get(e.data.id)!, done: e.data.done, total: e.data.total }));
      }
      if (e.event === "done") { toast.success("下载完成"); }
    };
    const url = await commands.providerStreamUrl(song.provider, song.id);
    await commands.downloadStart(song.id, url, `~/Music/CloudTone/${song.title}.mp3`, ch);
  }
  return <div>...</div>;
}

六、目录与命名

  • 默认目录:$HOME/Music/CloudTone/(用户可在设置里改)。
  • 文件名:{artist} - {title}.{ext},非法字符替换为 _。
  • 冲突:如存在同名,加 (2)、(3)。
pub fn sanitize(s: &str) -> String {
    s.chars().map(|c| match c { '/'|'\\'|':'|'*'|'?'|'"'|'<'|'>'|'|' => '_', _ => c }).collect()
}

七、缓存淘汰

$APPCACHE/stream/ 用 LRU:

pub fn evict_cache(dir: &Path, max_size_mb: u64) -> std::io::Result<()> {
    let mut files: Vec<_> = std::fs::read_dir(dir)?
        .filter_map(Result::ok)
        .map(|e| (e.path(), e.metadata().unwrap().modified().unwrap(), e.metadata().unwrap().len()))
        .collect();
    files.sort_by_key(|(_, m, _)| *m); // 旧的优先删
    let total: u64 = files.iter().map(|(_, _, s)| s).sum();
    let mut over = total.saturating_sub(max_size_mb * 1024 * 1024);
    for (p, _, s) in files {
        if over == 0 { break; }
        let _ = std::fs::remove_file(&p);
        over = over.saturating_sub(s);
    }
    Ok(())
}

本章小结

  • Range 请求 + append 写入 = 断点续传。
  • 信号量控制并发,避免把带宽打满。
  • 用 CancellationToken 做暂停 / 取消。

动手时刻

  • 下载一首 demo mp3,中途断网看恢复。
  • 同时下载 5 首,限制并发 2,观察排队。

下一章:均衡器(EQ)与音效处理。

第 40 章 均衡器(EQ)与音效处理

本章目标

  • 实现多段 biquad 峰值/低架/高架滤波器。
  • 把 EQ 插入到解码流之后、混响之前。
  • UI:10 段 ±12dB 滑块 + 预设(流行 / 摇滚 / 人声)。
  • 可选:压缩、限幅(Limiter)、立体声增强。

一、Biquad 滤波器

Biquad 是音频 DSP 的工作马。一段 RBJ Cookbook 的实现:

// core/audio/dsp/biquad.rs
pub struct Biquad {
    b0: f32, b1: f32, b2: f32, a1: f32, a2: f32,
    z1: f32, z2: f32,
}

impl Biquad {
    pub fn peaking(fs: f32, f0: f32, q: f32, gain_db: f32) -> Self {
        let a = 10f32.powf(gain_db / 40.0);
        let w0 = 2.0 * std::f32::consts::PI * f0 / fs;
        let alpha = w0.sin() / (2.0 * q);
        let b0 = 1.0 + alpha * a;
        let b1 = -2.0 * w0.cos();
        let b2 = 1.0 - alpha * a;
        let a0 = 1.0 + alpha / a;
        let a1 = -2.0 * w0.cos();
        let a2 = 1.0 - alpha / a;
        Self { b0: b0/a0, b1: b1/a0, b2: b2/a0, a1: a1/a0, a2: a2/a0, z1: 0.0, z2: 0.0 }
    }

    pub fn process(&mut self, x: f32) -> f32 {
        let y = self.b0 * x + self.z1;
        self.z1 = self.b1 * x - self.a1 * y + self.z2;
        self.z2 = self.b2 * x - self.a2 * y;
        y
    }

    pub fn reset(&mut self) { self.z1 = 0.0; self.z2 = 0.0; }
}

二、EQ Graph

pub struct Equalizer {
    bands: Vec<(f32, Biquad, Biquad)>, // (freq, left, right)
    enabled: bool,
    fs: f32,
}

impl Equalizer {
    pub const DEFAULT_BANDS: [f32; 10] = [31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];

    pub fn new(fs: f32) -> Self {
        let bands = Self::DEFAULT_BANDS.iter()
            .map(|&f| (f, Biquad::peaking(fs, f, 1.0, 0.0), Biquad::peaking(fs, f, 1.0, 0.0)))
            .collect();
        Self { bands, enabled: false, fs }
    }

    pub fn set_gains(&mut self, gains_db: &[f32]) {
        assert_eq!(gains_db.len(), self.bands.len());
        let fs = self.fs;
        for (i, &g) in gains_db.iter().enumerate() {
            let f = self.bands[i].0;
            self.bands[i].1 = Biquad::peaking(fs, f, 1.0, g);
            self.bands[i].2 = Biquad::peaking(fs, f, 1.0, g);
        }
    }

    pub fn process(&mut self, buf: &mut [f32]) {
        if !self.enabled { return; }
        // 立体声交错 L R L R
        for frame in buf.chunks_exact_mut(2) {
            let (l, r) = (frame[0], frame[1]);
            let mut yl = l; let mut yr = r;
            for (_, bl, br) in self.bands.iter_mut() {
                yl = bl.process(yl);
                yr = br.process(yr);
            }
            frame[0] = yl; frame[1] = yr;
        }
    }
}

三、挂到播放链

在 decoder 输出后、发给 output 前:

if let Some(eq) = self.eq.as_mut() {
    eq.process(&mut samples);
}
self.output.write(&samples);

EQ 线程安全:包 Arc<Mutex<Equalizer>>,或者让 pump 线程独占、通过 PlayerCmd::SetEq(Vec<f32>) 更新。

四、预设

pub fn preset(name: &str) -> Vec<f32> {
    match name {
        "flat"    => vec![0.0; 10],
        "rock"    => vec![5.0, 3.0, -1.0, -2.0, -1.0, 1.0, 2.0, 4.0, 5.0, 6.0],
        "pop"     => vec![-1.0, -1.0, 0.0, 2.0, 3.0, 3.0, 1.0, 0.0, -1.0, -2.0],
        "vocal"   => vec![-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.0, 2.0, 1.0, 0.0],
        "bass"    => vec![6.0, 5.0, 4.0, 2.0, 1.0, 0.0, -1.0, -2.0, -2.0, -2.0],
        _         => vec![0.0; 10],
    }
}

五、Limiter 防削波

EQ 加到 +12dB 可能削波(clip)。加一个软限幅:

pub fn limiter(samples: &mut [f32], threshold: f32) {
    for s in samples {
        if *s > threshold { *s = threshold + (*s - threshold).tanh() * (1.0 - threshold); }
        else if *s < -threshold { *s = -threshold + (*s + threshold).tanh() * (1.0 - threshold); }
    }
}

六、前端 UI

const BANDS = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];

export function EqPanel() {
  const [gains, setGains] = useState<number[]>(Array(10).fill(0));
  const [enabled, setEnabled] = useState(false);

  useEffect(() => { commands.eqSet(gains, enabled); }, [gains, enabled]);

  function loadPreset(name: string) {
    commands.eqPreset(name).then(setGains);
  }

  return (
    <div className="p-4 bg-surface-1 rounded-lg">
      <div className="flex justify-between mb-3">
        <Switch checked={enabled} onChange={setEnabled} label="启用 EQ" />
        <Select onChange={loadPreset} options={["flat","rock","pop","vocal","bass"]} />
      </div>
      <div className="flex gap-2 items-end h-40">
        {BANDS.map((f, i) => (
          <div key={f} className="flex flex-col items-center gap-1 flex-1">
            <input type="range" min={-12} max={12} step={0.5} value={gains[i]} orient="vertical"
                   onChange={e => {
                     const v = Number(e.target.value);
                     setGains(prev => prev.map((g, j) => j === i ? v : g));
                   }}
                   className="h-32" />
            <span className="text-xs text-text-tertiary">{f >= 1000 ? `${f/1000}k` : f}</span>
            <span className="text-xs">{gains[i] > 0 ? "+" : ""}{gains[i].toFixed(1)}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

七、性能与细节

  • 切歌时 EQ reset() 防止残余滤波。
  • 采样率变化时重新创建 Biquad。
  • EQ 应作用在重采样后(output 的 fs),否则预设不对。

本章小结

  • Biquad 一段干净实现 → 搭 10 段 EQ。
  • 预设覆盖 80% 需求,专业用户可自定义。
  • Limiter 不是可选:有 EQ 就得有它。

动手时刻

  • 拉高低频 +6dB,听人声和鼓的变化。
  • 选"摇滚"预设,对比关闭 EQ 的效果。

下一章:国际化 i18n 与字体加载。

第 41 章 国际化 i18n 与字体加载

本章目标

  • 前端接入 react-i18next,运行时切换语言。
  • 文案放到 JSON,支持插值和复数。
  • 字体:西文 Inter、中文 Noto Sans SC 的加载与回退。
  • 系统语言自动检测。

一、依赖

pnpm add i18next react-i18next i18next-browser-languagedetector

二、资源结构

src/i18n/
├── index.ts
└── locales/
    ├── zh.json
    ├── en.json
    └── ja.json
// zh.json
{
  "common": { "play": "播放", "pause": "暂停", "search": "搜索", "settings": "设置" },
  "player": { "nowPlaying": "正在播放", "noSong": "未选择歌曲", "queueCount": "{{count}} 首歌" },
  "library": { "import": "导入音乐", "scanning": "扫描中…({{done}}/{{total}})" }
}

三、初始化

// src/i18n/index.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import zh from "./locales/zh.json";
import en from "./locales/en.json";
import ja from "./locales/ja.json";

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: { zh: { translation: zh }, en: { translation: en }, ja: { translation: ja } },
    fallbackLng: "en",
    interpolation: { escapeValue: false },
    detection: { order: ["localStorage","navigator"], caches: ["localStorage"] },
  });

export default i18n;

main.tsx 里 import "./i18n"。

四、使用

import { useTranslation } from "react-i18next";

function PlayButton() {
  const { t } = useTranslation();
  return <button>{t("common.play")}</button>;
}

function ScanStatus({ done, total }: { done: number; total: number }) {
  const { t } = useTranslation();
  return <div>{t("library.scanning", { done, total })}</div>;
}

五、运行时切换

import i18n from "@/i18n";
i18n.changeLanguage("en");

存偏好到 Rust 侧的 settings 表:

async function setLanguage(lang: string) {
  await i18n.changeLanguage(lang);
  await commands.settingsSet("lang", lang);
}

启动时读:

const lang = await commands.settingsGet("lang");
if (lang) i18n.changeLanguage(lang);

六、字体

包内字体

把 TTF/WOFF2 放到 public/fonts/。CSS:

@font-face {
  font-family: "Inter";
  src: url("/fonts/Inter-Variable.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
}
@font-face {
  font-family: "Noto Sans SC";
  src: url("/fonts/NotoSansSC-Regular.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}
body {
  font-family: "Inter", "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

font-display: swap 让系统字体先渲染,避免 FOIT(隐形文本闪烁)。

仅按需加载

10MB 的中文 WOFF2 很贵。可以只在用户选中文时加载:

async function ensureChineseFont() {
  if (document.fonts.check("16px 'Noto Sans SC'")) return;
  const font = new FontFace("Noto Sans SC", "url(/fonts/NotoSansSC-Regular.woff2)");
  await font.load();
  document.fonts.add(font);
}

七、Rust 侧错误信息

Rust 返回的错误要不要本地化?CloudTone 选不做:

  • 错误信息发给用户友好的封装层(前端 toast),英文原文保留在 log 供调试。
  • 前端根据 error.code 查 i18n 表。
#[derive(thiserror::Error, Debug, serde::Serialize, specta::Type)]
#[serde(tag = "code", rename_all = "camelCase")]
pub enum UserError {
    #[error("file not found")] FileNotFound,
    #[error("unsupported format")] UnsupportedFormat,
    #[error("network")] Network,
}

前端:

catch (e: any) {
  toast.error(t(`errors.${e.code ?? "unknown"}`));
}

八、系统语言检测

detection: { order: ["localStorage","navigator"], caches: ["localStorage"] }

navigator 会用 navigator.language。Tauri 上它等于 WebView 的语言(通常随系统)。想严格跟系统:

#[tauri::command] #[specta::specta]
pub fn system_locale() -> String {
    sys_locale::get_locale().unwrap_or("en-US".into())
}

前端调用 system_locale() 做兜底。

本章小结

  • i18next 的生态最成熟,React 集成优雅。
  • 字体按需加载节省安装包。
  • 错误码分离展示文案,扩展语言零成本。

动手时刻

  • 切换到 English,看界面变化。
  • 首次启动时自动跟随系统语言。

下一章:插件系统设计。

第 42 章 插件系统设计

本章目标

  • 区分"Tauri 官方插件"与"你的应用级插件"。
  • CloudTone 的应用级插件架构:脚本(JS/WASM)或独立进程。
  • 给出一个最小安全沙箱方案:WebWorker + Structured Clone 通信。

一、两种插件观

  1. Tauri Plugin:用 Rust 写,编译进 App,深度访问系统。不适合第三方,因为需要重编。
  2. 应用级插件:运行时加载的脚本/配置,能力有限但可动态安装。

CloudTone 面向用户,取第 2 种。

二、插件规范

plugin-dir/
├── manifest.json
├── main.js
└── icon.png
{
  "id": "lyric-translator",
  "name": "歌词翻译",
  "version": "0.1.0",
  "permissions": ["network"],       // 可选权限
  "hooks": ["onSongLoad", "onLyrics"]
}

三、加载器

// core/plugins/mod.rs
use std::path::Path;

pub struct PluginMeta {
    pub id: String,
    pub name: String,
    pub path: std::path::PathBuf,
    pub permissions: Vec<String>,
    pub hooks: Vec<String>,
}

pub fn scan_plugins(dir: &Path) -> std::io::Result<Vec<PluginMeta>> {
    let mut out = vec![];
    if !dir.exists() { return Ok(out); }
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let mf = entry.path().join("manifest.json");
        if !mf.exists() { continue; }
        let raw = std::fs::read_to_string(&mf)?;
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
            out.push(PluginMeta {
                id: v["id"].as_str().unwrap_or("").into(),
                name: v["name"].as_str().unwrap_or("").into(),
                path: entry.path(),
                permissions: v["permissions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
                hooks: v["hooks"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
            });
        }
    }
    Ok(out)
}

四、前端沙箱

把插件 main.js 丢进 Web Worker:它没有 DOM 和大部分 API,通信靠 postMessage。

// src/features/plugins/host.ts
export class PluginHost {
  private worker: Worker;
  constructor(private meta: PluginMeta) {
    const code = `
      importScripts("${meta.mainUrl}");
      self.onmessage = async (e) => {
        try {
          const fn = self[e.data.hook];
          if (typeof fn === "function") {
            const result = await fn(e.data.payload);
            self.postMessage({ id: e.data.id, result });
          }
        } catch (err) {
          self.postMessage({ id: e.data.id, error: String(err) });
        }
      };
    `;
    const blob = new Blob([code], { type: "application/javascript" });
    this.worker = new Worker(URL.createObjectURL(blob));
  }

  call<T>(hook: string, payload: any): Promise<T> {
    const id = crypto.randomUUID();
    return new Promise((resolve, reject) => {
      const handler = (e: MessageEvent) => {
        if (e.data.id !== id) return;
        this.worker.removeEventListener("message", handler);
        if (e.data.error) reject(new Error(e.data.error));
        else resolve(e.data.result);
      };
      this.worker.addEventListener("message", handler);
      this.worker.postMessage({ id, hook, payload });
    });
  }

  destroy() { this.worker.terminate(); }
}

五、注册 Hook

export class PluginRegistry {
  hosts = new Map<string, PluginHost>();

  async load(meta: PluginMeta) {
    this.hosts.set(meta.id, new PluginHost(meta));
  }

  async onSongLoad(song: Song) {
    for (const host of this.hosts.values()) {
      try { await host.call("onSongLoad", song); } catch (e) { console.warn(e); }
    }
  }
}

六、示例插件:歌词翻译

// main.js (用户写的插件)
globalThis.onLyrics = async ({ lines, target }) => {
  const translated = await fetch("https://translate.example/api", {
    method: "POST",
    body: JSON.stringify({ lines: lines.map(l => l.text), target }),
  }).then(r => r.json());
  return lines.map((l, i) => ({ ...l, translated: translated[i] }));
};

CloudTone 主程序在加载歌词后调用:

const translated = await plugins.call("onLyrics", { lines, target: "zh-CN" });
setLyrics(translated);

七、权限与隔离

Worker 默认没有 fetch 限制,但你可以:

  • 拦截 fetch:在注入的 bootstrap 脚本里覆盖 globalThis.fetch 为带白名单的版本。
  • 只允许 manifest.permissions 声明的能力(如 network、storage)。
  • 敏感 API(读文件、播放控制)由 Host 提供给 Worker,走 postMessage 显式授权。
// bootstrap.js
const allowNet = {{JSON.stringify(meta.permissions.includes("network"))}};
const origFetch = self.fetch;
self.fetch = allowNet ? origFetch : () => { throw new Error("No network permission"); };

八、生命周期

  • 启动时扫描 $APPDATA/cloudtone/plugins/ 并加载。
  • 用户禁用时 destroy()。
  • Hot reload:开发模式监听文件变化重建 Worker。

九、WASM 方案(进阶)

如需更强隔离,用 wasmtime 或 wasmer。插件导出 onSongLoad(ptr, len),Host 在 Rust 层调用。学习成本高但更安全,适合给陌生开发者开放。

本章小结

  • Web Worker 是前端插件最轻量的沙箱。
  • Manifest 驱动权限能缩小攻击面。
  • 插件系统让产品长期活跃,用户参与感强。

动手时刻

  • 写一个 "onSongLoad" 插件,打印歌名到 console。
  • 给插件加"禁用/启用"开关。

下一章:自动更新。

第 43 章 自动更新(tauri-plugin-updater + 签名)

本章目标

  • 配置更新清单 latest.json。
  • 生成更新签名(minisign / 内置 tauri signer)。
  • 前端 UI:检查、下载、重启。
  • 差分更新与渠道(stable/beta)。

一、安装插件

pnpm add @tauri-apps/plugin-updater
# Cargo.toml
tauri-plugin-updater = "2"
// lib.rs
.plugin(tauri_plugin_updater::Builder::new().build())

二、生成签名密钥对

pnpm tauri signer generate -w ~/.tauri/cloudtone.key

产出:

  • cloudtone.key(私钥,保密)。
  • cloudtone.key.pub(公钥,放 tauri.conf.json)。
"plugins": {
  "updater": {
    "active": true,
    "endpoints": [
      "https://releases.cloudtone.app/{{target}}/{{current_version}}/latest.json"
    ],
    "dialog": false,
    "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6..."
  }
}

{{target}} 示例:darwin-aarch64、windows-x86_64-msvc、linux-x86_64。

三、构建时签名

在 CI 上:

export TAURI_SIGNING_PRIVATE_KEY="$(cat $SECRET_KEY_FILE)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$PASS"
pnpm tauri build

构建完成后目录里会多出 .sig 文件。latest.json 示例:

{
  "version": "1.2.3",
  "pub_date": "2026-04-20T09:00:00Z",
  "notes": "修复崩溃;新增均衡器;性能优化。",
  "platforms": {
    "darwin-aarch64": {
      "signature": "dW50cnVzdGVkIGNvbW1lbnQ6...",
      "url": "https://releases.cloudtone.app/darwin-aarch64/1.2.3/CloudTone.app.tar.gz"
    },
    "windows-x86_64-msvc": {
      "signature": "...",
      "url": "https://releases.cloudtone.app/windows-x86_64-msvc/1.2.3/CloudTone.msi.zip"
    }
  }
}

四、前端代码

// src/features/updater/useUpdater.ts
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { useState } from "react";

export function useUpdater() {
  const [state, setState] = useState<{ available?: boolean; version?: string; progress?: number; err?: string }>({});

  async function run() {
    try {
      const update = await check();
      if (!update) { setState({ available: false }); return; }
      setState({ available: true, version: update.version });
      await update.downloadAndInstall((event) => {
        if (event.event === "Progress") {
          setState(s => ({ ...s, progress: event.data.chunkLength }));
        }
      });
      await relaunch();
    } catch (e: any) { setState({ err: e.message }); }
  }
  return { ...state, run };
}

UI:

export function UpdateNotice() {
  const { available, version, progress, err, run } = useUpdater();
  if (!available) return null;
  return (
    <div className="fixed bottom-4 right-4 bg-surface-2 p-4 rounded-xl shadow-xl w-72">
      <div className="font-semibold">发现新版本 v{version}</div>
      <button onClick={run} className="mt-2 px-3 py-1 bg-brand-500 rounded">立即更新</button>
      {progress !== undefined && <div className="mt-2 text-xs">下载中…</div>}
      {err && <div className="mt-2 text-xs text-red-400">{err}</div>}
    </div>
  );
}

五、静默与计划

  • 启动 30s 后检查:避免抢占用户首屏。
  • 4 小时轮询:用 setInterval。
  • 失败降噪:3 次失败后停一天。
useEffect(() => {
  const t1 = setTimeout(() => check(), 30_000);
  const t2 = setInterval(() => check(), 4 * 60 * 60 * 1000);
  return () => { clearTimeout(t1); clearInterval(t2); };
}, []);

六、多渠道

endpoints 支持多个,按渠道切换:

"endpoints": [
  "https://releases.cloudtone.app/{{channel}}/{{target}}/latest.json"
]

channel 可以从设置读,启动时拼入 URL(需要自己拦截 endpoint)。简单做法:两份 latest.json (/stable/... vs /beta/...),用户切渠道时改配置文件。

七、差分更新

Tauri 默认下载整包。对大应用(100MB+),可以:

  • 服务端提供 bsdiff 补丁。
  • 客户端先下 patch,用 bsdiff::patch apply。

这是工程优化项,初版可以不做。

八、回滚与紧急停更

  • 线上出问题,把 latest.json 改回旧版本即可(clients 检查会发现"已是最新")。
  • 紧急:用 feature flag 远程配置关闭某功能,而不是靠更新。

九、macOS 公证 & Windows 签名

自动更新要求安装包本身通过 OS 的签名校验:

  • macOS:Apple Developer ID + notarytool 公证(第 46 章细讲)。
  • Windows:用代码签名证书(EV 为佳)签 .msi / .exe。

没签名的更新在 Gatekeeper / SmartScreen 上会被拦。

本章小结

  • Tauri updater + 签名 = 低成本实现安全更新。
  • UI 要克制,不打断用户。
  • 基础架构之上还有渠道、差分、回滚策略。

动手时刻

  • 生成密钥对,发布 v0.1.0。
  • 改版本号到 0.1.1,生成新包,验证客户端自动弹提示。

下一章:性能优化。

第 44 章 性能优化:虚拟列表、懒加载、包体积

本章目标

  • 10 万条歌曲不卡:虚拟列表(@tanstack/react-virtual)。
  • 图片懒加载与并发限流。
  • 减小打包体积:代码分割、依赖剃刀。
  • Rust 侧 SQL 优化、索引、预取。

一、虚拟列表

pnpm add @tanstack/react-virtual
// src/components/SongList.tsx
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";

export function SongList({ songs }: { songs: Song[] }) {
  const parent = useRef<HTMLDivElement>(null);
  const v = useVirtualizer({
    count: songs.length,
    getScrollElement: () => parent.current,
    estimateSize: () => 56,
    overscan: 8,
  });
  return (
    <div ref={parent} className="h-full overflow-y-auto">
      <div style={{ height: v.getTotalSize(), position: "relative" }}>
        {v.getVirtualItems().map(item => {
          const song = songs[item.index];
          return (
            <div key={song.id} style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${item.start}px)`, height: item.size }}>
              <SongRow song={song} />
            </div>
          );
        })}
      </div>
    </div>
  );
}

二、图片懒加载

<img src={`cover://${hash}`} loading="lazy" decoding="async" />

配合 Rust 侧 tokio::sync::Semaphore(8) 限制同时读磁盘的协议处理并发。

三、减小包体积

3.1 React 侧

// 动态 import
const LyricsPage = lazy(() => import("./pages/LyricsPage"));

Vite 会自动 chunk split。vite-bundle-visualizer 分析体积。

3.2 Tailwind

content 配置精准:

content: ["./index.html","./src/**/*.{ts,tsx}"]

JIT 自动删除未使用类。

3.3 Rust 侧

  • Cargo.toml release:
[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"
  • 剔除多余 feature:reqwest = { version = "0.12", default-features = false, features = ["rustls-tls","json","stream"] }
  • 用 cargo bloat --release --crates 看占比。

3.4 WebView 资源

Tauri 打包会把前端静态文件嵌入。vite build 后 dist/ 的图片要手动压缩(@squoosh/cli)。

四、首屏加速

  • 启动态:backdrop 组件渲染 skeleton,数据并发请求。
  • 预热:Rust 侧 setup 里 warm up SQLite(PRAGMA optimize),并发打开第一页数据的 query。
  • 去隐式渲染:React.memo 包裹 SongRow,list 内 key 稳定。

五、SQL 与 DB

  • 在 EXPLAIN QUERY PLAN 看有无 scan。
  • 加合适索引:idx_songs_added_at DESC、idx_play_history_played_at DESC。
  • 用分页 (LIMIT/OFFSET) 或 keyset:
SELECT * FROM songs WHERE added_at < ? ORDER BY added_at DESC LIMIT 200
  • PRAGMA journal_mode = WAL;、synchronous = NORMAL。

六、音频解码的"瘦身"

默认把 symphonia 全家桶拉进来。只启用需要的编解码器:

symphonia = { version = "0.5", default-features = false, features = ["mp3","flac","aac","alac","ogg","vorbis","wav"] }

七、前端动画

  • 只给 transform / opacity 加 transition,避免触发 layout。
  • 列表滚动避免 box-shadow 与 filter。
  • will-change: transform 仅给确认会动的元素。

八、内存

  • 用 weak 封面缓存:前端 Map 长度限 200。
  • Rust 侧大数据(扫描结果)分批写库,避免一次 Vec<Song> 20 万条。

九、观测

tracing::info!(target: "perf", took_ms = %elapsed.as_millis(), "library_list_songs");

前端用 console.time + Tauri 事件记录。定期导出 metrics,第 47 章接上报。

本章小结

  • 虚拟列表、懒加载、bundle 分析是前端性能三板斧。
  • Rust release profile 调好,二进制小到可以接受。
  • 端到端度量,数据驱动优化。

动手时刻

  • 扫 10 万首歌,滚动顺滑。
  • 首屏 1 秒内可交互。

下一章:测试。

第 45 章 测试:Rust 单测 + React 组件测试 + E2E

本章目标

  • Rust 单元 / 集成测试:cargo test、测试 DB、测试音频解析。
  • React 组件测试:vitest + @testing-library/react。
  • E2E:WebDriver(tauri-driver)在真实应用上跑。
  • CI 上的策略。

一、Rust 测试

单元

// core/lyrics/mod.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_minimal() {
        let s = "[00:12.30]hello";
        let r = parse(s);
        assert_eq!(r.len(), 1);
        assert_eq!(r[0].ts_ms, 12_300);
    }
}

集成(带 SQLite)

测试前启一个内存数据库:

// src-tauri/tests/db_test.rs
use sqlx::SqlitePool;
use cloudtone::core::db::{queries, migrations};

async fn setup_pool() -> SqlitePool {
    let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
    migrations::run(&pool).await.unwrap();
    pool
}

#[tokio::test]
async fn create_and_list_playlist() {
    let pool = setup_pool().await;
    let id = queries::create_playlist(&pool, "喜爱").await.unwrap();
    let list = queries::list_playlists(&pool).await.unwrap();
    assert_eq!(list.len(), 1);
    assert_eq!(list[0].id, id);
}

音频 smoke test

准备一个 1 秒的正弦波 WAV 资产 tests/fixtures/sine1s.wav:

#[test]
fn decode_sine() {
    let r = scanner::read_tags(Path::new("tests/fixtures/sine1s.wav")).unwrap();
    assert!((r.duration_ms as i64 - 1000).abs() < 50);
}

二、React 组件测试

pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom

vitest.config.ts:

export default defineConfig({
  test: {
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    globals: true,
  },
});

src/test/setup.ts:

import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";

// Mock Tauri IPC
vi.mock("@/lib/ipc", () => ({
  commands: {
    libraryListSongs: vi.fn().mockResolvedValue([]),
    playerPlay: vi.fn(),
  },
  events: { playerEvent: { listen: vi.fn() } },
}));

例子

// src/components/LikeButton.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { LikeButton } from "./LikeButton";
import { commands } from "@/lib/ipc";

test("click calls toggle", () => {
  const song = { id: 1, title: "", liked: false } as any;
  render(<LikeButton song={song} />);
  fireEvent.click(screen.getByRole("button"));
  expect(commands.libraryToggleFavorite).toHaveBeenCalledWith(1);
});

三、E2E:tauri-driver

cargo install tauri-driver
pnpm add -D webdriverio @wdio/cli @wdio/local-runner @wdio/mocha-framework

wdio.conf.ts:

export const config = {
  specs: ["./e2e/**/*.spec.ts"],
  hostname: "127.0.0.1",
  port: 4444,
  capabilities: [{
    "tauri:options": { application: "./src-tauri/target/release/cloudtone" },
  }],
  framework: "mocha",
};

测试:

// e2e/smoke.spec.ts
describe("CloudTone smoke", () => {
  it("renders library", async () => {
    const title = await $("h1").getText();
    expect(title).toContain("Library");
  });
  it("search opens", async () => {
    await browser.keys(["Meta","k"]);
    await expect($("input[placeholder^='搜索']")).toBeDisplayed();
  });
});

启动:

cargo tauri build --debug
tauri-driver &
pnpm wdio wdio.conf.ts

四、Mock 策略

  • IPC:单测 mock;E2E 跑真 IPC。
  • 网络:用 wiremock(Rust)或 msw(前端)。
  • 磁盘:临时目录 tempfile::tempdir()。

五、属性测试(Property-based)

proptest 验证 LRC 解析器的不变量:

proptest! {
    #[test]
    fn parse_roundtrip(lines in prop::collection::vec((0i64..3_600_000, ".*"), 1..50)) {
        let raw = lines.iter().map(|(ts, t)| format!("[{:02}:{:02}.{:02}]{}", ts/60000, (ts/1000)%60, (ts%1000)/10, t)).collect::<Vec<_>>().join("\n");
        let parsed = parse(&raw);
        // 同 ts 行一一对应
        assert!(parsed.len() >= lines.len());
    }
}

六、覆盖率

cargo install cargo-llvm-cov
cargo llvm-cov --lcov --output-path lcov.info

前端:vitest run --coverage。

七、CI 上的取舍

  • 每次 PR:Rust 单测 + 前端组件测试 + lint(几分钟)。
  • Nightly:E2E + 全平台构建。
  • Release:E2E 全通过才允许发版。

本章小结

  • 测试分层:快单测 → 中集成 → 慢 E2E。
  • Mock 是朋友,别在单测里拉起真 WebView。
  • CI 按时长分组,保证 PR 阶段的反馈速度。

动手时刻

  • 为 LRC 解析器补 5 个边界单测。
  • 写 1 个 E2E:启动 App → 导入 fixture 目录 → 出现歌曲。

下一章:CI/CD 多平台打包与代码签名。

第 46 章 CI/CD 多平台打包与代码签名

本章目标

  • GitHub Actions 三平台矩阵构建。
  • macOS 公证(notarization)。
  • Windows 代码签名。
  • Linux .deb、.rpm、AppImage。
  • 产物上传到 Releases 并自动生成 latest.json。

一、Workflow 骨架

.github/workflows/release.yml:

name: Release
on:
  push:
    tags: ["v*"]

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - { platform: "macos-14",    args: "--target aarch64-apple-darwin" }
          - { platform: "macos-14",    args: "--target x86_64-apple-darwin" }
          - { platform: "windows-latest", args: "" }
          - { platform: "ubuntu-22.04",   args: "" }
    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "pnpm" }
      - uses: pnpm/action-setup@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
        with: { workspaces: "./src-tauri -> target" }
      - name: install (linux)
        if: runner.os == 'Linux'
        run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
      - run: pnpm install --frozen-lockfile

      - name: Build & sign
        uses: tauri-apps/tauri-action@v0
        env:
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERT }}
          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
        with:
          tagName: ${{ github.ref_name }}
          releaseName: "CloudTone ${{ github.ref_name }}"
          releaseBody: "See CHANGELOG.md"
          releaseDraft: true
          args: ${{ matrix.args }}

二、macOS 公证

  1. 在 Apple Developer 后台申请 "Developer ID Application" 证书。
  2. 导出 .p12,用 base64 放 APPLE_CERTIFICATE。
  3. 创建 app-specific password,放 APPLE_ID_PASSWORD。
  4. APPLE_TEAM_ID = 10 位字符。

tauri.conf.json:

"bundle": {
  "macOS": {
    "minimumSystemVersion": "11.0",
    "entitlements": "./entitlements.plist",
    "hardenedRuntime": true,
    "providerShortName": "XXXXXXXXXX",
    "signingIdentity": "Developer ID Application: Your Name (XXXXXXXXXX)"
  }
}

entitlements.plist 至少包含:

<plist><dict>
  <key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
  <key>com.apple.security.device.audio-input</key><false/>
</dict></plist>

tauri-action 会在有对应 env 时自动 notarize。

三、Windows 代码签名

  • 个人开发者:EV 证书贵 ($300/年),普通 OV 也行但 SmartScreen 要积累信誉。
  • 生成 .pfx,base64 放 secret。
  • tauri.conf.json:
"bundle": {
  "windows": {
    "certificateThumbprint": null,
    "digestAlgorithm": "sha256",
    "timestampUrl": "http://timestamp.sectigo.com"
  }
}

tauri-action 会用证书签 .exe 和 .msi。

四、Linux

三种产物:

  • .deb(Debian/Ubuntu):cargo tauri build 自动生成。
  • .rpm(Fedora/RHEL):需要 rpmbuild。
  • .AppImage:Tauri 默认生成。

如果没有 code signing,至少发 SHA256 摘要文件。

五、生成 latest.json

tauri-action 输出每个平台的 signature。用后置步骤拼接:

  collect:
    needs: build
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/download-artifact@v4
      - run: node scripts/compose-latest-json.js
      - uses: softprops/action-gh-release@v2
        with:
          files: latest.json

scripts/compose-latest-json.js 读取各平台的 .sig,输出合并后的 JSON(第 43 章的结构)。

六、稳定 vs 预发

  • 分支 main 自动 build 但 releaseDraft: true,只上传 artifact。
  • 打 tag v1.2.3 走 stable endpoint。
  • 打 tag v1.2.3-beta.1 走 beta endpoint。
  args: ${{ matrix.args }} ${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-beta') && '' || '--config src-tauri/tauri.beta.conf.json' }}

七、加速构建

  • Swatinem/rust-cache@v2 缓存 target。
  • pnpm store 缓存 node_modules。
  • macOS runner 用 macos-14(Apple Silicon,速度快 2-3x)。
  • Windows 用 sccache:
      - uses: mozilla-actions/sccache-action@v0.0.3
      - run: echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV

八、供应链安全

  • Secret 只加在需要它的 job 上,用 environment 审核。
  • 用 cargo-audit + npm audit 每周跑一次。
  • 生成 SBOM(cargo sbom + cyclonedx-bom)。

本章小结

  • Tauri-action 封装了 80% 的打包细节。
  • 签名和公证是"门槛成本",搞定一次终身受益。
  • 矩阵构建 + 缓存 = CI 20 分钟内出四平台包。

动手时刻

  • 打一个 v0.1.0,手动下载每平台包验证能启动。
  • macOS 包装后用 spctl -a -v 检查公证。

下一章:发布、分发、监控。

第 47 章 发布、分发、监控与错误上报

本章目标

  • 官网 / 下载页 / 发行说明工作流。
  • 日志滚动、崩溃捕获、错误上报(Sentry)。
  • 用户运营:反馈、问卷、应用内消息。
  • 度量:埋点 + 隐私合规。

一、官网与下载页

  • 用 Next.js / Astro 起一个静态站。
  • 一个 <DownloadButton> 根据 UA 默认推荐平台。
  • 展示最新 latest.json 的版本 + 发行说明。
function DownloadBtn() {
  const p = detectPlatform(); // "mac-arm64" / "mac-x64" / "win" / "linux"
  return <a href={`/download/${p}`} className="btn-primary">下载 CloudTone</a>;
}

二、Sentry 接入

pnpm add @sentry/react
// src/main.tsx
import * as Sentry from "@sentry/react";
Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  tracesSampleRate: 0.1,
  replaysOnErrorSampleRate: 0.1,
  enabled: import.meta.env.PROD,
  release: `cloudtone@${import.meta.env.VITE_APP_VERSION}`,
});

Rust 侧:

sentry = { version = "0.34", default-features = false, features = ["rustls","backtrace","panic","contexts"] }
let _guard = sentry::init((std::env::var("SENTRY_DSN").ok(), sentry::ClientOptions {
    release: Some(env!("CARGO_PKG_VERSION").into()),
    environment: Some(if cfg!(debug_assertions) { "dev".into() } else { "prod".into() }),
    ..Default::default()
}));
std::panic::set_hook(Box::new(sentry::integrations::panic::panic_handler));

三、崩溃日志

除了 Sentry,本地也要留证据:

  • tracing_appender::rolling::daily 写 $APPDATA/cloudtone/logs/app.log.YYYY-MM-DD。
  • 保留最近 14 天。
  • 提供"打开日志目录"按钮,方便用户反馈。

四、用户反馈

<button onClick={() => openFeedback()}>反馈</button>

async function openFeedback() {
  const info = await commands.envSnapshot(); // 版本/OS/CPU
  open(`https://forms.cloudtone.app/feedback?v=${info.version}&os=${info.os}`);
}

如果走 GitHub Issues,用 Issue 模板预填 env:

const body = encodeURIComponent(`**版本**: ${v}\n**系统**: ${os}\n\n### 现象\n\n### 复现\n`);
open(`https://github.com/you/cloudtone/issues/new?template=bug.yml&body=${body}`);

五、度量埋点

  • 只上报非个人数据:版本、OS、启动次数、崩溃次数、核心功能使用计数。
  • 不上报:歌名、路径、文件哈希、用户输入。
  • 首次启动显示同意弹窗;可在设置里关闭。
track("feature_used", { name: "equalizer" });

后端实现:Cloudflare Worker + D1,5 行代码搞定一个计数器;或用 Plausible / PostHog 自托管。

六、隐私政策 & GDPR

  • 明确列出收集哪些数据、保留多久、如何删除。
  • 提供"删除我的数据" 按钮 → 调 API。
  • 欧盟用户需要 Cookie/Tracking consent。

七、应用内消息

  • Rust 侧定时 (每日一次) 拉 https://releases.cloudtone.app/messages.json。
  • 有新的 "公告" 时在 UI 顶部展示。
  • 允许用户"不再显示"。
[
  { "id": "2026-04-20-eq", "title": "新增均衡器", "url": "https://cloudtone.app/blog/eq", "expires": "2026-05-20" }
]

八、发版 checklist

  • CHANGELOG 更新
  • 版本号 bumpup(Cargo.toml / package.json / tauri.conf.json)
  • 打 tag v0.5.0
  • CI 构建 & 公证通过
  • 官网下载页指到新版本
  • Release note 发社群
  • 24h 观察崩溃率 < 基线

九、监控面板

简单可做:

  • GitHub Actions 每日聚合 Sentry 错误数、下载数、DAU,推 Slack。
  • 关键指标:P0 崩溃率(< 0.5%),启动 TTI(< 1.5s),更新成功率(> 95%)。

本章小结

  • 发布不是终点,而是开始。
  • 用户感知到的质量 = 崩溃率 + 响应速度 + 反馈闭环。
  • 监控和隐私同样重要,别越界收集。

动手时刻

  • 集成 Sentry,手动抛异常,看上报。
  • 写一个"关于"对话框,展示版本和日志目录。

Part 2 结语

至此,CloudTone 实战部分完结。你已经构建了一个现实世界水准的跨平台桌面应用:音频引擎、多窗口、媒体中心、插件、签名更新、CI/CD、监控。

接下来 Part 3 聚焦"从能做到做得好":架构观、性能极限、开源生态、职场准备。

下一章:源码阅读清单。

第 48 章 源码阅读清单:Tauri / Tokio / Symphonia / Rodio

本章目标

从"会用库"到"懂原理"。给出一份有序的源码阅读地图,每个项目说明看什么、怎么看、收获什么。

一、为什么要读源码

  • Debug 时能直达根因,而不是猜。
  • 写业务代码时更有品味(错误处理、API 设计)。
  • 面试时能讲"我读过它的 XX 模块"远胜空谈。

二、阅读方法

  1. 从 examples 入手:官方示例是最精炼的入口。
  2. 自底向上:从最底层类型(struct、enum)开始,看字段和方法签名。
  3. 画模块图:模块 → 子模块 → 关键类型,用白板或 markdown。
  4. 标"你会改"的位置:想象自己要加一个功能,问"我得改哪里?"。
  5. 带着问题读:比如"Channel 怎么实现背压",而不是通读。

三、Tauri

仓库:https://github.com/tauri-apps/tauri

建议路线:

  • crates/tauri/src/app.rs:App / Manager 初始化链。
  • crates/tauri/src/ipc/:命令 invoke 的解析、序列化。
  • crates/tauri/src/webview/mod.rs:WebviewWindow 怎么 wrap wry。
  • crates/tauri/src/manager/event.rs:emit / listen 实现,消息分发。
  • crates/tauri/src/protocol/:内置和自定义 URI scheme 的匹配链。

关键问题:

  • #[tauri::command] 宏展开成什么?(用 cargo expand)
  • State<'_, T> 如何在 invoke 里注入?

四、Wry & Tao

仓库:https://github.com/tauri-apps/wry,https://github.com/tauri-apps/tao

  • Wry = 跨平台 WebView。看 src/webview2/ (Windows)、src/wkwebview/ (macOS)、src/webkit2gtk/ (Linux)。
  • Tao = 跨平台窗口。和 winit 有分歧,专门服务 Tauri。

收获:理解三平台原生 WebView 的差异和统一抽象。

五、Tokio

仓库:https://github.com/tokio-rs/tokio

  • tokio/src/runtime/:多线程调度器核心。
  • tokio/src/sync/mpsc/:最常用的 channel。
  • tokio/src/io/util/:AsyncReadExt 等扩展 trait。

建议:通读一次 runtime::task::harness 模块,理解 future 怎么被 poll。

六、Symphonia

仓库:https://github.com/pdeljanov/Symphonia

  • symphonia-core/src/formats/:抽象 Format/Track/Packet。
  • symphonia-bundle-mp3/:完整 mp3 解码路径。
  • examples/symphonia-play/:最完整的参考播放器,值得逐行看。

收获:音频解码的完整管线——demux → decode → resample → output。

七、Rodio

仓库:https://github.com/RustAudio/rodio

  • src/source.rs:Source trait 的组合器模式(像迭代器)。
  • src/sink.rs:播放句柄,控制 play/pause/stop。
  • src/decoder/:基于 symphonia / hound / lewton 等的解码包装。

收获:组合器式的 DSL 在音频场景的应用。

八、Sqlx

仓库:https://github.com/launchbadge/sqlx

  • sqlx-core/src/pool/:连接池算法。
  • sqlx-sqlite/src/statement/:语句缓存和执行。
  • sqlx-macros-core/src/query/:编译期 SQL 检查。

收获:数据库驱动怎么在 async 世界里工作。

九、React(深入官方文档)

仓库:https://github.com/facebook/react

对前端新手,React 源码难度大。优先读官方文档的"内部工作原理"系列:Fiber、Hooks、Scheduler。源码浅尝即可,学会"怎么读"比"读完"重要。

十、Tanstack 系(Query、Router、Virtual)

风格统一且规模适中,是学习"库架构"的好例子:

  • 状态机(QueryObserver)。
  • 订阅/通知(QueryClient.subscribe)。
  • SSR / Suspense 集成的关注点分离。

十一、Zustand

小到可以一下午读完整个核心(< 300 行)。理解"基于订阅的极小状态"是怎么做到的。

十二、一个六周计划

  • 第 1 周:Tauri IPC 模块 + 事件分发。
  • 第 2 周:Tokio runtime + mpsc。
  • 第 3 周:Symphonia 例子 + 你自己的 audio engine 对比。
  • 第 4 周:Sqlx 连接池 + 一次 query 的生命周期。
  • 第 5 周:React Fiber 基础 + Scheduler。
  • 第 6 周:Zustand + Tanstack Query 对比。

本章小结

  • 读源码最好"有目的、分模块、带笔记"。
  • 看完后写一篇 200 字总结,巩固记忆。
  • 同一个库,做 CloudTone 进步一个阶段再来读,收获翻倍。

下一章:Tauri 的高级话题。

第 49 章 高级话题:Sidecar / 嵌入 Python / 原生菜单 / Webview 注入

本章目标

  • Sidecar:把第三方可执行文件打进 App。
  • Embedded Python:在 Tauri 里嵌入 Python 解释器(PyO3)。
  • 原生菜单栏深入:动态菜单、快捷键、响应式。
  • 向 WebView 注入 JS:做调试工具 / 注入脚本。

一、Sidecar

场景:需要调用某个 CLI(ffmpeg、yt-dlp、whisper.cpp)。

tauri.conf.json:

"bundle": {
  "externalBin": ["bin/ffmpeg"]
}

Tauri CLI 会在构建时按平台复制 bin/ffmpeg-x86_64-apple-darwin 等变体。

运行时:

use tauri_plugin_shell::ShellExt;

let output = app.shell()
    .sidecar("ffmpeg")?
    .args(["-i", input, output_path])
    .output()
    .await?;

Capability 里要允许:

{ "identifier": "shell:allow-execute",
  "allow": [{ "name": "ffmpeg", "sidecar": true, "args": true }] }

流式输出

let (mut rx, _child) = app.shell().sidecar("ffmpeg")?.args(args).spawn()?;
while let Some(ev) = rx.recv().await {
    match ev {
        CommandEvent::Stdout(line) => app.emit("ffmpeg:line", line)?,
        CommandEvent::Terminated(t) => app.emit("ffmpeg:exit", t.code)?,
        _ => {}
    }
}

二、嵌入 Python

偶尔需要调 Python 生态(比如 librosa 分析波形)。有两条路:

2.1 sidecar python 子进程

简单直接,跨平台成本低。把一个 PyInstaller 打包后的可执行文件作为 sidecar。

2.2 PyO3 嵌入

pyo3 = { version = "0.22", features = ["auto-initialize"] }
use pyo3::prelude::*;

pub fn analyze_bpm(path: &str) -> anyhow::Result<f64> {
    Python::with_gil(|py| {
        let librosa = py.import_bound("librosa")?;
        let (y, sr): (PyObject, f64) = librosa.call_method1("load", (path,))?.extract()?;
        let bpm: f64 = librosa.call_method1("beat.beat_track", (y,))?.get_item(0)?.extract()?;
        Ok(bpm)
    })
}

注意:

  • 发布时你得分发 Python 解释器(用 python-build-standalone),成本显著。
  • macOS 的 Hardened Runtime + Python dylib 的签名头疼。

一般建议优先 sidecar,除非真的需要高频调用。

三、原生菜单进阶

动态菜单项

let recent = state.recent_files.lock().await.clone();
let mut builder = Submenu::builder(app, "文件");
for path in recent {
    builder = builder.item(&MenuItem::with_id(app, format!("recent:{}", path), path, true, None::<&str>)?);
}
let menu = builder.build()?;
app.set_menu(menu)?;

菜单事件统一处理:

app.on_menu_event(|app, ev| {
    let id = ev.id().as_ref();
    if id.starts_with("recent:") {
        let path = id.trim_start_matches("recent:");
        let _ = app.emit("open_file", path);
    }
});

响应当前状态(Checkable)

播放模式菜单打勾:

let shuffle = CheckMenuItem::with_id(app, "shuffle", "随机", true, mode == Shuffle, None::<&str>)?;

四、向 WebView 注入 JS

if let Some(w) = app.get_webview_window("main") {
    w.eval("window.__APP_BUILD__ = 'nightly';")?;
}

高级:注入一个调试面板:

w.eval(include_str!("../assets/devtools.js"))?;

你甚至可以 hook console.log 上报到 Rust。

五、开发时打开 DevTools

#[cfg(debug_assertions)]
if let Some(w) = app.get_webview_window("main") { w.open_devtools(); }

六、自定义 WebView 设置

  • initialization_script:在所有页面加载前跑。
  • user_agent:设定 UA。
  • transparent:透明窗口。
  • accept_first_mouse:macOS 首次点击不被吃掉。
WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
    .initialization_script("window.__TAURI_BRIDGE__ = true;")
    .user_agent("CloudTone/1.0")
    .build()?;

七、与 OS 深度集成

  • macOS Dock 菜单:TrayIconBuilder + MenuBuilder + set_dock_menu。
  • Windows Jump List:通过 webview2/windows-rs 原生 API。
  • Linux 通知:tauri-plugin-notification,MPRIS(第 37 章)。

八、性能剖析

  • macOS Instruments:附加到 cloudtone 进程,profile CPU、内存。
  • Windows Performance Analyzer:ETW 跟踪。
  • 前端:Chrome DevTools(--remote-debugging-port=9222 附加)。

本章小结

  • Sidecar 比嵌入解释器简单得多。
  • 原生菜单是桌面感的关键,动态 + Checkable 提升体验。
  • 注入脚本是强力工具,慎用但必备。

下一章:求职与职业规划。

第 50 章 面试准备与职业规划

本章目标

  • 把你学到的落成可讲述的"作品叙事"。
  • 准备 Tauri / Rust / React 高级岗常见考察点。
  • 长期职业节奏。

一、作品叙事三段式

  1. 问题定义(30 秒):
    "CloudTone 是一个跨平台桌面音乐播放器。主要挑战是在单进程里同时处理高性能音频解码、SQLite 海量歌曲索引、三窗口 UI 同步。"
  2. 关键决策(1 分钟):
    "音频引擎采用 Actor 模式 + 专用 pump 线程,解耦 async 与 realtime;数据库用 FTS5 + 2-gram 搜中文;多窗口用 Tauri 单进程多 Webview 共享 AppState。"
  3. 度量结果(30 秒):
    "10 万首歌滚动保持 60fps;冷启动 1.1s;崩溃率 < 0.3%。"

练一个 2 分钟的版本、一个 10 分钟的版本。

二、作品可视化

  • GitHub README:屏幕录制 + 架构图。
  • 博客写 3 篇:
    • "我们为什么选 Tauri"
    • "写一个无 gc 延迟的 Rust 音频引擎"
    • "CloudTone 的插件沙箱设计"
  • YouTube / 视频站:5 分钟 demo。

三、常见考察点

Rust

  • 所有权 / 借用 / 生命周期:讲出为什么 self.audio 在 &mut self 里不能直接 move。
  • Send / Sync:tokio::spawn 的 future 必须 Send。
  • Pin / async 状态机:简略能讲即可。
  • 设计题:读写锁 vs Mutex;Channel vs 回调。

React / 前端

  • Reconciliation:为啥要 key。
  • Hook 闭包陷阱:useEffect 读到过期状态的典型。
  • 状态管理:Zustand / Redux / Query 分层。

Tauri / 架构

  • IPC 机制:Command vs Event vs Channel。
  • 权限模型:Capabilities & Scope。
  • 多窗口同步:state 在 Rust,事件广播。
  • 打包签名:mac 公证全流程。

系统 / 性能

  • 内存布局:ring buffer 为啥选 HeapRb。
  • 线程模型:cpal callback + pump thread。
  • I/O 优化:WAL、batch、mmap。

四、简历亮点写法

弱: "用 Tauri 实现了音乐播放器,实现了播放、歌单、歌词。"

强: "自研跨平台桌面音乐客户端 CloudTone:

  • Rust 音频引擎:symphonia 解码 + cpal 输出 + HeapRb 零分配管线,稳定 96kHz 播放,P99 回调延迟 < 2ms。
  • 多窗口单进程架构:3 个 Webview 共享 AppState,节省 200MB 内存对比 Electron 同类应用。
  • 本地 + 在线搜索:SQLite FTS5 + 2-gram 分词,10 万首歌 p95 延迟 18ms。
  • CI/CD:GitHub Actions 三平台矩阵构建,macOS 公证 + Windows 代码签名全流程自动化。"

五、项目贡献

  • 给 Tauri 提一个 issue / PR(即使是文档)。
  • 给 symphonia 报一个兼容性 bug。
  • 社群(Discord)答 10 个问题。

这些经历是简历上的 "X-factor",显示你不是只 copy paste。

六、学习节奏建议

  • 3 个月:做完 CloudTone 核心(Ch 21-35),能写简单 Tauri App。
  • 6 个月:完成完整 CloudTone(含更新、签名、E2E)、1 篇技术博客。
  • 12 个月:贡献过开源 PR、能独立排查任意 Tauri 线上问题、在团队里传帮带。

七、心态

  • 过度准备面试 < 把项目做深。
  • 做过真实 App 一次,顶 20 次八股。
  • 持续输出 > 零散学习。

八、下一本书?

  • 《Programming Rust, 2nd》—— 深化 Rust。
  • 《Designing Data-Intensive Applications》—— 系统设计。
  • 《High Performance Browser Networking》—— 理解 Web 栈。

九、给你的最后一句

完成重于完美,迭代胜过空想。 如果你已经把 CloudTone 做到能日常使用,你就已经站在了"高级候选人"的起跑线上。


本书正文完。接下来是附录——FAQ、资源、源码索引。

附录 A 常见问题 FAQ

环境 / 工具链

Q: cargo tauri dev 第一次编译 20 分钟正常吗?
A: 正常。首次要编 wry / webkit2gtk-sys 等原生依赖。之后增量只需几秒。

Q: Windows 提示找不到 WebView2?
A: 在 Windows 10 21H2 之前版本要手动装 Evergreen WebView2 Runtime。Tauri 默认会在首次运行时引导。

Q: Linux 编译报 webkit2gtk-4.1 not found?
A: sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev pkg-config。

Q: macOS 启动时弹"无法验证开发者"?
A: 未签名的 dev build 属于正常现象。右键 → 打开即可;发行包请完成公证。

代码

Q: 为什么 tokio::sync::Mutex 比 std::sync::Mutex 贵,却还推荐?
A: async 上下文里你可能在持锁时 .await,std::Mutex 会 block 整个线程,导致其他 task 饿死。tokio::Mutex 允许跨 await 持锁。

Q: tauri::State<'_, T> 的 T 必须 Send + Sync + 'static?
A: 是的。因为 State 在任意 async command 里都可能被借用。

Q: Command 返回 Result<T, String> vs 自定义 Error?
A: 自定义 Error + serde::Serialize + specta::Type 最佳,前端能拿到结构化错误。

Q: 为什么前端改了代码 Tauri 没热更?
A: tauri.conf.json 的 build.beforeDevCommand 需指向 pnpm dev,且 devUrl 指向 Vite server。

音频

Q: cpal 回调里报 "audio underrun"?
A: 解码没跟上。检查 ring buffer 容量、pump thread 是否被其他任务阻塞、Symphonia 是否在解码路径里有 println!。

Q: 播某些 FLAC 出现杂音?
A: 多半是采样率转换的锅。确认 rubato 的参数(input_rate、output_rate、chunk_size)与音频一致。

Q: 怎么做音量归一化?
A: 元数据里的 R128 / ReplayGain → 应用增益 → limiter 防 clipping。见第 27 章。

数据库

Q: SQLite 的 "database is locked" 怎么办?
A: 打开 WAL(PRAGMA journal_mode=WAL;)。避免长事务占写锁。

Q: 查询几十万行很慢?
A: 优先加索引;其次用 keyset 分页;再次考虑预聚合表。

打包 / 更新

Q: DMG 安装后启动闪退?
A: 检查 entitlements.plist 是否开启 com.apple.security.cs.allow-unsigned-executable-memory(PyO3 / WebKit 需要)。

Q: Windows 安装器被 SmartScreen 拦截?
A: 未签名的 EXE 常见现象。买 EV 证书或者等声誉积累;期间引导用户点"更多信息 → 仍要运行"。

Q: 自动更新没触发?
A: 依次排查:

  1. tauri.conf.json endpoint URL 返回 200?
  2. pubkey 与签发密钥配对?
  3. 新版本号确实 > 当前版本?
  4. 平台标识对(arch + os)?

性能

Q: 虚拟列表渲染闪烁?
A: 确保 row 有固定 height 或 estimateSize 精准;overscan 给 8-10。

Q: 发现内存一直涨?
A: 常见泄漏点:全局事件监听没 unlisten、Query 缓存未限制、图片缓存无上限。用 Chrome DevTools Heap Snapshot 对比。

其它

Q: 可以在 Tauri App 里调用 Bluetooth / USB 吗?
A: 可以,通过 Rust 侧第三方 crate(btleplug、rusb)。前端无权访问,必须经 IPC。

Q: 能上架 Mac App Store 吗?
A: 需要 sandbox + 额外 entitlements + Apple Distribution 证书。Tauri 社区已有成功案例(WeChat 衍生工具等)。

附录 B 资源汇总

官方文档

关键 Crate

领域Crate用途
异步tokioasync runtime
HTTPreqwest客户端
数据库sqlxSQLite/Postgres/MySQL 异步驱动
音频解码symphoniamp3/flac/aac/ogg
音频输出cpal跨平台音频 I/O
元数据loftyID3/FLAC/Vorbis
哈希blake3文件指纹
目录遍历walkdir递归扫描
文件监听notify / notify-debouncer-full库变化实时同步
日志tracing / tracing-subscriber结构化日志
错误thiserror / anyhow错误定义与组合
序列化serde / serde_jsonJSON / 其他格式
重采样rubato音频采样率转换
系统媒体souvlakimac/win/linux 媒体中心

Tauri 插件(Tauri 2)

  • tauri-plugin-fs
  • tauri-plugin-http
  • tauri-plugin-sql
  • tauri-plugin-log
  • tauri-plugin-dialog
  • tauri-plugin-global-shortcut
  • tauri-plugin-single-instance
  • tauri-plugin-updater
  • tauri-plugin-notification
  • tauri-plugin-shell
  • tauri-plugin-window-state

前端库

类别库
UIshadcn/ui, Radix, Headless UI
状态Zustand, Jotai, Valtio
数据TanStack Query / Router / Virtual / Table
表单React Hook Form, Zod
路由React Router, TanStack Router
图标lucide-react
动画framer-motion
拖拽@dnd-kit
i18nreact-i18next
测试Vitest, Testing Library, Playwright

社群与交流

学习项目推荐

  • tauri-apps/create-tauri-app:官方模板
  • tauri-apps/plugins-workspace:看插件源码学 API
  • 开源 Tauri App:OrchidApp、Pot、Spacedrive、Astral、Rerun

书籍

  • 《Programming Rust, 2nd》
  • 《Rust for Rustaceans》
  • 《Designing Data-Intensive Applications》
  • 《High Performance Browser Networking》
  • 《The Pragmatic Programmer》

博客 / YouTube

  • tauri.app/blog
  • Logan Smith (Rust)
  • Let's Get Rusty
  • Theo (fullstack views)
  • Fireship (fast overviews)

工具

附录 C 完整代码索引(CloudTone 骨架)

本附录给出 CloudTone 项目的完整目录结构与每个关键文件的入口注释。

目录结构

cloudtone/
├── Cargo.toml
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.ts
├── postcss.config.js
├── index.html
├── mini.html
├── lyric.html
├── README.md
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── scripts/
│   ├── compose-latest-json.js
│   └── gen-bindings.mjs
├── public/
│   ├── fonts/
│   └── icons/
├── src/                                # 前端 (React + TS)
│   ├── main.tsx                         # 主窗口入口
│   ├── mini/
│   │   ├── main.tsx
│   │   └── MiniApp.tsx
│   ├── lyric/
│   │   ├── main.tsx
│   │   └── LyricOverlay.tsx
│   ├── app/
│   │   ├── router.tsx
│   │   ├── home/HomePage.tsx
│   │   ├── library/LibraryPage.tsx
│   │   ├── playlist/PlaylistPage.tsx
│   │   ├── search/SearchPage.tsx
│   │   ├── lyrics/LyricsPage.tsx
│   │   ├── downloads/DownloadsPage.tsx
│   │   └── settings/SettingsPage.tsx
│   ├── components/
│   │   ├── shell/Shell.tsx
│   │   ├── shell/Sidebar.tsx
│   │   ├── shell/PlayerBar.tsx
│   │   ├── shell/TitleBar.tsx
│   │   ├── shell/NowPlayingPanel.tsx
│   │   ├── shell/SearchOverlay.tsx
│   │   ├── SongList.tsx
│   │   ├── SongRow.tsx
│   │   ├── LikeButton.tsx
│   │   ├── ContextMenu.tsx
│   │   └── ui/            (shadcn)
│   ├── features/
│   │   ├── player/player.ts             # Zustand store
│   │   ├── player/usePlayerSync.ts
│   │   ├── library/queries.ts
│   │   ├── search/useSearch.ts
│   │   ├── lyrics/useLyrics.ts
│   │   ├── playlists/queries.ts
│   │   ├── download/useDownloads.ts
│   │   ├── equalizer/useEq.ts
│   │   ├── plugins/host.ts
│   │   └── updater/useUpdater.ts
│   ├── hooks/
│   │   ├── useDebouncedValue.ts
│   │   ├── useHotkeys.ts
│   │   └── useMediaEvents.ts
│   ├── lib/
│   │   ├── ipc.ts                       # specta 自动生成
│   │   ├── cn.ts
│   │   ├── fmt.ts                       # 时间/大小格式化
│   │   └── types.ts
│   ├── i18n/
│   │   ├── index.ts
│   │   └── locales/
│   ├── styles/
│   │   ├── index.css
│   │   └── tokens.css
│   └── test/
│       ├── setup.ts
│       └── *.test.tsx
└── src-tauri/                         # Rust 后端
    ├── Cargo.toml
    ├── tauri.conf.json
    ├── build.rs
    ├── icons/
    ├── capabilities/
    │   ├── default.json
    │   ├── mini.json
    │   └── lyric.json
    ├── migrations/
    │   ├── 20260115_init.sql
    │   └── 20260201_addons.sql
    ├── entitlements.plist
    └── src/
        ├── main.rs
        ├── lib.rs
        ├── state.rs                    # AppState
        ├── error.rs                    # AppError
        ├── cmds/
        │   ├── mod.rs
        │   ├── library.rs
        │   ├── player.rs
        │   ├── queue.rs
        │   ├── playlist.rs
        │   ├── search.rs
        │   ├── lyrics.rs
        │   ├── download.rs
        │   ├── eq.rs
        │   ├── settings.rs
        │   ├── plugins.rs
        │   └── updater.rs
        ├── core/
        │   ├── mod.rs
        │   ├── audio/
        │   │   ├── mod.rs
        │   │   ├── decoder.rs
        │   │   ├── output.rs
        │   │   ├── player.rs
        │   │   ├── queue.rs
        │   │   ├── fader.rs
        │   │   ├── media_controls.rs
        │   │   └── dsp/
        │   │       ├── biquad.rs
        │   │       └── equalizer.rs
        │   ├── library/
        │   │   ├── mod.rs
        │   │   ├── scanner.rs
        │   │   ├── importer.rs
        │   │   └── manager.rs
        │   ├── db/
        │   │   ├── mod.rs
        │   │   ├── migrations.rs
        │   │   ├── models.rs
        │   │   ├── queries.rs
        │   │   └── search.rs
        │   ├── lyrics/mod.rs
        │   ├── providers/
        │   │   ├── mod.rs
        │   │   ├── demo.rs
        │   │   └── registry.rs
        │   ├── download/mod.rs
        │   ├── plugins/mod.rs
        │   ├── protocol/
        │   │   ├── mod.rs
        │   │   └── cover.rs
        │   └── window/
        │       ├── mod.rs
        │       ├── main.rs
        │       ├── mini.rs
        │       └── lyric.rs
        └── tests/
            ├── db_test.rs
            └── fixtures/

关键入口文件

src-tauri/src/lib.rs

use tauri_specta::Builder;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let builder = Builder::<tauri::Wry>::new()
        .commands(tauri_specta::collect_commands![
            cmds::library::library_scan,
            cmds::library::library_list_songs,
            cmds::library::library_toggle_favorite,
            cmds::player::player_play,
            cmds::player::player_pause,
            cmds::player::player_toggle,
            cmds::player::player_seek,
            cmds::player::player_set_volume,
            cmds::queue::queue_set,
            cmds::queue::queue_next,
            cmds::queue::queue_prev,
            cmds::queue::queue_set_mode,
            cmds::playlist::playlist_create,
            cmds::playlist::playlist_list,
            cmds::playlist::playlist_add,
            cmds::playlist::playlist_remove,
            cmds::playlist::playlist_move,
            cmds::search::search,
            cmds::lyrics::lyrics_load,
            cmds::download::download_start,
            cmds::download::download_cancel,
            cmds::eq::eq_set,
            cmds::eq::eq_preset,
            cmds::settings::settings_get,
            cmds::settings::settings_set,
            cmds::plugins::plugins_list,
        ]);

    #[cfg(debug_assertions)]
    builder.export(specta_typescript::Typescript::default(), "../src/lib/ipc.ts").unwrap();

    tauri::Builder::default()
        .plugin(tauri_plugin_log::Builder::default().build())
        .plugin(tauri_plugin_single_instance::init(|app, _, _| { /* focus main */ }))
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_http::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_shell::init())
        .plugin(tauri_plugin_notification::init())
        .plugin(tauri_plugin_global_shortcut::Builder::new().build())
        .plugin(tauri_plugin_updater::Builder::new().build())
        .invoke_handler(builder.invoke_handler())
        .setup(move |app| {
            builder.mount_events(app);
            state::setup(app)?;
            core::window::main::configure(app)?;
            core::protocol::cover::register(app)?;
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

src-tauri/src/main.rs

fn main() { cloudtone::run(); }

src/main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "react-router-dom";
import { router } from "./app/router";
import "./styles/index.css";
import "./i18n";

const qc = new QueryClient();

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <QueryClientProvider client={qc}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </React.StrictMode>,
);

一页 Cheatsheet

要做……翻到第几章
注册一个命令第 11 章
发事件 / 监听第 12 章
多窗口第 14、36 章
全局快捷键第 15 章
读写文件第 17 章
调 HTTP第 18 章
SQLite第 19、29 章
音频引擎第 26、27 章
扫描音乐库第 28 章
搜索第 33 章
自定义协议第 32 章
媒体键第 37 章
自动更新第 43 章
CI/CD第 46 章
发布第 47 章

完结

本书到此结束。感谢一路同行。你现在手里有:

  • 一本完整的 Tauri 2.x + 现代前端 + Rust 生产工程指南。
  • 一个可日常使用的桌面音乐播放器 CloudTone。
  • 面试 Tauri / 桌面应用高级岗所需的叙事和深度。

祝你快速拿到心仪 offer,或者把 CloudTone 做成下一个现象级开源项目。