把待办应用从Electron换成Tauri,内存占用狂降90%,打包体积仅5MB

凌晨两点,同事在群里丢了一张任务管理器截图:一个"待办清单"桌面应用,挂着 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 可以直接用 rusqlite crate ,无需经过 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");
}

注意 setupapp_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

相关推荐
假如让我当三天老蒯1 小时前
回归基本功!前端的解构赋值、扩展运算符、剩余参数
前端·面试
bonechips1 小时前
JS 数组指南:从内存原理到二维矩阵
前端·javascript
亿元程序员2 小时前
美术妹子让我给模型加个描边,我差点把Cocos卸了
前端
IT_陈寒2 小时前
React的useEffect依赖数组把我坑惨了,真相其实很简单
前端·人工智能·后端
徐小夕2 小时前
JitWord 3.0 正式发布,高精度Word异构解析+复杂组件兼容,打造web端协同Word编辑器
前端·vue.js·算法
恋猫de小郭2 小时前
KMP / CMP 鸿蒙版本 Beta 发布,他有什么特别之处?
android·前端·flutter
乘风gg3 小时前
OpenClaw 爆火,但”飞书"赢麻了!!!
前端·ai编程·claude