一、项目介绍
1.1 什么是 Actual Budget?
Actual Budget 是一款革命性的本地优先(Local-First) 个人财务管理应用。与传统的云同步理财工具不同,Actual 将用户数据的主权完全交还给用户:
- ✅ 100% 开源 - MIT License,代码完全透明
- ✅ 本地优先 - 数据首先存储在本地设备
- ✅ 隐私优先 - 可选同步,用户掌控数据
- ✅ 跨平台 - Web、Windows、Mac、Linux 全平台支持
1.2 为什么值得关注?
在 YNAB(You Need A Budget)等商业软件月费高昂、数据隐私堪忧的背景下,Actual Budget 提供了:
- 零成本 - 完全免费开源
- 零锁定 - 数据本地存储,随时导出
- 零妥协 - 功能不输商业软件
1.3 项目数据概览
| 指标 | 数据 |
|---|---|
| GitHub Stars | 18.5k+ ⭐ |
| 首次开源 | 2022年4月 |
| 总提交数 | 5,200+ commits |
| 活跃度 | 🔥 非常活跃(2026年6月仍在高频更新) |
| 技术栈 | TypeScript + React + Electron + Node.js |
| 架构模式 | Monorepo (Yarn Workspaces) |
二、核心原理
2.1 本地优先架构(Local-First Architecture)
2.1.1 什么是本地优先?
本地优先是一种应用架构理念,由 Ink & Switch 实验室提出,核心原则是:
"Your data should primarily live on your device, not in the cloud."
传统云应用 vs 本地优先应用对比:
┌─────────────────────────────────────────────────────────┐
│ 传统云应用架构 │
├─────────────────────────────────────────────────────────┤
│ 用户操作 → 发送到云端 → 云端处理 → 返回结果 │
│ ❌ 离线无法使用 │
│ ❌ 云端故障导致服务中断 │
│ ❌ 数据隐私依赖服务商 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 本地优先应用架构 │
├─────────────────────────────────────────────────────────┤
│ 用户操作 → 本地立即响应 → 后台同步到云端 │
│ ✅ 离线完全可用 │
│ ✅ 云端故障不影响本地使用 │
│ ✅ 用户掌控数据主权 │
└─────────────────────────────────────────────────────────┘
2.1.2 Actual 的本地优先实现
Actual 使用 SQLite 作为本地数据库,所有操作首先在本地完成,然后通过 CRDT 系统进行增量同步。
typescript
// 数据流向示例
用户界面操作
↓
loot-core 处理业务逻辑
↓
SQLite 本地存储 (立即完成)
↓
CRDT 生成同步消息 (后台)
↓
同步服务器 (可选)
2.2 CRDT:无冲突复制数据类型
2.2.1 CRDT 基本概念
CRDT (Conflict-free Replicated Data Type) 是一种数据结构,它保证:
- 并发安全性 - 多个节点可以同时修改数据
- 自动冲突解决 - 不需要中央协调器
- 最终一致性 - 所有节点最终会达到相同状态
2.2.2 Actual 的 CRDT 实现
Actual 使用基于消息的 CRDT,每次数据库修改都会生成一个带时间戳的消息,这些消息可以在不同设备间安全重放。
核心组件:
| 组件 | 作用 |
|---|---|
| Merkle Clock | 混合逻辑时钟,为每条消息分配全局有序的时间戳 |
| Protobuf | 消息序列化格式,高效压缩同步数据 |
| LWW (Last-Write-Wins) | 冲突解决策略,最后写入的值获胜 |
| Tombstone | 删除标记,确保删除操作能正确同步 |
三、核心公式与算法
3.1 Merkle Clock(混合逻辑时钟)
3.1.1 时间戳结构
Actual 使用 Merkle Clock 为每个操作分配时间戳,确保全局因果顺序。
时间戳组成:
Timestamp = (counter, nodeId)
其中:
counter- 逻辑计数器,单调递增nodeId- 节点唯一标识符(UUID)
3.1.2 时间戳比较算法
typescript
function compareTimestamp(ts1: Timestamp, ts2: Timestamp): number {
// 第一优先级:比较计数器
if (ts1.counter > ts2.counter) return 1;
if (ts1.counter < ts2.counter) return -1;
// 第二优先级:计数器相同时,比较节点ID(字典序)
if (ts1.nodeId > ts2.nodeId) return 1;
if (ts1.nodeId < ts2.nodeId) return -1;
// 完全相同
return 0;
}
因果一致性保证:
如果操作 A 在操作 B 之前发生(在同一个节点上),
那么 A 的时间戳一定小于 B 的时间戳。
形式化表达:
A → B (因果关系的 happens-before)
⟹ Timestamp(A) < Timestamp(B)
3.2 冲突解决:Last-Write-Wins (LWW)
3.2.1 LWW 算法
当多个设备同时修改同一字段时,使用 LWW 策略解决冲突:
typescript
function resolveConflict(
localValue: Value,
remoteValue: Value,
localTimestamp: Timestamp,
remoteTimestamp: Timestamp
): Value {
// 比较时间戳,较大的获胜
if (compareTimestamp(remoteTimestamp, localTimestamp) > 0) {
return remoteValue; // 远程值较新
} else {
return localValue; // 本地值较新或相等
}
}
3.2.2 字段级合并
Actual 的冲突解决是字段级别的,而不是行级别:
场景:两个设备同时修改同一笔交易的不同字段
设备A: 修改 amount = 50
设备B: 修改 notes = "Groceries"
结果: amount = 50, notes = "Groceries" (两个修改都保留)
3.3 增量同步算法
3.3.1 同步消息格式
每条 CRDT 消息包含:
protobuf
message CRDTMessage {
string table = 1; // 表名
string row = 2; // 行ID
string column = 3; // 列名
string value = 4; // 新值
Timestamp timestamp = 5; // 时间戳
bool isDeleted = 6; // 是否删除(tombstone)
}
3.3.2 增量同步公式
设:
- T_last = 上次同步的时间戳
- M_all = 所有消息集合
- M_new = {m ∈ M_all | m.timestamp > T_last}
同步过程:
1. 客户端上传 M_new (增量消息)
2. 服务器合并消息到全局状态
3. 服务器返回 M_server_new (客户端缺失的消息)
4. 客户端应用 M_server_new
四、架构图示
4.1 项目整体架构
┌─────────────────────────────────────────────────────────────┐
│ 用户界面层 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │desktop-client│ │desktop-electron│ │
│ │ (React UI) │ │ (Electron) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ └──────────┬─────────┘ │
│ ↓ │
│ ┌──────────────────┐ │
│ │component-library │ │
│ │ (共享组件库) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 业务逻辑层 │
│ ┌────────────────────────────────────────────┐ │
│ │ loot-core │ │
│ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │
│ │ │ client │ │ server │ │ shared │ │ │
│ │ │ (客户端) │ │ (服务端) │ │ (共享) │ │ │
│ │ └──────────┘ └──────────┘ └────────┘ │ │
│ │ ↓ ↓ │ │
│ │ 数据库操作 CRDT消息生成 │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 数据同步层 │
│ ┌────────────┐ ┌─────────────┐ │
│ │ crdt │◄──────►│ sync-server │ │
│ │ (冲突解决) │ │ (同步服务) │ │
│ └────────────┘ └─────────────┘ │
│ ↓ ↓ │
│ Protocol Buffer Express Server │
│ (消息序列化) (Node.js) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 数据存储层 │
│ ┌─────────────────────────────────┐ │
│ │ better-sqlite3 (SQLite) │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │messages │ │messages_clock│ │ │
│ │ │(CRDT消息) │ │ (时钟状态) │ │ │
│ │ └──────────┘ └──────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
4.2 CRDT 系统数据流
┌─────────────┐
│ 用户操作 │
│ (新增交易) │
└──────┬──────┘
│
↓
┌─────────────────────────────────────────┐
│ loot-core (业务逻辑) │
│ ┌─────────────────────────────────┐ │
│ │ 1. 生成 Merkle Clock 时间戳 │ │
│ │ 2. 创建 CRDT 消息 │ │
│ │ 3. 写入本地 SQLite │ │
│ │ 4. 序列化消息 (Protobuf) │ │
│ └─────────────────────────────────┘ │
└──────┬──────────────────────────────────┘
│
├─────────→ 本地立即完成 (用户体验)
│
↓
┌─────────────────────────────────────────┐
│ sync-server (后台同步) │
│ ┌─────────────────────────────────┐ │
│ │ 1. 接收增量消息 │ │
│ │ 2. 按时间戳排序 │ │
│ │ 3. LWW 冲突解决 │ │
│ │ 4. 合并到全局状态 │ │
│ └─────────────────────────────────┘ │
└──────┬──────────────────────────────────┘
│
↓
┌─────────────────────────────────────────┐
│ 其他设备拉取同步 │
│ ┌─────────────────────────────────┐ │
│ │ 1. 请求增量消息 (since T_last) │ │
│ │ 2. 接收并反序列化 │ │
│ │ 3. 应用 CRDT 消息到本地数据库 │ │
│ │ 4. 更新本地时钟状态 │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
4.3 Monorepo 包依赖关系
actual (根项目)
│
├─→ loot-core (核心逻辑)
│ ↑
├─→ desktop-client (Web UI) ◄─┐
│ │ │
│ ↓ │
├─→ desktop-electron (桌面包装) │
│ │
├─→ sync-server (同步服务器) ──┘ (依赖 @actual-app/web)
│
├─→ crdt (CRDT 引擎)
│
├─→ api (公开 API)
│
├─→ component-library (组件库)
│
├─→ plugins-service (插件服务)
│
├─→ eslint-plugin-actual (Lint 规则)
│
├─→ vite-plugin-peggy (构建插件)
│
├─→ cli (命令行工具)
│
├─→ ci-actions (CI 自动化)
│
└─→ docs (文档网站)
五、踩坑点与实战经验
5.1 CRDT 时钟重置问题
问题描述
当一个预算文件从一台设备转移到另一台设备时(例如导出后导入),如果不重置时钟,两台设备会使用相同的 nodeId,导致时间戳冲突。
踩坑场景
设备A: nodeId = "abc", counter = 100
↓ 导出预算文件
设备B: nodeId = "abc" (相同!), counter = 100 (相同!)
结果:
设备A 生成消息: timestamp = (101, "abc")
设备B 生成消息: timestamp = (101, "abc") ← 完全相同!
冲突:无法判断哪个消息更新
解决方案
Actual 使用 resetClock 标志来解决这个问题:
typescript
// 导出预算时设置 resetClock 标志
function exportBudget() {
const metadata = {
// ... 其他元数据
resetClock: true // ← 关键标志
};
return { data: budgetData, meta: metadata };
}
// 导入预算时检查标志
function loadBudget(data, meta) {
if (meta.resetClock) {
// 生成新的客户端ID
CRDT.resetClock(generateUUID());
}
// ... 加载数据
}
源码位置:
packages/loot-core/src/server/cloud-storage.ts:178-184packages/loot-core/src/server/budgetfiles/app.ts:563-577
5.2 Protobuf 生成的代码与 CSP 冲突
问题描述
使用 protoc 生成的 JavaScript 代码包含一段不兼容 CSP(Content Security Policy)的代码:
javascript
// 默认生成的代码
var global = (function() {
return this || window || global || self || Function('return this')();
})();
这段代码在启用 CSP 的浏览器环境中会报错。
解决方案
需要手动修改生成的代码:
javascript
// 修改后的代码
var global = globalThis;
自动化建议:
可以在构建脚本中添加自动替换逻辑:
javascript
// scripts/fix-protobuf-csp.js
import fs from 'fs';
import path from 'path';
const filePath = path.join(__dirname, '../src/generated/protobuf.js');
let content = fs.readFileSync(filePath, 'utf-8');
content = content.replace(
/var global = \(function\(\) \{[^}]+\}\)\.call\(null\);/,
'var global = globalThis;'
);
fs.writeFileSync(filePath, content);
源码位置 :packages/crdt/README.md
5.3 增量同步的消息丢失
问题描述
在长时间不同步的情况下,增量同步可能丢失消息,因为服务器可能清理了旧消息。
解决方案
Actual 实现了全量同步 和增量同步两种模式:
typescript
async function sync() {
const lastSyncTimestamp = await getLastSyncTimestamp();
if (lastSyncTimestamp === null) {
// 首次同步:全量同步
await initialFullSync();
} else {
// 后续同步:增量同步
await incrementalSync(lastSyncTimestamp);
}
}
async function incrementalSync(lastSyncTimestamp: number) {
// 获取自上次同步以来的新消息
const newMessages = await crdt.getMessagesSince(lastSyncTimestamp);
if (newMessages.length === 0) {
console.log('Already up to date');
return;
}
// 上传本地新消息
await uploadMessages(newMessages);
// 下载服务器端的新消息
const remoteMessages = await downloadMessages(lastSyncTimestamp);
// 应用远程消息
await applyMessages(remoteMessages);
}
源码位置 :packages/loot-core/src/server/cloud-storage.ts:341-363
5.4 缓存表的不同步问题
问题描述
kvcache 和 kvcache_key 表用于存储计算缓存(例如报表数据),如果同步这些缓存,会导致:
- 同步文件过大
- 新设备上的缓存可能损坏
- stale 的电子表格公式
解决方案
Actual 明确排除缓存表的同步:
typescript
// 同步时排除的表
const EXCLUDED_TABLES = ['kvcache', 'kvcache_key'];
function shouldSyncTable(tableName: string): boolean {
return !EXCLUDED_TABLES.includes(tableName);
}
源码位置 :packages/loot-core/src/server/cloud-storage.ts:165-170
5.5 React 组件的性能优化
问题描述
在大型预算文件(数千笔交易)的情况下,React 渲染性能会下降。
解决方案
Actual 使用了多种优化策略:
- 虚拟滚动(Virtual Scrolling)
typescript
// 只渲染可见区域的交易
import { useVirtualizer } from '@tanstack/react-virtual';
function TransactionList({ transactions }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: transactions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 每行高度
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<TransactionRow transaction={transactions[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
- React.memo 避免不必要的渲染
typescript
const TransactionRow = React.memo(function TransactionRow({ transaction }) {
// 只有 transaction 变化时才会重新渲染
return <div>{transaction.payee} - {transaction.amount}</div>;
});
六、源码拆解
6.1 loot-core:核心业务逻辑
6.1.1 目录结构
packages/loot-core/
├── src/
│ ├── client/ # 客户端逻辑
│ │ ├── app.js # 应用主逻辑
│ │ └── ...
│ ├── server/ # 服务端逻辑
│ │ ├── budgetfiles/ # 预算文件管理
│ │ │ └── app.ts # 核心应用类
│ │ ├── cloud-storage.ts # 云存储同步
│ │ └── ...
│ ├── shared/ # 共享工具函数
│ ├── types/ # TypeScript 类型定义
│ └── ...
├── migrations/ # 数据库迁移文件
└── package.json
6.1.2 核心类:App (app.ts)
typescript
// packages/loot-core/src/server/budgetfiles/app.ts
export class App {
// 加载预算文件
async loadBudget(filePath: string): Promise<void> {
// 1. 打开 SQLite 数据库
await this.openDatabase(filePath);
// 2. 加载 CRDT 时钟状态
await db.loadClock();
// 3. 检查是否需要重置时钟
const meta = await this.getMetadata();
if (meta.resetClock) {
CRDT.resetClock(generateUUID());
// 清除标志
await this.updateMetadata({ resetClock: false });
}
// 4. 应用待处理的 CRDT 消息
await this.applyPendingMessages();
}
// 保存预算变更
async saveTransaction(transaction: Transaction): Promise<void> {
// 1. 生成 CRDT 时间戳
const timestamp = CRDT.getClock().tick();
// 2. 写入本地数据库
await db.run(
'INSERT OR REPLACE INTO transactions (id, amount, payee, ...) VALUES (?, ?, ?, ...)',
[transaction.id, transaction.amount, transaction.payee, ...]
);
// 3. 生成 CRDT 消息
const message = CRDT.createMessage({
table: 'transactions',
row: transaction.id,
column: '*', // * 表示整行
value: JSON.stringify(transaction),
timestamp: timestamp,
isDeleted: false
});
// 4. 存储消息
await db.run(
'INSERT INTO messages (timestamp, content) VALUES (?, ?)',
[CRDT.serializeTimestamp(timestamp), CRDT.serializeMessage(message)]
);
}
}
源码位置 :packages/loot-core/src/server/budgetfiles/app.ts:561-577
6.2 crdt:CRDT 引擎
6.2.1 Merkle Clock 实现
typescript
// packages/crdt/src/merkle-clock.ts
export class MerkleClock {
private counter: number;
private nodeId: string;
constructor(nodeId: string, initialCounter: number = 0) {
this.nodeId = nodeId;
this.counter = initialCounter;
}
// 生成下一个时间戳
tick(): Timestamp {
this.counter++;
return {
counter: this.counter,
nodeId: this.nodeId
};
}
// 比较时间戳
static compare(a: Timestamp, b: Timestamp): number {
if (a.counter > b.counter) return 1;
if (a.counter < b.counter) return -1;
// 计数器相同,比较节点ID
if (a.nodeId > b.nodeId) return 1;
if (a.nodeId < b.nodeId) return -1;
return 0; // 完全相同
}
// 序列化时钟状态(用于持久化)
serialize(): string {
return JSON.stringify({
counter: this.counter,
nodeId: this.nodeId
});
}
// 反序列化时钟状态
static deserialize(data: string): MerkleClock {
const { counter, nodeId } = JSON.parse(data);
return new MerkleClock(nodeId, counter);
}
}
6.2.2 消息序列化 (Protobuf)
protobuf
// packages/crdt/proto/message.proto
syntax = "proto3";
package crdt;
message Timestamp {
uint64 counter = 1;
string node_id = 2;
}
message CRDTMessage {
string table = 1;
string row = 2;
string column = 3;
string value = 4;
Timestamp timestamp = 5;
bool is_deleted = 6;
}
message MessageBatch {
repeated CRDTMessage messages = 1;
}
typescript
// packages/crdt/src/serializer.ts
import { CRDTMessage, Timestamp } from './generated/protobuf';
export function serializeMessage(message: CRDTMessage): Uint8Array {
const protoMessage = CRDTMessage.create({
table: message.table,
row: message.row,
column: message.column,
value: message.value,
timestamp: {
counter: message.timestamp.counter,
nodeId: message.timestamp.nodeId
},
isDeleted: message.isDeleted
});
return CRDTMessage.encode(protoMessage).finish();
}
export function deserializeMessage(data: Uint8Array): CRDTMessage {
return CRDTMessage.decode(data);
}
源码位置 :packages/crdt/package.json, packages/crdt/README.md
6.3 sync-server:同步服务器
6.3.1 同步端点实现
typescript
// packages/sync-server/src/routes/sync.ts
import express from 'express';
import { CRDT } from '@actual-app/crdt';
const router = express.Router();
// 上传增量消息
router.post('/api/sync/upload', async (req, res) => {
const { budgetId, messages } = req.body;
// 1. 验证请求
if (!budgetId || !messages) {
return res.status(400).json({ error: 'Invalid request' });
}
// 2. 解码消息
const decodedMessages = messages.map((m: string) =>
CRDT.deserializeMessage(Buffer.from(m, 'base64'))
);
// 3. 按时间戳排序
decodedMessages.sort((a, b) =>
CRDT.compareTimestamps(a.timestamp, b.timestamp)
);
// 4. 合并到数据库
for (const message of decodedMessages) {
await applyMessage(budgetId, message);
}
res.json({ success: true });
});
// 下载增量消息
router.get('/api/sync/download', async (req, res) => {
const { budgetId, since } = req.query;
// 1. 查询自 since 以来的新消息
const messages = await getMessagesSince(budgetId, parseInt(since as string));
// 2. 序列化消息
const encodedMessages = messages.map(m =>
Buffer.from(CRDT.serializeMessage(m)).toString('base64')
);
res.json({
messages: encodedMessages,
timestamp: Date.now()
});
});
async function applyMessage(budgetId: string, message: CRDTMessage) {
// 1. 检查冲突(同一行同一列的新旧程度)
const existing = await db.get(
'SELECT timestamp FROM messages WHERE table = ? AND row = ? AND column = ?',
[message.table, message.row, message.column]
);
if (existing) {
const existingTimestamp = CRDT.deserializeTimestamp(existing.timestamp);
// 2. LWW 冲突解决
if (CRDT.compareTimestamps(message.timestamp, existingTimestamp) > 0) {
// 新消息较新,应用它
await db.run(
'UPDATE messages SET timestamp = ?, content = ? WHERE ...',
[CRDT.serializeTimestamp(message.timestamp), CRDT.serializeMessage(message)]
);
} else {
// 旧消息,忽略
console.log('Ignoring outdated message');
}
} else {
// 3. 无冲突,直接插入
await db.run(
'INSERT INTO messages (table, row, column, timestamp, content) VALUES (?, ?, ?, ?, ?)',
[message.table, message.row, message.column, CRDT.serializeTimestamp(message.timestamp), CRDT.serializeMessage(message)]
);
}
}
源码位置 :packages/sync-server/ (Note: 目前主要是 JavaScript,正在迁移到 TypeScript)
6.4 desktop-client:React 前端
6.4.1 组件结构
packages/desktop-client/
├── src/
│ ├── components/ # React 组件
│ │ ├── accounts/ # 账户相关组件
│ │ ├── budget/ # 预算相关组件
│ │ ├── reports/ # 报表组件
│ │ ├── transactions/# 交易组件
│ │ └── ...
│ ├── hooks/ # 自定义 React Hooks
│ ├── util/ # 工具函数
│ ├── App.tsx # 应用根组件
│ └── ...
├── e2e/ # 端到端测试
└── package.json
6.4.2 自定义 Hook 示例
typescript
// packages/desktop-client/src/hooks/useSync.ts
import { useState, useEffect, useCallback } from 'react';
import * as lootCore from '@actual-app/loot-core';
export function useSync() {
const [syncing, setSyncing] = useState(false);
const [lastSyncTimestamp, setLastSyncTimestamp] = useState<number | null>(null);
const [error, setError] = useState<Error | null>(null);
// 执行同步
const sync = useCallback(async () => {
setSyncing(true);
setError(null);
try {
// 1. 获取上次同步时间
const lastSync = await lootCore.getLastSyncTimestamp();
// 2. 上传本地新消息
const localMessages = await lootCore.getLocalMessages(lastSync);
if (localMessages.length > 0) {
await lootCore.uploadMessages(localMessages);
}
// 3. 下载远程新消息
const remoteMessages = await lootCore.downloadMessages(lastSync);
if (remoteMessages.length > 0) {
await lootCore.applyRemoteMessages(remoteMessages);
}
// 4. 更新同步时间
const now = Date.now();
await lootCore.setLastSyncTimestamp(now);
setLastSyncTimestamp(now);
} catch (err) {
setError(err as Error);
} finally {
setSyncing(false);
}
}, []);
// 自动同步(每7天)
useEffect(() => {
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
if (lastSyncTimestamp === null || (Date.now() - lastSyncTimestamp) > SEVEN_DAYS) {
sync();
}
// 定期检查
const interval = setInterval(() => {
if ((Date.now() - lastSyncTimestamp!) > SEVEN_DAYS) {
sync();
}
}, 60 * 60 * 1000); // 每小时检查一次
return () => clearInterval(interval);
}, [lastSyncTimestamp, sync]);
return {
syncing,
lastSyncTimestamp,
error,
sync
};
}
源码位置 :packages/desktop-client/src/hooks/
七、效果对比
7.1 Actual Budget vs YNAB (You Need A Budget)
| 对比维度 | Actual Budget | YNAB |
|---|---|---|
| 价格 | 🟢 完全免费开源 | 🔴 14.99/月 或 98.99/年 |
| 数据所有权 | 🟢 本地存储,用户完全掌控 | 🔴 云端存储,依赖服务商 |
| 离线使用 | 🟢 完全离线可用 | 🟡 有限离线功能 |
| 隐私 | 🟢 可选同步,隐私优先 | 🔴 所有数据上传云端 |
| 同步 | 🟢 自托管或 PikaPods 托管 | 🔴 强制云端同步 |
| 功能 | 🟢 信封预算法、报表、账户管理等 | 🟢 功能类似 |
| 开源 | 🟢 100% 开源 | 🔴 闭源 |
| 社区 | 🟢 活跃开源社区 | 🔴 商业支持 |
| 技术栈 | 🟢 现代技术栈 (TypeScript, React) | ❓ 未知 |
结论 :如果你重视数据隐私 和零成本,Actual Budget 是 YNAB 的最佳替代品。
7.2 本地优先 vs 传统云同步
| 对比维度 | 本地优先 (Actual) | 传统云同步 (YNAB) |
|---|---|---|
| 响应速度 | 🟢 本地即时响应 | 🔴 依赖网络延迟 |
| 离线能力 | 🟢 完全离线可用 | 🔴 功能受限 |
| 可靠性 | 🟢 本地始终可用 | 🔴 云端故障影响使用 |
| 数据隐私 | 🟢 用户掌控数据 | 🔴 依赖服务商信誉 |
| 同步复杂度 | 🔴 需要 CRDT 等复杂技术 | 🟢 相对简单 |
| 冲突解决 | 🔴 需要自动冲突解决 | 🟢 中央服务器协调 |
| 存储成本 | 🟢 本地存储免费 | 🔴 服务端存储成本 |
结论 :本地优先架构在用户体验 和隐私方面有巨大优势,但技术实现更复杂。
7.3 CRDT vs 传统冲突解决
| 对比维度 | CRDT (Actual) | 中央协调 (传统) | OT (Operational Transformation) |
|---|---|---|---|
| 冲突解决 | 🟢 自动无冲突 | 🟢 服务器协调 | 🟡 需要复杂算法 |
| 去中心化 | 🟢 完全去中心化 | 🔴 依赖中央服务器 | 🟡 通常需要中央服务器 |
| 实现复杂度 | 🔴 较高 | 🟢 低 | 🔴 非常高 |
| 性能 | 🟢 本地优先,快速 | 🔴 依赖网络 | 🟡 中等 |
| 最终一致性 | 🟢 保证 | 🟢 保证 | 🟢 保证 |
| 适用场景 | 分布式协同编辑 | 客户端-服务器 | 实时协同编辑 |
结论 :CRDT 非常适合分布式离线优先的应用场景,但实现复杂度较高。
八、总结与展望
8.1 技术亮点
- 本地优先架构 - 用户体验极佳,离线完全可用
- CRDT 同步引擎 - 自动冲突解决,无需用户干预
- Monorepo 工程化 - 代码复用率高,维护性好
- 现代技术栈 - TypeScript + React + Vite,开发体验好
- 活跃社区 - 2026年仍在高频更新,AI 辅助开发
8.2 适用场景
✅ 适合:
- 重视数据隐私的用户
- 需要离线使用的场景
- 开源软件爱好者
- 自托管解决方案
❌ 不适合:
- 需要实时多人协作(CRDT 是最终一致性)
- 不需要本地存储的纯 Web 应用
- 对 UI 定制要求极高的场景
8.3 学习价值
对于开发者,Actual Budget 是一个非常好的学习案例:
- 本地优先架构实践 - 如何在现实中实现本地优先
- CRDT 应用 - 如何在实际项目中使用 CRDT
- Monorepo 管理 - Yarn Workspaces 的最佳实践
- Electron 开发 - 如何构建跨平台桌面应用
- 开源项目管理 - 如何维护一个活跃的开源项目
8.4 参考文献
- Actual Budget 官方文档
- Local-First Software
- CRDT 原理介绍
- Merkle Clock 论文
- Protocol Buffers 官方文档
- Actual GitHub 仓库
附录:快速部署指南
A.1 Docker 部署(推荐)
bash
# 1. 拉取镜像
docker pull actualbudget/actual-server:latest
# 2. 运行容器
docker run -d \
--name actual-server \
-p 5006:5006 \
-v /path/to/data:/data \
-e TRUSTED_PROXIES=192.168.0.0/16 \
actualbudget/actual-server:latest
# 3. 访问应用
# 打开浏览器访问 http://localhost:5006
A.2 本地开发环境搭建
bash
# 1. 克隆仓库
git clone https://github.com/actualbudget/actual.git
cd actual
# 2. 安装依赖 (需要 Node.js >= 22.18.0)
corepack enable
yarn install
# 3. 启动开发服务器
yarn dev
# 4. 访问应用
# 桌面应用会自动打开,或访问 http://localhost:3000
A.3 PikaPods 一键部署
访问 PikaPods,点击 "Deploy" 按钮,无需任何配置即可部署 Actual Budget(约 $1.40/月)。