前言:为什么写这本书
这本书要帮你做成什么事
这本书只有一个目标:让你从零起步,在大约 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 高级岗位面试 |
每章的结构
为了让你读得舒服也学得扎实,每一章尽量遵循下面的结构:
- 本章目标:3–5 句话,告诉你读完能做到什么。
- 背景/原理:讲清楚「为什么是这样」而不只是「怎么做」。
- 动手代码:完整、可运行的片段。关键代码会有中文注释。
- 常见陷阱:我和许多开发者踩过的坑,提前告诉你。
- CloudTone 本章增量(从第 21 章起):你这一章要往项目里加/改什么,用 diff 思维描述。
- 练习:自检题 + 可选拓展题。
你需要投入多少时间
- 每天 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 年初的生态情况):
| 方案 | 后端 | 前端渲染 | 安装包大小 | 内存占用 | 成熟度 | 适合谁 |
|---|---|---|---|---|---|---|
| Electron | Node.js | 打包 Chromium | 80–200MB | 150–400MB | ★★★★★ | 预算充足的大型应用 |
| Tauri 2.x | Rust | 系统 WebView | 3–10MB | 40–100MB | ★★★★ | 性能敏感、预算有限、追求安全 |
| Wails | Go | 系统 WebView | 5–15MB | 40–100MB | ★★★ | Go 工程师团队 |
| NW.js | Node.js | 打包 Chromium | 80–200MB | 150–400MB | ★★★ | 老项目,新项目少用 |
| Neutralino | C++ | 系统 WebView | 1–3MB | 20–60MB | ★★ | 纯轻量小工具 |
| Flutter Desktop | Dart | 自绘 Skia | 30–60MB | 60–150MB | ★★★ | 已有 Flutter 团队 |
| React Native for Desktop | JS 桥接 | 原生控件 | 40–80MB | 80–200MB | ★★★ | Windows 10/11 原生风 |
结论:在 2026 年,如果你要做一款新的跨平台桌面应用,且对性能和安装体积敏感,Tauri 和 Flutter Desktop 是并列的两个主流选择。Tauri 胜在 Web 生态的易用,Flutter 胜在一次绘制跨平台一致。招聘端看,Tauri 岗位需求从 2024 年起增速最快。
Tauri 的三大核心技术点
贯穿本书的学习主线,实质上就是下面三件事:
- IPC(Inter-Process Communication):前端和 Rust 后端怎么通信?答案是
invoke(前→后调命令)和emit/listen(事件总线)。第 11、12 章会讲透。 - Capabilities / ACL:前端不能为所欲为地调用 Rust——必须在
capabilities/*.json里声明哪些窗口、哪些命令可以用。这是 Tauri 2.x 的核心安全机制。第 13 章专门讲。 - 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
必须装两个东西:
- Microsoft C++ Build Tools(在 Visual Studio Installer 里勾选「Desktop development with C++」,或者单独下载 Build Tools for Visual Studio)。
- 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 都行。接下来的例子,你只需要:
- 新建一个文件夹,比如
Desktop/hello-web/。 - 里面新建一个文件叫
index.html。 - 把本章每个示例的 HTML 代码粘进去。
- 双击
index.html,浏览器就会打开它。 - 改代码 → 保存 → 浏览器按 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 写在哪里?
三种方式,由差到好排序:
-
行内样式(不推荐,只做临时调试):
<h1 style="color: red;">红色标题</h1> -
内部样式表(小页面调试 OK):把
<style>...</style>放在<head>里,像上面那个例子。 -
外部样式表(正式项目都这么做):单独写一个
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 选择器 #hero | 100 |
类 .title / 属性 [type] / 伪类 :hover | 10 |
标签 p / 伪元素 ::before | 1 |
分高的赢;同分时后面写的赢。
日常 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 的核心就两件事:
- 给父元素加
display: flex,它就变成"Flex 容器"。 - 然后用几个属性控制子元素怎么排。
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 或右键 → 检查):
- Elements 面板:点任一元素,右边显示它命中的所有 CSS 规则。被划掉的就是被覆盖了。
- Computed 面板:显示最终生效的值,不管来自哪条规则。
- Box Model 图:显示这个元素真实的 margin/border/padding/content 尺寸。
- 临时加样式:可以直接在右边面板改数字看效果,不用回编辑器。
排查技巧:
- 拿不准元素边界 → 加
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();
三点必记:
- 用
async标记的函数总是返回 Promise。 await只能在async函数里用。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 例子。每次数据变化,你都要:
- 清空
<ul>。 - 循环生成
<li>。 - 分别加 checkbox、文字、按钮。
- 给每个元素绑事件。
数据一多,手写 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>
);
}
三点关键:
{name}把变量插进 HTML 里。{items.map(...)}循环生成一堆元素。列表里每个元素都要有唯一的key属性,React 用它追踪哪个是哪个。- 多个顶层元素要用一个父元素包起来(或者用
<>...</>,叫 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 个常见坑
- state 更新了但组件没变 → 你改了对象内部而不是换了引用。用
setX({ ...x, y: 2 })。 - effect 跑两次 → 严格模式特性,不是 bug。
- 依赖数组警告你漏了 → 照 ESLint 提示加,或想清楚为啥不需要(通常还是加上对)。
- 闭包陷阱:
useEffect里读的 state 永远是挂载时那个值 → 用函数式更新setCount(c => c + 1)或把值加进依赖。 - 列表用 index 当 key → 列表重排或删除时出 bug,用稳定 id。
- input 光标乱跳 → 检查是不是 key 写错导致重新挂载。
- Context 变一次所有用它的都重渲 → 把大对象拆小 Context,或用 Zustand。
- className 拼错 → React 不检查 class 名,CSS 找不到的类静默不生效。
- 忘了
e.preventDefault()→ 表单提交刷新页面。 - 组件函数里写副作用(直接 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,清理用返回的函数。 - 先把功能写对,别过早优化。
强烈建议:
- 把第七节 TODO 例子亲手敲一遍。
- 再加一个功能:"只看未完成" 的过滤开关。
- 再把 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
| 类别 | 类 | 作用 |
|---|---|---|
| display | block inline inline-block flex inline-flex grid inline-grid hidden contents | display: ... |
| position | static relative absolute fixed sticky | position: ... |
| inset | inset-0 inset-x-0 top-0 left-4 -top-2 top-[12px] | 定位偏移 |
| z-index | z-0 z-10 z-20 z-30 z-40 z-50 z-[100] | 层级 |
| float | float-left float-right float-none | 极少用 |
| overflow | overflow-auto overflow-hidden overflow-scroll overflow-x-auto overflow-y-hidden | 溢出 |
| overscroll | overscroll-contain overscroll-none | 阻止边缘联动滚动 |
| object-fit | object-contain object-cover object-fill object-none | <img> 填充方式 |
| isolation | isolate | 创建独立 stacking context |
| aspect-ratio | aspect-square aspect-video aspect-[16/9] | 固定宽高比 |
| columns | columns-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 查询类别的心理映射
遇到新需求时,我的脑内检索顺序大致是:
- 「这是什么 CSS 属性?」
- 「Tailwind 叫什么前缀?」(通常属性名省略元音或缩写:padding → p-、margin → m-、background → bg-、border → border-、text color → text-、overflow → overflow-)
- 「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 末稳定)的核心变化:
| 维度 | v3 | v4 |
|---|---|---|
| 引擎 | PostCSS + 自研 JIT | Oxide(Rust 重写,5-10× 更快) |
| 配置方式 | tailwind.config.ts (JS/TS) | CSS-first:@import "tailwindcss"; + @theme { ... } 在 CSS 里 |
| content 扫描 | 显式配置 | 自动发现(依赖 git/项目扫描) |
| 颜色空间 | RGB/HSL | OKLCH 为默认,更准的对比度 |
| 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,就过了精通门槛。
动手时刻
- 在第 2 章的 smoke-test 项目里,把
SongCard按本章最终版本写一遍,加上group+data-[state=active]变体。 - 建立
src/lib/cn.ts、src/components/ui/button.tsx(用 cva)。 - 用
@tailwindcss/container-queries做一个「在宽度 < 400px 时变成单列」的歌曲列表。 - 用 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。
所以我们必须对下面这套东西非常熟:
async / .await和tokio运行时。Send + Sync、Arc<Mutex<_>>、RwLock、跨线程/跨 await 共享状态。- 错误建模:
thiserror定义枚举 error,anyhow做 bubble up,向前端序列化成字符串。 serde把结构体与 JSON 互转。- 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之前把 guarddrop掉。
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>>,
}
要点:
asynctrait 需要async-traitcrate(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-trait | async trait |
tokio | 异步运行时 |
tracing + tracing-subscriber | 结构化日志 |
reqwest | HTTP 客户端 |
sqlx | SQLite/MySQL 异步访问 |
url | URL 构造 |
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 fnin 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 扩展点。
动手时刻
不用写代码,脑中回答:
- 为什么 Tauri State 里要包
Mutex? std::sync::Mutex和tokio::sync::Mutex在 Tauri 里怎么选?#[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");
}
过程拆解:
tauri::generate_context!()读取tauri.conf.json+ 资源文件,编译期嵌入到二进制。Builder构造出App,启动一个 tokio runtime。- 根据
tauri.conf.json的windows数组创建 WebView 窗口。每个窗口会向系统申请 WebView 实例。 setup闭包只在第一次启动时跑一次,适合初始化数据库、注册托盘等。- 事件循环启动。主线程负责窗口和托盘事件;业务逻辑跑在 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 });
发生了什么:
invoke通过 WebView 的原生桥(iOS 的WKScriptMessageHandler、Windows 的postMessage给 WebView2、Linux 的webkit_user_content_manager)把消息{ cmd: "add", args: { a:1, b:2 } }送到 Core。- Core 在注册表里找到
add,反序列化参数。 - 调用
fn add(a: i64, b: i64) -> i64。 - 返回值序列化,走反向通道回到 WebView。
- 前端
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 的架构对比
| 方面 | Electron | Tauri |
|---|---|---|
| 主进程 | Node.js | Rust |
| 渲染进程 | 打包 Chromium | 系统 WebView |
| IPC | ipcRenderer.invoke / ipcMain.handle | invoke + #[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 server1420 端口被占用或 Vite 启动失败。先
pnpm dev看错误。
Windows 上
error: linker link.exe not found第 2 章的 C++ Build Tools 没装全。
invoke调用报Not allowed by ACLCapabilities 不对。第 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 是万恶之源。两种方案:
ts-rs:在 Rust 里派生TS,cargo test时导出.ts。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 + SyncRust 会大段报错。常见原因: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 用上一章的
useTauriEventhook。
下一章,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 里,开一项整个应用都能用。这有两个问题:
- 权限泄露:某个 WebView 被 XSS 攻破,全部权限都在它手里。
- 第三方内容:如果你嵌入第三方网页(比如插件窗口),它和你主界面共享权限。
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-titlecore:window:allow-closecore:event:defaultcore:path:defaultfs:allow-read-text-filefs:allow-write-text-filedialog:allow-openshell:allow-opensql:defaulthttp: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
诊断三步:
- 确认该命令的 permission 名字。查插件仓库或
src-tauri/gen/schemas/acl-manifests.json。 - 去
capabilities/*.json对应windows里加上这条 permission。 - 重启
pnpm tauri dev(capabilities 是编译期读取的,热更不生效)。
八、CloudTone 的 Capabilities 设计(预告)
主窗口 main:
core:default、core:event:default、core:window:default、core:path:defaultfs:default+ scope 限制到$APPDATA/cloudtone/**和用户选择的音乐目录dialog:default(选目录)http:default(调用在线音源 API)notification:defaultsql:defaultlog: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 ACL99% 的问题都在 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_sizeposition(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 默认快捷键表
| 功能 | macOS | Win/Linux |
|---|---|---|
| 播放/暂停 | Cmd+Alt+P | Ctrl+Alt+P |
| 下一曲 | Cmd+Alt+Right | Ctrl+Alt+Right |
| 上一曲 | Cmd+Alt+Left | Ctrl+Alt+Left |
| 音量+ | Cmd+Alt+Up | Ctrl+Alt+Up |
| 音量- | Cmd+Alt+Down | Ctrl+Alt+Down |
| 显示/隐藏主窗口 | Cmd+Alt+Space | Ctrl+Alt+Space |
| 切换桌面歌词 | Cmd+Alt+L | Ctrl+Alt+L |
| 快速搜索 | Cmd+Alt+F | Ctrl+Alt+F |
快捷键要可被用户自定义,第 47 章讲配置页。
冲突与异常
某些快捷键已被系统或其他 app 占用。注册会返回错误。良好做法:
- 尝试注册,失败就记日志。
- 在设置页列出 "冲突",让用户改键。
- 支持 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/broadcastchannel 做「内部事件总线」。 - 避免最常见的死锁与
!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,
}
}
}
要点:
- 外面不要再套
Arc:Tauri 自己manage(state)时会包一层,内部字段用Arc<Mutex>就够。 - 读多写少用
RwLock:settings、library 都是。 - 内部事件 broadcast:audio 线程往里发,其它模块可以订阅。
二、std::sync::Mutex vs tokio::sync::Mutex
std::sync::Mutex | tokio::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 → 死锁
规则:
- 固定加锁顺序。全局约定
player > library > settings。 - 锁作用域尽量小。别在锁里调用可能阻塞的东西。
- 别在持锁时 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 pathcapability 里没声明 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
pathAPI 让跨平台路径变简单。 fs插件前端侧受 scope 管控。- 真正的重活放 Rust,前端只传路径或调用命令。
动手时刻
-
在 hello 项目里写一个
list_music命令,返回$MUSIC下的全部音频文件路径。 - 前端展示列表。
-
加
notifywatcher,音乐目录变化时前端 toast。
下一章,HTTP 客户端与 API 调用。
第 18 章 HTTP 客户端与 API 调用(reqwest + tokio)
本章目标
- 在 Rust 侧用
reqwest发 HTTP,并对前端暴露受控的网络能力。 - 了解
tauri-plugin-http与直接用reqwest的差别。 - 打好 JSON 请求/响应、超时、重试、取消、代理、TLS 证书六大基本功。
- 设计 CloudTone 的网络层抽象。
一、两条网络路线
- 前端直接用
fetch:受 WebView CSP + capability 管控,允许发送请求到白名单域名。 - 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-tls99% 解决。
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::RollingFileAppender2.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) │
└───────────────────────────┘
五、数据流(以"播放一首歌"为例)
- 用户双击歌曲列表里的一首歌(UI)。
usePlayerStore.getState().play(song)(前端)。- Zustand 内部调用
invoke("player_play", { songId })。 - Rust command 从 DB 拿到
path,Audio Engine加载并解码。 Audio Engine启动 cpal 输出 + 定时发出player:progress事件。player:state广播给所有窗口(主、迷你、桌面歌词)。- 前端根据事件刷新 UI。
- DB 写入
play_history。
六、UI 草图(文字版)
┌──────────────────────────────────────────────────────────────┐
│ [≡] CloudTone [搜索框] [设置 账号] │
├─────────────┬────────────────────────────┬───────────────────┤
│ 发现 │ │ │
│ 音乐馆 │ │ [封面大图] │
│ 每日推荐 │ 中间主内容区 │ │
│ ───────── │ (歌单/歌曲列表/艺人) │ 歌词滚动 │
│ 我的音乐 │ │ │
│ 收藏 │ │ │
│ 歌单 ▸ │ │ │
│ 最近播放 │ │ │
│ ───────── │ │ │
│ 本地音乐 │ │ │
└─────────────┴────────────────────────────┴───────────────────┘
│ ◀◀ ▶ / ⏸ ▶▶ [00:12 ━━━━━━━ 03:45] ♡ 🎚 🔀 📃 ♪ │
└──────────────────────────────────────────────────────────────┘
七、里程碑
| 里程碑 | 章节 | 产出 |
|---|---|---|
| M0 骨架 | 22–25 | UI 框架 + 路由 + 状态 |
| M1 音频核心 | 26–27 | 能播放任意本地音频 |
| M2 音乐库 | 28–30 | 扫描、入库、队列 |
| M3 歌词 + 元数据 | 31–32 | 歌词同步、封面、自定义协议 |
| M4 搜索 + 歌单 | 33–35 | 本地搜索、歌单 CRUD |
| M5 桌面体验 | 36–37 | 迷你播放器、媒体控制 |
| M6 在线扩展 | 38–39 | Provider 架构、下载 |
| M7 进阶 | 40–42 | EQ、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 简化版:
- 当前歌 "position >= duration - 0.5" 时,emit
PlayerEvent::NearEnd。 - 前端决定下一首并
invoke("player_preload", nextId)。 - Rust 预加载第二个 decoder + buffer。
- 当前 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);
}
三、歌词来源策略
- 同名 .lrc(
/music/起风了.lrc)。 - ID3 里的 USLT(不同步)或 SYLT(同步)。
- 在线音源 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。
- macOS:
一个库覆盖三平台: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)
本章目标
- 定义统一的
MusicProvidertrait。 - 实现一个示例 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 通信。
一、两种插件观
- Tauri Plugin:用 Rust 写,编译进 App,深度访问系统。不适合第三方,因为需要重编。
- 应用级插件:运行时加载的脚本/配置,能力有限但可动态安装。
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::patchapply。
这是工程优化项,初版可以不做。
八、回滚与紧急停更
- 线上出问题,把
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.tomlrelease:
[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 公证
- 在 Apple Developer 后台申请 "Developer ID Application" 证书。
- 导出 .p12,用 base64 放
APPLE_CERTIFICATE。 - 创建 app-specific password,放
APPLE_ID_PASSWORD。 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 模块"远胜空谈。
二、阅读方法
- 从 examples 入手:官方示例是最精炼的入口。
- 自底向上:从最底层类型(
struct、enum)开始,看字段和方法签名。 - 画模块图:模块 → 子模块 → 关键类型,用白板或 markdown。
- 标"你会改"的位置:想象自己要加一个功能,问"我得改哪里?"。
- 带着问题读:比如"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:Sourcetrait 的组合器模式(像迭代器)。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 高级岗常见考察点。
- 长期职业节奏。
一、作品叙事三段式
- 问题定义(30 秒):
"CloudTone 是一个跨平台桌面音乐播放器。主要挑战是在单进程里同时处理高性能音频解码、SQLite 海量歌曲索引、三窗口 UI 同步。" - 关键决策(1 分钟):
"音频引擎采用 Actor 模式 + 专用 pump 线程,解耦 async 与 realtime;数据库用 FTS5 + 2-gram 搜中文;多窗口用 Tauri 单进程多 Webview 共享 AppState。" - 度量结果(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: 依次排查:
tauri.conf.jsonendpoint URL 返回 200?pubkey与签发密钥配对?- 新版本号确实 > 当前版本?
- 平台标识对(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 资源汇总
官方文档
- Tauri 2.0: https://tauri.app/
- Rust Book: https://doc.rust-lang.org/book/
- Tokio Tutorial: https://tokio.rs/tokio/tutorial
- React Docs (新版): https://react.dev/
- TypeScript Handbook: https://www.typescriptlang.org/docs/handbook/
- Tailwind CSS: https://tailwindcss.com/docs
关键 Crate
| 领域 | Crate | 用途 |
|---|---|---|
| 异步 | tokio | async runtime |
| HTTP | reqwest | 客户端 |
| 数据库 | sqlx | SQLite/Postgres/MySQL 异步驱动 |
| 音频解码 | symphonia | mp3/flac/aac/ogg |
| 音频输出 | cpal | 跨平台音频 I/O |
| 元数据 | lofty | ID3/FLAC/Vorbis |
| 哈希 | blake3 | 文件指纹 |
| 目录遍历 | walkdir | 递归扫描 |
| 文件监听 | notify / notify-debouncer-full | 库变化实时同步 |
| 日志 | tracing / tracing-subscriber | 结构化日志 |
| 错误 | thiserror / anyhow | 错误定义与组合 |
| 序列化 | serde / serde_json | JSON / 其他格式 |
| 重采样 | rubato | 音频采样率转换 |
| 系统媒体 | souvlaki | mac/win/linux 媒体中心 |
Tauri 插件(Tauri 2)
tauri-plugin-fstauri-plugin-httptauri-plugin-sqltauri-plugin-logtauri-plugin-dialogtauri-plugin-global-shortcuttauri-plugin-single-instancetauri-plugin-updatertauri-plugin-notificationtauri-plugin-shelltauri-plugin-window-state
前端库
| 类别 | 库 |
|---|---|
| UI | shadcn/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 |
| i18n | react-i18next |
| 测试 | Vitest, Testing Library, Playwright |
社群与交流
- Tauri Discord: https://discord.com/invite/tauri
- Tauri GitHub Discussions: https://github.com/tauri-apps/tauri/discussions
- Rust 中文社区: https://rustcc.cn/
- Awesome Tauri: https://github.com/tauri-apps/awesome-tauri
学习项目推荐
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)
工具
- cargo-expand:看宏展开
- cargo-bloat:分析二进制体积
- cargo-watch
- sccache
- vite-bundle-visualizer
- hyperfine:基准测试
附录 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 做成下一个现象级开源项目。