Tauri 的价值在「体积」——同一个应用,Electron 打出来 100MB+,Tauri 通常 5-10MB。这篇用一个本地 Markdown 笔记应用把 Tauri 的核心能力串起来:文件读写、IPC 命令、窗口状态、打包分发。
一、为什么这个选题适合练 Tauri
Markdown 笔记应用正好命中 Tauri 的三个核心场景:
- 需要读写本地文件——Web 做不到,必须走 Rust 侧
- 需要长期驻留——桌面应用形态,不是网页
- 需要小巧——用户不愿为一个笔记工具装 100MB
而且业务逻辑简单(读文件、写文件、列目录),可以把注意力放在 Tauri 的机制上。
二、脚手架与目录
npm create tauri-app@latest note-app
1. 选择:TypeScript + React + Vite
cd note-app
npm run tauri dev # 开发(热重载)
1. 目录结构
src/ # 前端(React)
src-tauri/
├── src/lib.rs # Rust 逻辑,命令写在这里
├── Cargo.toml
├── tauri.conf.json # 应用配置
└── capabilities/
└── default.json # 权限声明(v2 的核心)三、Rust 侧:三个命令
笔记应用只需要三个能力:列出笔记、读笔记、写笔记。
// src-tauri/src/lib.rs
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
// 笔记目录:放在应用数据目录下,跨平台自动适配
fn notes_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir()
.map_err(|e| e.to_string())?
.join("notes");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
#[derive(serde::Serialize)]
struct NoteMeta {
name: String,
modified: u64,
}
// 1. 列出所有笔记(按修改时间倒序)
#[tauri::command]
fn list_notes(app: tauri::AppHandle) -> Result<Vec<NoteMeta>, String> {
let dir = notes_dir(&app)?;
let mut out = Vec::new();
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
let e = entry.map_err(|e| e.to_string())?;
let path = e.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let modified = e.metadata().ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs()).unwrap_or(0);
out.push(NoteMeta {
name: path.file_stem().unwrap().to_string_lossy().into_owned(),
modified,
});
}
out.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(out)
}
// 2. 读笔记
#[tauri::command]
fn read_note(app: tauri::AppHandle, name: String) -> Result<String, String> {
let path = notes_dir(&app)?.join(format!("{name}.md"));
fs::read_to_string(path).map_err(|e| e.to_string())
}
// 3. 写笔记
#[tauri::command]
fn write_note(app: tauri::AppHandle, name: String, content: String) -> Result<(), String> {
let path = notes_dir(&app)?.join(format!("{name}.md"));
fs::write(path, content).map_err(|e| e.to_string())
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![list_notes, read_note, write_note])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}app_data_dir()是跨平台的:Linux 在~/.local/share/,macOS 在~/Library/Application Support/,Windows 在%APPDATA%,不用自己判断- 文件名要防路径穿越:上面代码里
name直接拼路径,如果前端传../../etc/passwd就危险了——生产必须校验(见下文「安全」一节) - 返回
Result<T, String>而非 panic:错误要能传到前端展示
四、配置与权限(v2 与 v1 最大的区别)
// src-tauri/tauri.conf.json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "note-app",
"identifier": "com.example.noteapp",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{ "title": "笔记", "width": 900, "height": 640, "minWidth": 500 }
]
},
"bundle": { "active": true, "targets": ["dmg", "msi", "appimage"] }
}// src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default" // 用到 dialog 插件就声明
]
}v2 的最大变化是权限模型:默认最小权限,每个插件的能力都要显式声明。报「not allowed」时先查 capabilities,不要先怀疑代码——这是 v2 迁移最常见的卡点。
五、前端调用
// src/App.tsx
import { invoke } from '@tauri-apps/api/core'
import { useEffect, useState } from 'react'
type NoteMeta = { name: string; modified: number }
export default function App() {
const [notes, setNotes] = useState<NoteMeta[]>([])
const [current, setCurrent] = useState('')
const [content, setContent] = useState('')
const refresh = () => invoke<NoteMeta[]>('list_notes').then(setNotes)
useEffect(() => { refresh() }, [])
const open = async (name: string) => {
setCurrent(name)
setContent(await invoke<string>('read_note', { name }))
}
const save = async () => {
if (!current) return
await invoke('write_note', { name: current, content })
refresh() // 保存后刷新列表(修改时间变了)
}
return (
<div className="app">
<aside>
{notes.map(n => (
<button key={n.name} onClick={() => open(n.name)}>{n.name}</button>
))}
</aside>
<main>
<input value={current} onChange={e => setCurrent(e.target.value)} placeholder="文件名" />
<textarea value={content} onChange={e => setContent(e.target.value)} />
<button onClick={save}>保存</button>
</main>
</div>
)
}参数名要注意大小写:Rust 的 snake_case 参数(如 note_name)在前端调用时用 camelCase(noteName)。
六、安全:路径穿越必须防
上面 write_note 直接把 name 拼进路径。如果前端被注入或用户手输 ../../../.ssh/authorized_keys,就能写到任意位置。修正:
fn safe_path(app: &tauri::AppHandle, name: &str) -> Result<PathBuf, String> {
// 1. 只允许字母数字与短横线
if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err("文件名非法".into());
}
// 2. 拼出路径后检查是否仍在 notes 目录内
let dir = notes_dir(app)?;
let path = dir.join(format!("{name}.md"));
let canon = path.canonicalize().unwrap_or(path.clone());
if !canon.starts_with(&dir) {
return Err("路径越界".into());
}
Ok(path)
}- 白名单字符比黑名单安全:只允许
a-z0-9-_,比禁止../更可靠 - canonicalize 后再比对前缀:防住「看起来在目录内、实际指向外面」的符号链接
- 这是所有本地文件应用的必修课,不是 Tauri 特有问题
七、打包与体积对比
npm run tauri build
1. 产物在 src-tauri/target/release/bundle/
1. Linux: .deb / .rpm / .AppImage
1. macOS: .dmg / .app
1. Windows: .msi / .exe| 方案 | 安装包体积 | 内存占用(空载) | 说明 |
|---|---|---|---|
| Electron(对照) | 80-120 MB | 150-250 MB | 捆绑 Chromium |
| Tauri(本项目) | 5-10 MB | 40-80 MB | 用系统 WebView |
体积差异的代价:Tauri 用系统的 WebView,所以同一个应用在不同系统的渲染效果可能有差异(Linux 的 WebKitGTK 常落后于 Chrome)。纯展示型应用没问题,重前端交互的要先测目标平台。
八、验收清单
| 检查项 | 怎么验 |
|---|---|
| 能列出已有 md 文件 | 在 app_data_dir 里手动放几个 .md,重启应用看是否出现 |
| 读写正常 | 编辑保存后,用系统文件管理器打开确认内容已写入 |
| 错误能提示 | 故意写只读文件,看前端是否弹出错误 |
| 路径穿越被拦 | 前端传 ../test,应报错而不是写入 |
| 打包产物能装 | 在另一台机器(或干净虚拟机)装一次 |
| 体积符合预期 | 看 bundle 目录里安装包大小 |
九、总结:Tauri 的三个认知
- 它是「Web 前端 + Rust 后端」的组合:所有系统能力都在 Rust 侧,通过命令暴露
- v2 的权限模型是硬约束:不声明就不允许,报错时先查 capabilities
- 体积优势来自「不捆绑 WebView」,代价是跨平台渲染一致性需要自己验证
- 本地文件应用必须防路径穿越:这是安全底线,不是可选项