v0.3.0
概述
v0.3.0 是一次全栈性能批次升级:照着 docs/plans/2026-05-22-performance-improvement-plan.md 三档 21 条全部落地,主线四件事:
- 前端首屏与渲染:bundle 从 227 KB 拆到 37 KB(vendor chunk 独立 + 首页换轻量 SVG),编辑录入页不再每键全树重渲染
- 后端 hot path:snapshot 构建 N+1 消除、FX 同步阻塞 30 s 改异步、分析端点加进程级缓存
- 桌面冷启动:daily snapshot 改 daemon 线程不再卡
/health,loading 页接真实 backend stage - 数据模型清理:新增
daily_totalsslim 表为未来轻量端点铺路,去掉无消费方的 event snapshot 双写让单次编辑后端 IO 减半
每条改动独立 commit 独立测试,共 22 个 commit。后端 98/101 通过、前端 76/76 全过、桌面 67/67 全过(3 个 fail 全部在 v0.2.0 baseline 同样 fail,与本次性能改动无关)。
主要变更
1. 前端首屏与 bundle 体积
index.js从 227 KB 缩到 37 KB(gzip 73 → 13):vite.config.ts拆出react-vendor(react + react-dom + react-router-dom + @tanstack/react-query)+icons-vendor(lucide-react)两个独立 chunk。业务代码迭代时 vendor 不变,用户增量下载只命中差量- 首屏 LCP 路径剥离 ~600 KB echarts:
OverviewPage趋势图换成新写的纯 SVGSparklineTrend(~120 行,含三条折线 / 端点圆点 / 渐变面 / 图例 / 起止日期),不再触发echarts-core(436 KB) +zrender(173 KB) +echarts-react(17 KB) 链下载。分析看板仍用完整 ECharts - ECharts 增量 setOption:去掉
notMerge: true(之前每次 option 引用变化都重建实例),6 个 chart 组件补全useMemo包buildXxxOption,ResizeObserver加requestAnimationFrame合批。切换 analytics 日期范围 / 滚动 / 勾选 / 输入不再触发图表抖动
2. React Query 缓存与 re-render
- 拆 holding mutate invalidate 范围:原一条编辑触发 8 个 query key 失效,分析 6 个重端点并发雪崩。缩到核心 2 个 key(
holdings.all()+analyticsDateBounds.all()),分析端点统一加staleTime: 30s,用户切回分析页才自然 stale 重 fetch;新增invalidateAllHoldingDependentQueries给 CSV import / bulk delete 等真正大批量场景 - 合并 settings 缓存 key:原 OverviewPage / AnalyticsPage / EntryPage 三处用
settings.scope('xxx')切成 3 份独立缓存,跨页切换重复 fetch;统一改settings.all()共享一份 - AnalyticsPage 加
placeholderData: keepPreviousData:5 个 date-range 分析 query 在日期切换时不再回退 Skeleton 闪屏,旧数据占位过渡到新数据 - EntryPage / EntryHoldingsTable 渲染重做:模块级
EMPTY_HOLDINGS/EMPTY_MEMBERS常量替代?? []稳定引用;buildGroups加useMemo;GroupBlock包React.memo;handler 全部useCallback锁;keyword 输入用useDeferredValue让 input 立即响应,过滤计算延后
3. 后端 hot path
- snapshot 构建消除 N+1:
_build_snapshot_payload原每条 holding 跑 3 次session.get(Category)= 40 条 holding 120 次单查;改一次SELECT ... WHERE id IN (...)批量预取,DB 往返 -90%+。被 trend / sankey / rebalance / currency-overview 与每次 holding 写路径反复调用 - FX
resolve_rate_for_pair同步阻塞 30 s 改异步:原汇率缺失时同步调refresh_rates()(httpx 5s × 3 retries × 2 providers = 最长 30 s 阻塞);改为「exact → 历史 fallback(is_estimated)+ 后台 daemon 线程 refresh → 真冷启动才同步」三段。录入新币种 P99 延迟从 30 s 降到 ~100 ms build_daily_series进程级缓存:trend / volatility / correlation 三端点同 cold load 反序列化 N 天 payload 3 次;现共享 LRU cache(max 32 entries),版本指纹由(MAX(snapshot_date), COUNT, MAX(holdings.updated_at))自动失效- currency-overview 改读 latest snapshot:跟 sankey / rebalance 一致,不再调
build_current_payload重新跑全表 SELECT + N+1 get_default_family+ tz + sankey member name 三处合并优化:前两者加Session.info级缓存(请求生命周期内复用),sankey member 改单条 IN(...) batch 替代 N 次get_scoped_member
4. 桌面冷启动与运行时
- daily snapshot 改 daemon 线程异步执行:原
bootstrap_runtime同步跑create_daily_snapshot,/health在快照写完前不返回 200,Electron loading 页要等这一步。改异步后桌面冷启动 -1 ~ -3 s - loading 页接真实 backend stage:原 1700 ms fake 轮播假进度,跟 backend ready 节奏脱钩。现在主进程通过
webContents.executeJavaScript推送真实文案(「正在等待本地服务就绪」/「正在加载主界面」),fake 仅作为兜底 - 更新下载进度节流 250 ms:原每个 chunk 都触发
state.jsonfsync + IPC 广播,100 MB 包对应 20-1600+ 次写盘;节流后写盘 -95%+,更新期间 UI 流畅 BackgroundScheduler1 worker + lazy 实例化:APScheduler 默认 10 worker,常驻 ~20 MB 内存白付;本项目只有两个 job 互不并发,限到 1 worker;模块 import 时不再立即实例化
5. 数据模型清理
- 新增
daily_totalsslim 表:alembic migration 创建 + 一次性回填存量snapshot_daily.payload_json.totals;create_daily_snapshot双写。snapshot_daily 仍是 holding 粒度真理源,新表给未来 totals-only 端点(如轻量净资产趋势)提供不必反序列化的快路径 - 去掉 holding mutate 时的 event snapshot 双写:grep 全仓库无任何前端 / 脚本消费
/api/v1/snapshots/events,是写而不读的纯历史记录。_refresh_snapshots改为只刷 daily snapshot;高价值事件(CSV import / settings 改 base_currency)仍由 import_service / settings_service 显式写。单条 holding mutate 后端 IO -50% - 新增 metadata-only summary 端点:
/api/v1/snapshots/events/summary与/snapshots/daily/summary不反序列化 payload_json(events 返 4 个 metadata 字段、daily 直接读daily_totals新表),相同 limit 下响应体积 -95%。旧端点保留向后兼容
6. 构建与打包
- PyInstaller bundle 瘦身:
backend/build_desktop.py加--exclude-module列表(watchfiles / tkinter / unittest / test / pytest / _pytest / setuptools),DMG 估计 -10 ~ -30 MB,冷启动 import -0.5 ~ -1.5 s
收益对照表(可验证)
| 指标 | Before | After | 验证点 |
|---|---|---|---|
Bundle index.js |
227 KB / 73 KB gzip | 37 KB / 13 KB gzip | M5 build 输出 |
| 首屏 LCP 必下载 | ~860 KB / ~230 KB gzip | ~250 KB / ~80 KB gzip | M6 OverviewPage 不再 import echarts |
| 单条 holding 编辑后端 IO | 2× JSON + 2× INSERT | 1× JSON + 1× INSERT | L3 _refresh_snapshots 去 event 写 |
| 单条 holding 编辑前端请求 | 8 个 query invalidate | 2 个(holdings + dateBounds) | Q2 拆 invalidate 范围 |
_build_snapshot_payload DB 往返 |
3N 次 | 1 次 IN 预取 | Q5 N+1 修复 |
| FX 新币种 P99 | 最长 ~30 s | ~100 ms | Q6 异步化 |
桌面冷启动 /health |
含 1-3 s 同步 daily snapshot | 不卡 | Q7 异步化 |
| 更新下载 IO/IPC | 每 chunk 1× fsync + 1× IPC | 每 250 ms ≤1 次 | Q8 节流 |
| Snapshots list 响应 | 几 MB(含全 payload) | summary 端点 -95% | L5 metadata-only |
| Scheduler 常驻 worker | 10 | 1 | M8 |
升级说明
- 存量用户:alembic migration 自动创建
daily_totals新表 + 回填存量数据;event snapshot 表保留不动,老数据完整可读;分类与 holdings schema 完全不变。无需手动操作 - 开发期:删
backend/data/app.db不再必要(migration 自动升级);如需重建,bootstrap仍会写入方案 D 树 - 桌面端:v0.2.0 用户启动后自动检测、后台静默下载,点击通知确认即可升级(流程跟 v0.2.0 一致,无需 Gatekeeper 二次授权)
构建产物
- macOS arm64:
HouseholdBalanceSheet-0.3.0-macos-arm64.dmg - macOS arm64:
HouseholdBalanceSheet-0.3.0-macos-arm64.zip
Overview
v0.3.0 is a full-stack performance batch: every one of the 21 items in docs/plans/2026-05-22-performance-improvement-plan.md lands across all three tiers (Quick Wins / Medium / Large). Four main threads:
- Front-end first-paint and render: the bundle's
index.jsdrops from 227 KB to 37 KB (vendor chunks split off, the home page replaces ECharts with a lightweight SVG), and the entry page no longer re-renders the whole tree on every keystroke - Backend hot paths: snapshot N+1 elimination, FX 30-second sync block turned async, analytics endpoints get a process-level cache
- Desktop cold start: the daily snapshot moves off the lifespan thread so
/healthno longer waits for it; the loading page now reflects real backend stages - Data-model cleanup: a new
daily_totalsslim table prepares for future lightweight endpoints, and the no-consumer event-snapshot dual-write is removed, halving the backend IO per holding mutation
Every item lands as its own commit with its own tests — 22 commits in total. Backend tests 98/101 pass, frontend 76/76, desktop 67/67 (the three failures all reproduce on v0.2.0 main and are unrelated to this batch).
Highlights
1. Front-end first-paint and bundle size
index.jsfrom 227 KB to 37 KB (gzip 73 → 13):vite.config.tscarves outreact-vendor(react + react-dom + react-router-dom + @tanstack/react-query) andicons-vendor(lucide-react) into independent chunks. On subsequent releases vendors stay cached and users only download the diff- First-paint LCP path sheds ~600 KB of ECharts:
OverviewPage's trend chart is replaced by a hand-rolledSparklineTrendSVG (~120 lines: three lines, endpoint dots, gradient area fill, legend, date range), no longer pulling inecharts-core(436 KB) +zrender(173 KB) +echarts-react(17 KB). The analytics dashboard still uses the full ECharts - ECharts switches to incremental
setOption: dropsnotMerge: true(which forced a fresh instance on every option reference change); the six chart components all gainuseMemoaroundbuildXxxOption; theResizeObservercallback is now batched throughrequestAnimationFrame. Switching analytics date ranges, scrolling, toggling checkboxes and typing no longer cause chart redraw jitter
2. React Query cache and re-render
- Shrink holding-mutation invalidate scope: each single-row edit used to invalidate 8 query keys, firing 6 heavy analytics endpoints in parallel. Now invalidates only the two essentials (
holdings.all()+analyticsDateBounds.all()); analytics queries share a 30 sstaleTimeso they re-fetch naturally on next mount. A newinvalidateAllHoldingDependentQueriescovers genuinely wholesale paths (CSV import, bulk delete) - Unify the
settingscache key: previously three pages usedsettings.scope('xxx')and split the same response into three caches, repeating the fetch on every page switch. Now all three sharesettings.all() - AnalyticsPage uses
placeholderData: keepPreviousData: the five date-range driven queries no longer fall back to Skeleton on every range change; the previous response stays visible until the new one arrives - EntryPage / EntryHoldingsTable render overhaul: module-level
EMPTY_HOLDINGS/EMPTY_MEMBERSconstants replace?? []for reference stability;buildGroupsis memoised;GroupBlockis wrapped inReact.memo; handlers areuseCallback-stable; keyword input flows throughuseDeferredValueso typing stays smooth
3. Backend hot paths
_build_snapshot_payloadN+1 fix: previously calledsession.get(Category)three times per holding — 40 holdings = 120 single-row lookups. Now oneSELECT ... WHERE id IN (...)batch prefetch, ~90% fewer DB round-trips. Called from trend / sankey / rebalance / currency-overview and every holding write- FX
resolve_rate_for_pairno longer blocks 30 s: previously when a rate was missing it synchronously calledrefresh_rates()(httpx 5 s × 3 retries × 2 providers = up to 30 s blocking). The lookup is now exact → historical fallback (withis_estimated=True) + fire-and-forget daemon-thread refresh → cold-start sync only. New-currency holding P99 latency drops from 30 s to ~100 ms build_daily_seriesprocess-level cache: trend / volatility / correlation all called it independently, each deserialising N daily payloads. Now a shared LRU cache (max 32 entries) keyed on(family_id, window, start, end), version-stamped with(MAX(snapshot_date), COUNT, MAX(holdings.updated_at))for automatic invalidation- currency-overview reads the latest snapshot: aligned with sankey / rebalance, no longer calls
build_current_payloadto rebuild from scratch get_default_family+ timezone + sankey member name consolidation: the first two now cache inSession.info(per-request lifetime); sankey member lookups collapse from Nget_scoped_membercalls into one IN-batched query
4. Desktop cold start and runtime
- Daily snapshot moved to a daemon thread: previously
bootstrap_runtimerancreate_daily_snapshotsynchronously, so/healthcouldn't return 200 until it finished and the Electron loading page waited. Async now drops 1-3 s off cold start - Loading page reflects real backend stages: the 1700 ms fake carousel was decoupled from actual backend readiness. The main process now pushes real copy via
webContents.executeJavaScript("waiting for local service to be ready" / "loading the main interface"); fake states are kept only as fallback - Update download progress throttled to 250 ms: every chunk used to trigger a
state.jsonfsync + IPC broadcast — for a 100 MB package that was 20-1600+ writes/broadcasts. Throttled now: ~95% fewer writes, UI stays smooth during updates BackgroundSchedulercapped to 1 worker + lazy instantiation: APScheduler defaults to 10 worker threads (~20 MB of resident memory the project never uses); the project only has two jobs and they don't run concurrently. The scheduler instance also no longer instantiates at module-import time
5. Data-model cleanup
- New
daily_totalsslim table: an alembic migration creates the table and back-fills from existingsnapshot_daily.payload_json.totals;create_daily_snapshotnow dual-writes. snapshot_daily remains the source of truth at holding granularity; the new table is a fast path for future totals-only endpoints (e.g. a lightweight net-asset sparkline) - Remove the event-snapshot dual-write on holding mutations: a repo-wide grep found no frontend or script consumers of
/api/v1/snapshots/events._refresh_snapshotsnow only refreshes the daily snapshot; high-value events (CSV import, settings base_currency change) still callcreate_event_snapshotexplicitly. Per-mutation backend IO drops ~50% - New metadata-only summary endpoints:
/api/v1/snapshots/events/summaryand/snapshots/daily/summaryskippayload_jsondeserialisation entirely (events project four metadata fields, daily reads the newdaily_totalstable). Response body shrinks ~95% at the samelimit; the old endpoints are retained for backwards compatibility
6. Build and packaging
- PyInstaller bundle trim:
backend/build_desktop.pyadds an--exclude-modulelist (watchfiles / tkinter / unittest / test / pytest / _pytest / setuptools). DMG estimated to drop 10-30 MB and cold-start imports 0.5-1.5 s
Before / after
| Metric | Before | After | Where to verify |
|---|---|---|---|
Bundle index.js |
227 KB / 73 KB gzip | 37 KB / 13 KB gzip | M5 build output |
| First-paint LCP path | ~860 KB / ~230 KB gzip | ~250 KB / ~80 KB gzip | M6, OverviewPage no longer imports echarts |
| Backend IO per holding edit | 2× JSON serialise + 2× INSERT | 1× / 1× | L3 _refresh_snapshots drops event write |
| Frontend requests per holding edit | 8 query invalidates | 2 (holdings + dateBounds) | Q2 scope shrink |
_build_snapshot_payload DB round-trips |
3N | 1 IN prefetch | Q5 N+1 fix |
| FX new-currency P99 | up to ~30 s | ~100 ms | Q6 async |
Desktop cold-start /health |
blocked 1-3 s by sync snapshot | unblocked | Q7 async |
| Update download IO/IPC | per-chunk fsync + IPC | ≤1 per 250 ms | Q8 throttle |
| Snapshots list response | multiple MB (full payloads) | summary endpoint -95% | L5 metadata-only |
| Scheduler resident workers | 10 | 1 | M8 |
Upgrade notes
- Existing users: an alembic migration automatically creates the new
daily_totalstable and back-fills existing data; the event-snapshot table is untouched and old data remains readable; categories and holdings schema are unchanged. No manual action needed - Dev environment: deleting
backend/data/app.dbis no longer required for upgrades — the migration handles it. If you do rebuild,bootstrapstill seeds the Scheme D taxonomy - Desktop: v0.2.0 users will auto-detect, background-download and prompt with a single confirm to upgrade (same flow as v0.2.0; no Gatekeeper re-authorisation needed)
Artifacts
- macOS arm64:
HouseholdBalanceSheet-0.3.0-macos-arm64.dmg - macOS arm64:
HouseholdBalanceSheet-0.3.0-macos-arm64.zip