第 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] 的深水区。