Tauri + SvelteKit全栈桌面应用

Tauri用Rust替代Electron的Node.js后端,配合SvelteKit的轻量前端,可以构建体积小、性能好的桌面应用。本文从项目搭建到SQLite持久化再到打包发布,走完全流程。

为什么选Tauri + SvelteKit

Tauri vs Electron

  • 打包体积:Tauri应用通常3-10MB,Electron动辄100MB+
  • 内存占用:Tauri使用系统WebView,不内嵌Chromium
  • 后端语言:Rust vs Node.js,性能和安全性优势明显
  • 系统集成:Tauri的Rust后端可以直接调用系统API

SvelteKit vs React/Vue

  • 编译时框架,运行时几乎零开销
  • 原生支持SSG/SPA模式,适合Tauri
  • 写起来更简洁,模板语法直观
  • 内置路由、数据加载等

项目搭建

# 创建SvelteKit项目
npm create svelte@latest my-tauri-app
cd my-tauri-app
npm install

# 添加Tauri
npm install -D @tauri-apps/cli@latest
npx tauri init

tauri init会问几个问题:

  • App name: my-tauri-app
  • Window title: My App
  • Dev server URL: http://localhost:5173(SvelteKit默认端口)
  • Dev command: npm run dev
  • Build command: npm run build

生成的项目结构:

my-tauri-app/
├── src/              # SvelteKit前端
│   ├── routes/
│   │   ├── +layout.svelte
│   │   └── +page.svelte
│   └── lib/
├── src-tauri/        # Rust后端
│   ├── src/
│   │   └── main.rs
│   ├── Cargo.toml
│   └── tauri.conf.json
├── package.json
└── svelte.config.js

SvelteKit需要配置为SPA模式(Tauri不需要服务端渲染):

npm install -D @sveltejs/adapter-static
// svelte.config.js
import adapter from '@sveltejs/adapter-static';

export default {
    kit: {
        adapter: adapter({
            fallback: 'index.html'  // SPA fallback
        })
    }
};
<!-- src/noutes/+layout.ts -->
export const prerender = true;
export const ssr = false;

Rust命令:前后端通信

Tauri的核心通信机制是Command——前端通过invoke调用Rust函数。

定义Rust命令

// src-tauri/src/main.rs
use tauri::command;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Note {
    id: Option<i64>,
    title: String,
    content: String,
    created_at: String,
}

#[command]
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

#[command]
async fn create_note(title: String, content: String) -> Result<Note, String> {
    // 后面会接入SQLite
    Ok(Note {
        id: Some(1),
        title,
        content,
        created_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
    })
}

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            greet,
            create_note,
        ])
        .run(tauri::generate_context!())
        .expect("error running tauri application");
}

前端调用

<!-- src/noutes/+page.svelte -->
<script>
    import { invoke } from '@tauri-apps/api/core';

    let name = '';
    let greeting = '';
    let noteTitle = '';
    let noteContent = '';

    async function handleGreet() {
        greeting = await invoke('greet', { name });
    }

    async function handleCreateNote() {
        try {
            const note = await invoke('create_note', {
                title: noteTitle,
                content: noteContent
            });
            console.log('Created note:', note);
        } catch (err) {
            console.error('Failed to create note:', err);
        }
    }
</script>

<main>
    <input bind:value={name} placeholder="Enter a name" />
    <button on:click={handleGreet}>Greet</button>
    <p>{greeting}</p>

    <h2>New Note</h2>
    <input bind:value={noteTitle} placeholder="Title" />
    <textarea bind:value={noteContent} placeholder="Content"></textarea>
    <button on:click={handleCreateNote}>Save</button>
</main>

invoke是类型安全的异步调用。参数名必须和Rust函数参数名一致(nametitlecontent)。

SQLite持久化

桌面应用需要本地数据存储,SQLite是最佳选择。Tauri生态有官方的SQLite插件,但直接用rusqlite更灵活。

# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
chrono = "0.4"

数据库管理模块:

// src-tauri/src/db.rs
use rusqlite::{Connection, Result, params};
use std::sync::Mutex;
use crate::Note;

pub struct Database {
    conn: Mutex<Connection>,
}

