
“如果一个软件不能离线用,那它根本不属于你。” 这句话,成了无数被网络坑过的开发者的共鸣。有位开发者曾因不稳定的Wi-Fi,丢失了3小时的工作成果,这种崩溃感,每一个常年和电脑打交道的人都懂——明明是自己辛苦做的内容,却因为没网,瞬间化为乌有。
为了摆脱“网络绑架”,该开发者先后试过Electron+localStorage的组合,本以为能轻松做出离线桌面端,结果却处处碰壁:软件打包后高达150MB,启动慢如蜗牛,本地存储还经常卡顿报错。就在其快要放弃时,Tauri+IndexedDB的组合,彻底改变了这一切,做出的离线桌面软仅3.8MB,冷启动不到400ms,没网也能流畅运行。
这一突破无疑解决了开发者的核心痛点,但让人深思的是:同样是做离线桌面端,为什么Electron频频掉链,而Tauri能实现质的飞跃?难道真的有“完美离线技术组合”吗?其实没有绝对的完美,只有适配的选择,而Tauri+IndexedDB的崛起,恰恰戳中了当下开发者对“轻量、安全、离线”的迫切需求。
本文核心用到的两大关键技术,均为开源免费,无需支付任何费用,适合个人开发者和中小企业直接上手:
1. Tauri:一款用于构建跨平台桌面应用的工具,基于Rust语言开发,开源免费,GitHub星数高达7.8万+。区别于Electron的“捆绑浏览器”模式,它采用系统自带WebView,大幅缩减软件体积,同时主打安全优先,自带硬化API边界,能有效防范各类安全风险。
2. IndexedDB:浏览器原生的客户端数据库,无需额外安装,开源免费,无GitHub星数统计(属于浏览器内置标准)。支持异步读写、大型结构化数据存储和索引查询,完美适配离线场景,既能单独用于网页端,也能配合Tauri实现桌面端本地存储。
该开发者最终做出的离线桌面App,无需后端服务器,支持本地加密存储、数据导出导入,适配Windows、Mac、Linux三大系统,无论是做 productivity工具、日记App,还是本地CRM,开发者都能直接复用这套框架。以下是完整实操步骤,代码可直接复制使用。
在搭建之前,该开发者先对比了4种热门技术组合,最终敲定Tauri+IndexedDB,核心对比和选择逻辑如下,帮大家避开选型坑:
1. 桌面端框架:Electron vs Tauri(弃用Electron)
Electron的优势是生态成熟、开发成本低,但缺点极为明显:每款软件都要捆绑Chromium浏览器,打包后体积高达150MB左右,启动慢、内存占用高,且安全性较差,不适合离线轻量场景。
Tauri则完美规避这些问题,核心优势的5点,也是该开发者选型的关键:
- 安全优先设计:运行时自带硬化API边界,有效防范恶意攻击,保护本地数据安全;
- 无捆绑浏览器:采用系统自带WebView,无需额外占用存储空间;
- 轻量 binaries:打包后体积仅3MB左右,是Electron的1/50;
- Rust原生集成:能精准控制文件访问和系统级权限,适配离线场景的本地操作;
- 无需持续联网:不依赖外部服务,离线状态下也能稳定运行。
2. 本地数据库:localStorage vs PouchDB vs SQLite vs IndexedDB(敲定IndexedDB)
离线App的核心是本地存储,该开发者测试了3种主流方案,均发现明显缺陷,最终选择IndexedDB:
- localStorage:同步运行,处理大量数据时会阻塞主线程,导致软件卡顿、崩溃;
- PouchDB:使用便捷,但需配合CouchDB同步,增加额外复杂度,不适合纯离线场景;
- SQLite(Tauri插件):功能强大,但脱离浏览器原生环境,无法复用网页端逻辑,开发成本高;
- IndexedDB:浏览器原生支持,核心优势4点,适配离线桌面端:
- 支持大型结构化数据集,满足各类App的存储需求;
- 异步读写,不阻塞主线程,保证软件流畅运行;
- 支持索引查询,快速调取本地数据,提升UI响应速度;
- 网页/桌面端逻辑复用,降低开发成本,一套代码多端适配。
这款离线桌面App的核心架构分为4层,无需后端服务器,所有操作均在本地完成,架构清晰,新手也能快速理解:
1. 顶层:Tauri Shell(Rust):负责桌面端原生交互,比如文件导出/导入、加密备份、系统权限控制;
2. 中间层:Secure IPC Bridge:安全的进程间通信桥梁,负责Rust(Tauri)和前端JS的通信,保证数据传输安全;
3. 前端层:Frontend UI(SvelteKit / React):负责用户交互、界面渲染,开发者可根据自己熟悉的框架选择;
4. 底层:Client-Side Data Layer(IndexedDB):负责本地数据存储、缓存,是离线运行的核心。
核心逻辑:前端收集用户数据,存储到IndexedDB中;Tauri负责处理本地文件操作和加密;无需后端,同步(如需)可手动操作或添加可选接口,完全脱离网络依赖。
以下代码均为该开发者实操可用版本,按模块拆分,配合注释,新手也能快速上手,重点实现“本地加密存储+离线启动+数据导出导入”三大核心功能。
# 1. 安装Tauri CLI(全局安装)
npm install -g @tauri-apps/cli
# 2. 初始化Tauri项目(选择SvelteKit/React均可,此处以React为例)
create-tauri-app my-offline-app --template react
# 3. 进入项目目录,安装依赖
cd my-offline-app
npm install
# 4. 配置Tauri(修改src-tauri/tauri.conf.json,开启本地文件访问权限)
{
"tauri": {
"allowlist": {
"fs": {
"all": true,
"readFile": true,
"writeFile": true,
"createDir": true
},
"secureStorage": {
"all": true
}
},
"bundle": {
"identifier": "com.offline.app",
"icon": ["icons/icon.png"]
}
}
}// src/utils/indexedDB.js
// 初始化IndexedDB,支持加密存储
import { encrypt, decrypt } from './crypto'; // 加密工具,下文提供
// 打开/创建数据库
function openDB() {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open('OfflineAppDB', 1);
// 数据库版本升级(首次创建/修改 schema 时触发)
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建存储对象(存储用户数据,加密存储)
if (!db.objectStoreNames.contains('userData')) {
db.createObjectStore('userData', { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
// 存储数据(加密后存储)
export async function saveData(data) {
const db = await openDB();
const transaction = db.transaction('userData', 'readwrite');
const store = transaction.objectStore('userData');
// 加密数据后存储
const encryptedData = await encrypt(JSON.stringify(data));
await store.add({ ...data, content: encryptedData });
db.close();
}
// 获取数据(解密后返回)
export async function getData(id) {
const db = await openDB();
const transaction = db.transaction('userData', 'readonly');
const store = transaction.objectStore('userData');
const request = store.get(id);
return new Promise((resolve) => {
request.onsuccess = async () => {
const data = request.result;
if (data) {
// 解密数据
const decryptedContent = await decrypt(data.content);
resolve({ ...data, content: JSON.parse(decryptedContent) });
} else {
resolve(null);
}
db.close();
};
});
}
// 删除数据
export async function deleteData(id) {
const db = await openDB();
const transaction = db.transaction('userData', 'readwrite');
const store = transaction.objectStore('userData');
await store.delete(id);
db.close();
}// src/utils/crypto.js
// 基于Web Crypto API实现数据加密/解密,配合Tauri secureStorage存储密钥
import { getSecureStorage, setSecureStorage } from '@tauri-apps/api/secureStorage';
// 生成/获取加密密钥(存储在Tauri secureStorage中,安全无泄露)
async function getKey() {
let key = await getSecureStorage('encryptionKey');
if (!key) {
// 生成随机密钥,存储到secureStorage
const cryptoKey = await window.crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
// 转换密钥为字符串,便于存储
const keyStr = btoa(String.fromCharCode(...new Uint8Array(await window.crypto.subtle.exportKey('raw', cryptoKey))));
await setSecureStorage('encryptionKey', keyStr);
key = keyStr;
}
// 解析密钥
const keyBytes = new Uint8Array(atob(key).split('').map(c => c.charCodeAt(0)));
return window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
}
// 加密数据
export async function encrypt(data) {
const key = await getKey();
const encoder = new TextEncoder();
const dataBytes = encoder.encode(data);
// 生成随机初始化向量
const iv = window.crypto.getRandomValues(new Uint8Array(12));
// 加密
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
dataBytes
);
// 拼接iv和加密后的数据,便于解密
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encrypted), iv.length);
return btoa(String.fromCharCode(...combined));
}
// 解密数据
export async function decrypt(encryptedData) {
const key = await getKey();
const decoder = new TextDecoder();
// 解析加密数据(拆分iv和加密内容)
const combinedBytes = new Uint8Array(atob(encryptedData).split('').map(c => c.charCodeAt(0)));
const iv = combinedBytes.slice(0, 12);
const encryptedBytes = combinedBytes.slice(12);
// 解密
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
encryptedBytes
);
return decoder.decode(decrypted);
}// src-tauri/src/main.rs(Rust代码,处理文件操作)
use tauri::{command, fs, AppHandle, PathBuf};
use zip::write::ZipWriter;
use std::fs::File;
// 导出数据(加密为zip文件)
#[command]
async fn export_data(app_handle: AppHandle, file_path: PathBuf, data: String) -> Result<(), String> {
// 创建zip文件
let file = File::create(&file_path).map_err(|e| e.to_string())?;
let mut zip = ZipWriter::new(file);
// 写入加密数据
zip.start_file("data.enc", zip::write::FileOptions::default()).map_err(|e| e.to_string())?;
zip.write_all(data.as_bytes()).map_err(|e| e.to_string())?;
zip.finish().map_err(|e| e.to_string())?;
Ok(())
}
// 导入数据(读取zip文件,解密)
#[command]
async fn import_data(file_path: PathBuf) -> Result {
// 读取zip文件
let file = File::open(&file_path).map_err(|e| e.to_string())?;
let mut zip = zip::read::ZipArchive::new(file).map_err(|e| e.to_string())?;
// 读取加密数据
let mut file = zip.by_name("data.enc").map_err(|e| e.to_string())?;
let mut data = String::new();
file.read_to_string(&mut data).map_err(|e| e.to_string())?;
Ok(data)
}
// 注册命令,供前端调用
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![export_data, import_data])
.run(tauri::generate_context!())
.expect("error while running tauri application");
} // src/App.jsx(React示例,可替换为SvelteKit)
import { useEffect, useState } from 'react';
import { saveData, getData, deleteData } from './utils/indexedDB';
import { invoke } from '@tauri-apps/api/tauri';
import { open, save } from '@tauri-apps/api/dialog';
function App() {
const [data, setData] = useState('');
const [savedData, setSavedData] = useState(null);
const [networkStatus, setNetworkStatus] = useState(navigator.onLine);
// 监听网络状态,实时反馈
useEffect(() => {
const handleNetworkChange = () => {
setNetworkStatus(navigator.onLine);
};
window.addEventListener('online', handleNetworkChange);
window.addEventListener('offline', handleNetworkChange);
// 离线启动:加载本地缓存数据
const loadLocalData = async () => {
const localData = await getData(1); // 读取id为1的数据,可根据需求修改
if (localData) {
setSavedData(localData);
}
};
loadLocalData();
return () => {
window.removeEventListener('online', handleNetworkChange);
window.removeEventListener('offline', handleNetworkChange);
};
}, []);
// 保存数据(离线可用)
const handleSave = async () => {
if (!data) return;
await saveData({ content: data, time: new Date().toLocaleString() });
setSavedData(await getData(1));
alert('保存成功!无网也能查看');
};
// 导出数据(加密zip)
const handleExport = async () => {
if (!savedData) return;
const filePath = await save({
title: '导出数据',
filters: [{ name: 'Zip Files', extensions: ['zip'] }]
});
if (filePath) {
await invoke('export_data', {
file_path: filePath,
data: JSON.stringify(savedData)
});
alert('导出成功!');
}
};
// 导入数据
const handleImport = async () => {
const filePath = await open({
filters: [{ name: 'Zip Files', extensions: ['zip'] }]
});
if (filePath) {
const importedData = await invoke('import_data', { file_path: filePath });
const parsedData = JSON.parse(importedData);
setSavedData(parsedData);
alert('导入成功!');
}
};
return (
setData(e.target.value)}
placeholder="输入内容,无网也能保存..."
style={{ width: '100%', height: '150px', marginTop: '10px' }}
/>
# 1. 开发环境运行(测试离线功能,可断开网络测试)
npm run tauri dev
# 2. 打包生成桌面端可执行文件(支持Windows/Mac/Linux)
npm run tauri build
# 打包完成后,文件路径:src-tauri/target/release/bundle
# Windows:.exe文件(3.8MB左右)
# Mac:.app文件
# Linux:.deb/.rpm文件搭建过程中,该开发者遇到了3个典型难题,均给出了可直接复用的解决方案,避免其他开发者重复踩坑:
1. 难题1:IndexedDB schema升级(修改存储结构后,旧数据丢失)
解决方案:抽象数据库层,实现自动迁移。在openDB函数中,通过数据库版本号控制schema升级,自动迁移旧存储、索引和数据,示例代码如下:
// 修改openDB函数,添加自动迁移逻辑
function openDB() {
return new Promise((resolve, reject) => {
// 版本号递增(修改schema时,将版本号+1)
const request = window.indexedDB.open('OfflineAppDB', 2);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;
const newVersion = event.newVersion;
// 版本1→2:添加新字段(示例:添加tag字段)
if (oldVersion< 2 && db.objectStoreNames.contains('userData')) {
const store = event.target.transaction.objectStore('userData');
// 为旧数据添加默认tag值
store.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
cursor.update({ ...cursor.value, tag: 'default' });
cursor.continue();
}
};
}
};
// 其余代码不变...
});
}2. 难题2:Rust与JS通信(权限控制不当,导致本地文件访问失败)
解决方案:使用Tauri的invoke() API实现通信,在tauri.conf.json中精准配置权限,仅开放必要的文件访问权限(前文初始化配置已包含),前端调用示例:
// 前端调用Rust的export_data命令
import { invoke } from '@tauri-apps/api/tauri';
async function exportData() {
try {
const filePath = await save({ title: '导出数据', filters: [{ name: 'Zip', extensions: ['zip'] }] });
if (filePath) {
await invoke('export_data', { file_path: filePath, data: '加密后的数据' });
}
} catch (e) {
console.error('导出失败:', e);
}
}3. 难题3:App重启后,无法恢复上次状态(离线场景核心需求)
解决方案:结合IndexedDB异步快照和Rust本地清单文件,保存用户上次操作状态。在App启动时,先读取Rust管理的manifest文件,获取上次打开的数据ID,再从IndexedDB中读取对应数据,实现状态无缝恢复:
// 前端启动时,恢复上次状态
useEffect(() => {
const restoreLastState = async () => {
// 1. 从Rust读取上次打开的数据ID
const lastDataId = await invoke('get_last_data_id');
// 2. 从IndexedDB读取对应数据
if (lastDataId) {
const data = await getData(lastDataId);
setSavedData(data);
}
};
restoreLastState();
}, []);不可否认,Tauri+IndexedDB的组合,在离线桌面App开发中实现了巨大突破——轻量、安全、离线可用,解决了Electron的诸多痛点,让个人开发者也能轻松做出高质量的桌面软。但它并非万能,有优势就有短板,辩证看待才能合理选型,避免开发者盲目跟风。
1. 优势:轻量高效,打包体积仅3.8MB,冷启动<400ms,内存占用30-60MB,低配置电脑也能流畅运行;短板:生态不如Electron成熟,部分复杂的桌面端功能(如系统级通知、全局快捷键)需要开发者额外开发,适配成本略高。这就意味着,如果开发者要做的是简单的离线工具(日记、任务管理),它完全够用;但如果是复杂的桌面应用(如视频编辑、IDE),Electron可能更适配。
2. 优势:IndexedDB浏览器原生支持,无需额外安装,异步读写不卡顿,适配离线场景;短板:学习成本高于localStorage,不支持SQL查询,复杂的数据关联查询需要开发者手动实现,对于新手不够友好。那么问题来了,新手开发者到底该选IndexedDB,还是退而求其次用localStorage?其实答案很简单:如果数据量小、逻辑简单,localStorage足够;如果需要存储大量结构化数据、追求流畅体验,IndexedDB才是最优解。
3. 优势:开源免费,无版权成本,适合个人和中小企业;短板:依赖Rust语言,前端开发者若不熟悉Rust,可能需要额外学习,增加开发周期。这也引发了一个思考:技术选型到底该优先“适配需求”,还是优先“自己熟悉”?其实两者并不矛盾,若需求是离线轻量,即便需要学习Rust,长期来看也能提升开发者的开发效率;若只是临时做一个简单工具,用自己熟悉的技术,快速落地才是关键。
很多开发者容易陷入“技术崇拜”,看到Tauri火就盲目弃用Electron,看到IndexedDB好用就否定所有其他存储方案。但实际上,技术本身没有优劣,关键在于是否适配开发者的需求。
如果开发者的项目符合以下3点,Tauri+IndexedDB就是最优解:① 主打离线功能,无需持续联网;② 追求轻量高效,反感臃肿的安装包;③ 注重数据安全,需要本地加密存储;④ 是中小型工具类App(非复杂桌面应用)。
如果开发者的项目需要复杂的桌面端交互、依赖成熟的生态插件,或者开发团队不熟悉Rust,那么Electron+SQLite的组合,可能更适合。毕竟,技术的核心是解决问题,而非盲目追求“最新、最火”。
该开发者用Tauri+IndexedDB搭建离线桌面App的实践,不仅解决了自身的痛点,更折射出当下桌面App开发的一个重要趋势——离线优先,正在取代“永远在线”,成为用户和开发者的共同需求。
在云服务普及的今天,用户习惯了“数据存在云端”,但这背后隐藏着两大风险:一是网络依赖,一旦没网,用户就无法访问自己的内容;二是数据安全,云端数据可能被泄露、篡改,甚至因为平台倒闭而丢失。
而离线优先的App,恰好解决了这两个问题:数据存储在本地,无网也能流畅使用,用户真正拥有数据的所有权;加上本地加密存储,即便电脑被他人使用,数据也不会泄露。这种“我的数据我做主”的体验,正是当下用户迫切需要的——就像用户存照片,既会备份到云端,也会保留本地原图,因为只有握在自己手里的,才最安心。
这也让开发者思考:在“万物互联”的时代,用户到底需要什么样的软件?是追求“永远在线”的便捷,还是“离线可用”的安心?其实两者并不冲突,离线优先、云端同步(可选),才是未来软件的核心形态——既保证离线时的可用性,也兼顾在线时的便捷性。
在Tauri出现之前,桌面端开发的门槛很高:开发者要么用Electron,接受它的臃肿;要么用原生开发(Java Swing、C#),学习成本高、跨平台困难,个人开发者很难独立完成。
而Tauri+IndexedDB的组合,彻底降低了离线桌面App的开发门槛:前端开发者只需掌握JS/TS,再简单学习一点Rust基础,就能快速搭建跨平台的离线桌面软;打包体积小、性能优,无需担心用户因为“安装包太大”而放弃使用;开源免费,无需支付任何版权费用,中小企业和个人开发者也能负担得起。
更重要的是,这种技术组合,让更多开发者能聚焦于“解决用户需求”,而非“攻克技术难题”。无论是做一款适合自己的 productivity工具,还是做一款面向大众的日记App、任务管理器,开发者都能快速落地,甚至能打造出差异化的产品——在当下“云端至上”的市场中,离线优先的产品,本身就具备独特的竞争力。
过去十年,行业经历了“云端至上”的时代,所有软件都追求“永远在线”,仿佛离线功能只是一个可有可无的补充。但随着用户对数据安全、网络依赖的不满越来越多,行业正在发生反转——本地优先,正在成为新的主流。
从Tauri的崛起,到IndexedDB的广泛应用;从Notion推出离线模式,到Obsidian主打本地存储,不难发现:用户想要的,从来不是“必须联网才能用”的软件,而是“无论有没有网,都能正常使用”的软件。离线优先,不再是一个“加分项”,而是一个“基础项”。
而Tauri+IndexedDB的组合,正是顺应这一趋势的产物——它不否定云端的价值,而是将“本地”放在首位,让软件回归“工具本质”:解决用户需求,不受网络限制,保护用户数据。
看到这里,相信很多开发者和用户都能产生共鸣——谁没被不稳定的网络坑过?谁没担心过云端数据的安全?该开发者用Tauri+IndexedDB做出的离线桌面App,不仅解决了自身的痛点,也为其他开发者提供了一种新的技术思路。
今天,我们就来聊聊离线桌面App那些事,欢迎在评论区留下你的观点,一起交流学习:
1. 你有没有过“因为没网,丢失重要工作/数据”的经历?当时是怎么解决的?
2. 开发桌面App时,你更倾向于用Tauri还是Electron?为什么?
3. 你平时常用哪些离线桌面工具?它们有哪些优点,哪些地方让你不满意?
4. 如果你用Tauri+IndexedDB开发离线App,会做一款什么类型的工具?日记、任务管理,还是其他?
另外,如果你正在学习Tauri或IndexedDB,遇到了选型、代码编写的问题,也可以在评论区留言,大家一起探讨解决方案,少走弯路!
最后提醒一句:文中所有代码均可直接复制使用,打包后的App仅3.8MB,离线可用,感兴趣的开发者可以动手试试,亲手打造一款属于自己的离线桌面神软~
更新时间:2026-02-25
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight All Rights Reserved.
Powered By 61893.com 闽ICP备11008920号
闽公网安备35020302034844号