凌晨两点,同事在群里丢了一张任务管理器截图:一个"待办清单"桌面应用,挂着 17 个任务卡片,内存占用 540MB。老板跟了一句:"咱这工具怎么比 IDE 还能吃?" 团队用的那个 Electron 版 Todo ,启动要 8 秒,切换标签页有明显延迟,三个月前就有人抱怨,这次被截图钉在了耻辱柱上。周末我决定用 Tauri + SQLite 重写一遍。结果跑完测试,内存压到 46MB,安装包从 118MB 缩到 4.7MB ------ 这个提升幅度,值得拿出来跟你唠一唠。
问题拆解
Electron 的痛,归根到底一句话:每个应用都是一个迷你 Chromium 浏览器 。哪怕你只想显示一行"今天啥也没干",它也要把完整的渲染进程、V8 引擎、Node.js 运行时全部拉起来。我们的 Todo 应用里嵌了一个带富文本编辑的列表,用 better-sqlite3 存数据,开发体验还不错,但性能账单全都转嫁给了用户:
- 内存:单个空 Electron 窗口吃掉 ~250MB,实际业务数据一加载,轻松破 500MB。
- 打包体积:即使只写了几千行 JS,打出来的包 100MB+ 起步,因为 Chromium 那套东西必须带上。
- 启动速度:冷启动要加载 Node 和渲染引擎,8 秒够用户把鼠标摔三次了。
Electron 那套"用 Web 技术写桌面端"确实香,但香是开发者的,苦是用户的。团队想过切到 CEF 或者直接写原生,但前者复杂度不低,后者维护两套代码成本太高。我们需要的是一条中间路线:保留前端生态,但把 "浏览器" 这层臃肿的运行时替换掉。
方案设计
第一个被毙掉的是 Flutter。不是因为不好,而是团队前端栈全是 React/TypeScript ,硬切 Dart 学习曲线太陡,迁移成本不可控。
第二个考虑过 Neutralinojs,它用系统 WebView ,体积确实小,但生态和社区活跃度还不够,碰到诡异 bug 时只能自己啃源码,风险高。
最终选 Tauri,理由很明确:
- 系统 WebView 替代 Chromium:Windows 用 Edge WebView2 ,macOS 用 WKWebView ,不用每个应用自带浏览器,打包体积直接从 100MB+ 级掉到 5MB 以内。
- Rust 后端 :所有核心逻辑跑在 Rust 侧,原生性能,内存分配精确可控。SQLite 可以直接用
rusqlitecrate ,无需经过 Node 那层 C++ 插件的序列化开销。 - IPC 极简 :前端通过
invoke调 Rust 命令,数据走 JSON ,没有渲染进程和主进程之间沉重的上下文隔离负担。 - 安全模型:Tauri 默认关闭 Node.js API,前端不能随意访问文件系统,安全边界比 Electron 清晰得多。
架构上,我们用 Tauri + React + SQLite 重写待办应用:前端只管 UI ,所有数据库读写、文件操作都封装成 Tauri 命令,在 Rust 侧执行。SQLite 数据库文件保存在 app data 目录,单文件零配置部署,完全满足离线使用场景。
核心实现
下面按步骤走完整流程,代码都是实际可跑的。
1. 创建 Tauri 项目并添加依赖
这段命令解决"从零搭架子"的问题,注意我们用的是 React + TypeScript 模板:
bash
npm create tauri-app@latest todo-tauri -- --template react-ts
cd todo-tauri
在 src-tauri/Cargo.toml 里加上 rusqlite (带 bundled 特性,把 SQLite 原库编译进去,免去系统依赖烦恼)和 serde (用于命令参数/返回值的序列化):
toml
[dependencies]
tauri = { version = "1.5", features = ["shell-open"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.30", features = ["bundled"] }
2. 初始化数据库连接,用 Mutex 护住连接池
这段代码解决"在 Rust 侧安全、线程安全地持有 SQLite 连接"的问题。rusqlite::Connection 不是 Send ,不能在多线程环境下直接塞给 Tauri 的命令。我用 std::sync::Mutex 包一层,再配合 tauri::State 管理。
src-tauri/src/main.rs:
rust
// 防止 Windows 上缺少 WebView2 时静默失败
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::State;
// 定义待办条目的数据结构,前后端共享
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Todo {
id: Option<i64>,
title: String,
completed: bool,
}
struct DbState(Mutex<Connection>);
#[tauri::command]
fn add_todo(state: State<DbState>, title: String) -> Result<Todo, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
conn.execute("INSERT INTO todos (title, completed) VALUES (?1, 0)", [&title])
.map_err(|e| e.to_string())?;
let id = conn.last_insert_rowid();
Ok(Todo {
id: Some(id),
title,
completed: false,
})
}
#[tauri::command]
fn get_todos(state: State<DbState>) -> Result<Vec<Todo>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, title, completed FROM todos ORDER BY id DESC")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(Todo {
id: Some(row.get(0)?),
title: row.get(1)?,
completed: row.get(2)?,
})
})
.map_err(|e| e.to_string())?;
let mut todos = Vec::new();
for row in rows {
todos.push(row.map_err(|e| e.to_string())?);
}
Ok(todos)
}
fn main() {
tauri::Builder::default()
.setup(|app| {
// 获取系统固定的 app data 路径,保证跨平台隔离
let app_dir = app
.path_resolver()
.app_data_dir()
.expect("failed to get app data dir");
std::fs::create_dir_all(&app_dir).expect("failed to create app data dir");
let db_path = app_dir.join("todos.db");
let conn = Connection::open(&db_path).expect("failed to open database");
// 表不存在则创建
conn.execute(
"CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
)",
[],
)
.expect("failed to create table");
// 将连接注入全局状态
app.manage(DbState(Mutex::new(conn)));
Ok(())
})
.invoke_handler(tauri::generate_handler![add_todo, get_todos])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
注意 setup 里 app_dir 的使用 ------ 这比写死相对路径靠谱得多,macOS 会落到 ~/Library/Application Support/...,Windows 在 AppData\Roaming,Linux 在 ~/.local/share,每个平台都有最佳归宿。
3. 前端调用 Rust 命令
前端代码只需要 invoke,不用操心 SQL 细节。
src/App.tsx 简化版:
tsx
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/tauri";
interface Todo {
id?: number;
title: string;
completed: boolean;
}
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [title, setTitle] = useState("");
const loadTodos = async () => {
const data = await invoke<Todo[]>("get_todos");
setTodos(data);
};
const addTodo = async () => {
if (!title.trim()) return;
await invoke("add_todo", { title });
setTitle("");
loadTodos();
};
useEffect(() => {
loadTodos();
}, []);
return (
<div>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
</div>
);
}
export default App;
前端逻辑很薄:输入标题点添加,调 Rust 命令插入数据库,刷新列表从 SQLite 拉数据。所有重活都在 Rust 侧,前端只管渲染。
踩坑记录
坑是开发过程中最值钱的东西,我挑两个典型的。
坑一:rusqlite::Connection 不能直接放 Tauri 命令里
现象:把 Connection 直接作为 State 的类型,编译报错 Connection 没有实现 Send。原因:SQLite 连接内部持有 *mut ffi::sqlite3 原始指针,跨线程传递不安全,Rust 直接给你拦下。解决方法:用 Mutex<Connection> 包裹,所有访问都走 lock(),虽然有点互斥开销,但写操作本就互斥,对桌面应用完全不是瓶颈。也可以用 r2d2 连接池,但单用户场景一个连接足矣,没必要引入额外依赖。
坑二:数据库文件路径在开发与生产环境不一致
现象:在 npm run tauri dev 时数据能正常读写,打包后安装到系统,启动后表不见了。原因:开发模式下 Tauri 的 app_data_dir 跟着项目目录走,生产环境则解析到系统标准路径,绝对不能用 ./todos.db 这种相对路径。上面 setup 里通过 app.path_resolver().app_data_dir() 动态获取,完美避免了这类问题。官方文档对 path_resolver 的说明很简略,这个坑我查了四五个 GitHub issue 才摸透。
效果验证
拿旧 Electron 版和新 Tauri 版做了一组对比,测试机 Windows 11 / 16GB 内存,打开同一个含 2000 条待办记录的数据库,取样三次取均值:
| 指标 | Electron 版 | Tauri 版 | 变化 |
|---|---|---|---|
| 冷启动时间 | 7.8 秒 | 1.1 秒 | 降低 86% |
| 空闲内存占用 | 312 MB | 42 MB | 降低 86% |
| 操作 100 条插入后内存峰值 | 540 MB | 56 MB | 降低 90% |
| 安装包体积 | 118 MB | 4.7 MB | 缩小 96% |
| CPU 持续占用(空闲) | 0.8% | 0.1% | 几乎消失 |
一个简单待办应用,Tauri 给出的体感几乎是瞬间启动,内存曲线平稳得让人感动。打包后丢给同事,对方第一反应是:"你是不是只发了一个快捷方式?"
可直接用的代码/工具
如果你也想快速起一个 Tauri + SQLite 的桌面应用,可以直接克隆我已经配好的模板,一条命令进入开发:
bash
git clone https://github.com/baofugege/tauri-sqlite-starter
cd tauri-sqlite-starter && npm install && npm run tauri dev
仓库里包含了上文所有代码、数据库迁移逻辑和跨平台打包配置,开箱即用。
#Tauri #Rust #SQLite #桌面开发 #性能优化
关于作者
一个常年和后端性能、桌面工具死磕的实战派开发者,相信好工具应该像瑞士军刀------小巧、高效、不废话。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... ------ 如果这篇文章帮你省下重写 Electron 的时间,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 桌面工具定制 / 技术咨询,找我可以 Telegram @baofugege。