impl Database {
    pub fn new(db_path: &str) -> Result<Self> {
        let conn = Connection::open(db_path)?;
        conn.execute_batch("
            CREATE TABLE IF NOT EXISTS notes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                content TEXT NOT NULL DEFAULT '',
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL
            );
            CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);
        ")?;
        Ok(Database { conn: Mutex::new(conn) })
    }

    pub fn create_note(&self, title: &str, content: &str) -> Result<Note> {
        let conn = self.conn.lock().unwrap();
        let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
        conn.execute(
            "INSERT INTO notes (title, content, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)",
            params![title, content, now],
        )?;
        let id = conn.last_insert_rowid();
        Ok(Note {
            id: Some(id),
            title: title.to_string(),
            content: content.to_string(),
            created_at: now,
        })
    }

    pub fn list_notes(&self) -> Result<Vec<Note>> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn.prepare(
            "SELECT id, title, content, created_at FROM notes ORDER BY created_at DESC"
        )?;
        let notes = stmt.query_map([], |row| {
            Ok(Note {
                id: Some(row.get(0)?),
                title: row.get(1)?,
                content: row.get(2)?,
                created_at: row.get(3)?,
            })
        })?.collect::<Result<Vec<_>>>()?;
        Ok(notes)
    }

    pub fn delete_note(&self, id: i64) -> Result<bool> {
        let conn = self.conn.lock().unwrap();
        let affected = conn.execute("DELETE FROM notes WHERE id = ?1", params![id])?;
        Ok(affected > 0)
    }
}

在Tauri中注册为State:

// src-tauri/src/main.rs
mod db;
use db::Database;
use tauri::State;

#[command]
async fn create_note(
    db: State<'_, Database>,
    title: String,
    content: String
) -> Result<Note, String> {
    db.create_note(&title, &content).map_err(|e| e.to_string())
}

#[command]
async fn list_notes(db: State<'_, Database>) -> Result<Vec<Note>, String> {
    db.list_notes().map_err(|e| e.to_string())
}

#[command]
async fn delete_note(db: State<'_, Database>, id: i64) -> Result<bool, String> {
    db.delete_note(id).map_err(|e| e.to_string())
}

fn main() {
    let db = Database::new("app_data.db").expect("Failed to init database");

    tauri::Builder::default()
        .manage(db)  // 注册为全局State
        .invoke_handler(tauri::generate_handler![
            create_note,
            list_notes,
            delete_note,
        ])
        .run(tauri::generate_context!())
        .expect("error running tauri application");
}

前端列表页:

<!-- src/noutes/+page.svelte -->
<script>
    import { invoke } from '@tauri-apps/api/core';
    import { onMount } from 'svelte';

    let notes = [];
    let title = '';
    let content = '';

    onMount(async () => {
        notes = await invoke('list_notes');
    });

    async function addNote() {
        if (!title.trim()) return;
        const note = await invoke('create_note', { title, content });
        notes = [note, ...notes];
        title = '';
        content = '';
    }

    async function removeNote(id) {
        await invoke('delete_note', { id });
        notes = notes.filter(n => n.id !== id);
    }
</script>

<div class="container">
    <h1>Notes</h1>
    <form on:submit|preventDefault={addNote}>
        <input bind:value={title} placeholder="Title" required />
        <textarea bind:value={content} placeholder="Write something..." />
        <button type="submit">Add Note</button>
    </form>

    <div class="notes-list">
        {#each notes as note (note.id)}
            <div class="note-card">
                <h3>{note.title}</h3>
                <p>{note.content}</p>
                <small>{note.created_at}</small>
                <button on:click={() => removeNote(note.id)}>Delete</button>
            </div>
        {/each}
    </div>
</div>

打包与分发

# 开发模式
npm run tauri dev

# 打包
npm run tauri build

打包产物在src-tauri/target/nelease/bundle/下:

  • Windows: .msi安装包和.exe(NSIS安装器)
  • macOS: .dmg.app
  • Linux: .deb.AppImage

打包配置在tauri.conf.json中:

{
    "bundle": {
        "active": true,
        "targets": "all",
        "identifier": "com.example.my-tauri-app",
        "icon": [
            "icons/32x32.png",
            "icons/128x128.png",
            "icons/icon.icns",
            "icons/icon.ico"
        ]
    }
}

最终的Windows安装包大约4MB——同样功能的Electron应用至少80MB。

小结

Tauri + SvelteKit是2025年构建桌面应用的一个很好的技术组合。Tauri提供了轻量、安全的桌面运行时,SvelteKit负责高效的UI层,Rust后端处理业务逻辑和数据存储。学习曲线主要在Rust——如果你还不熟悉Rust,可以先从简单的Command开始,逐步深入。生态在快速成熟中,官方插件覆盖了文件系统、HTTP请求、通知等常见需求。