ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Cherry Studio 缓存体系实战指南:useCache / useSharedCache / usePersistCache 与 CacheService 全分层使用手册

Cherry Studio 缓存体系实战指南:useCache / useSharedCache / usePersistCache 与 CacheService 全分层使用手册 Cherry Studio 缓存体系实战指南useCache / useSharedCache / usePersistCache 与 CacheService 全分层使用手册【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300 assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio本文是 Cherry Studio 缓存系统的使用手册围绕 cache-usage.md 展开系统讲解 Memory内存、Shared跨窗口共享、Persist持久化三层缓存的 React Hooks、Renderer/Main 双进程的直接 CacheService API、订阅机制、就绪状态与常见实战模式。读完本文你将掌握如何为组件选择正确的缓存层级、如何在多窗口与主进程之间安全地读写与观察缓存以及如何规避挂载竞争、TTL 失效、hook 冲突等隐蔽陷阱。背景三层缓存架构与定位在深入 API 之前先明确缓存的适用范围。根据 cache-overview.md 的定义Cache 只用于可以重新生成或丢失而不影响用户的数据——无需备份、无需跨设备同步、生命周期绑定在组件、窗口或应用会话上。用户设置请使用 Preference偏好系统业务数据请使用 DataApi数据 API。缓存分为以下层级层级作用域重启后存活权威方典型用途Memory单进程内否各进程本地计算结果、API 响应Shared所有渲染窗口 Main否Main中继 冲突收敛跨窗口 UI 状态PersistRenderer所有渲染窗口是localStorage各渲染进程最近使用项、非关键 UI 状态PersistMain仅主进程是JSON 文件Main可丢失的主进程状态需要特别强调的是Persist 存在两个相互独立的存储每个渲染进程持久化到各自的localStorage存储键为cs_cache_persist见 Renderer CacheService而 Main 进程持久化到独立的 JSON 文件{userData}/cache.json通过 Main 侧CacheService的getPersist/setPersist/hasPersist/deletePersist以及subscribePersistChange访问。两者永不共享数据——Main 无法读取渲染进程的 persist反之亦然。Main 仍会以 IPC 中继的方式转发渲染进程发起的CacheSyncMessage { type: persist }消息仅转发不存储。Main Persist 是最后的选择。它是最晚加入的层级专为一个窄需求而设计小体积、可丢失、主进程权威且确实无处安放的状态。选择之前应先排除更合适的系统用户设置属于 Preference跨窗口或渲染进程拥有的 UI 状态属于 Shared / 渲染进程 Persist业务数据属于 DataApi。绝大多数情况下上述系统才是正确答案。完整决策指南见 data/README.md。键的类型体系Fixed / Template / Casual三层缓存的键全部由 schema 驱动定义在 cacheSchemas.ts类型示例 schema调用方式适用层级Fixedchat.web_search.searching: booleanget(chat.web_search.searching)Memory / Shared / PersistTemplatescroll.position.${topicId}: numberget(scroll.position.t42)Memory / SharedCasual无仅类型参数getCasualT(my.dynamic.key)仅 MemoryTemplate 键的所有实例共享同一个默认值——例如所有web_search.provider.last_used_key.*实例都回退到见 cacheSchemas.ts 与 cacheSchemas.ts。Casual 键在编译期会被禁止匹配任何 schema 模式UseCacheCasualKey类型定义于 cacheSchemas.ts这是有意为之的约束。Template 占位符的匹配规则定义在 templateKey.tsisTemplateKey通过检测${与}判断是否为模板键templateToRegex将每个${variable}占位符展开为([\w\-])即动态段只允许 ASCII 单词字符加连字符点号、冒号与非 ASCII 字符一律拒绝src/shared/data/cache/templateKey.ts:35-46。同时模板占位符的变量名在运行时是匿名的——${providerId}与${foo}匹配完全相同的具体键。React Hooks组件内使用缓存React Hooks 从data/hooks/useCache导入实现位于 useCache.ts是渲染进程组件消费缓存的首选入口Hook层级签名useCacheMemory(key: UseCacheKey, initValue?: V) [V, (next: V \| ((prev) V)) void]useSharedCacheShared(key: SharedCacheKey, initValue?: V) [V, (next: V \| ((prev) V)) void]useSharedCacheValueShared(key: SharedCacheKey) V \| undefined—— 只读观察者useSharedCacheSelectorShared(keys: SharedCacheKey[], selector: (values) S, isEqual?) S—— 多键只读聚合usePersistCachePersist(key: RendererPersistCacheKey) [V, (next: V \| ((prev) V)) void]Hook 的核心语义值类型由 schema 推断以useCache(scroll.position.topic123)为例TypeScript 会自动推断出number对应 schemascroll.position.${topicId}: number无需手动标注泛型。可写 Hook 会钉住缓存条目refcountedHook 挂载时调用cacheService.registerHook(key)卸载时调用unregisterHook(key)见 useCache.ts。只要任一 Hook 处于挂载状态delete/deleteShared就会返回false拒绝删除见 Renderer CacheService。useSharedCacheValue不钉住、不写默认值它只订阅并读取物理镜像因此拥有者的删除操作总能穿透观察者同时它不接受initValue物理缺失时返回undefined。Hook 不接受 TTL 参数在可写 Hook 下使用 TTL 会打印警告并被视为不良实践见下文设计不变量 #4。对应实现中useCache与useSharedCache挂载时都会检查hasTTL(key)/hasSharedTTL(key)并输出logger.warnuseCache.ts。依据写入来源选择 Shared Hook如果本窗口负责写这个键使用useSharedCache如果键由其他进程拥有通常是 Main 服务通过setShared发布而本窗口只负责展示使用useSharedCacheValue。原因在于挂载可写 Hook 会把 schema 默认值回写进缓存并广播出去初始化 effect 中的逻辑见 useCache.ts。在拥有者尚未发布的挂载竞争窗口期内这会物化默认值并广播到所有窗口从而覆盖拥有者的真实值。使用?? fallback时fallback 必须是引用稳定的默认值模块级常量或无条件求值的useMemo。永远不要把 Hook 调用放在??右侧——那是条件式 Hook 调用违反 React 规则。只写不读的调用点直接调用 cacheService如果组件只写某个键、从不读取应直接调用cacheService.setPersist(key, ...)等命令式 API而不是const [, setX] usePersistCache(key)。原因是后者仍会注册useSyncExternalStore订阅每次对该键的写入都会触发一个从不读取它的组件重渲染。函数式 updater 正是让弃用 Hook变得安全的关键——它在写入时解析prev不需要渲染期快照。判定一个调用点是否为只写要看数据流而非解构形式只有当写入的值从不来源于该键的渲染值时才算只写由其他状态重新计算出的具体值同样算。函数式 updater(prev) nextsetter 接受具体值或函数式 updater(prev) next与 React 的useState一致。updater 在写入时针对最新存储值解析而非渲染期快照因此跨await的读-改-写依然正确——只要下一个值派生自当前值就优先使用它。需要严格遵守的纪律详见 CacheService.ts 中CacheSetStateAction的文档prev是浅层只读的容器类型对象/数组会变为ReadonlyT直接修改prev会编译报错基本类型原样通过因此prev !prev、prev prev 1正常工作。updater 必须纯净并返回新值原地修改prev再返回同一引用会被isEqual短路判定为值未变静默跳过重渲染对应设计不变量 #1。updater 必须无副作用不要从 updater 中把派生值走私出去例如写入外层变量来驱动写后工作也不要依赖它被调用的次数或时机。要响应什么变了例如为被移除的条目释放资源应在监听该值的useEffect中派生。useSharedCache的 updater 只针对本窗口的本地值解析不具备跨窗口原子性并发写入仍然是 last-write-wins。完整代码示例import { useCache, useSharedCache, useSharedCacheValue, usePersistCache } from data/hooks/useCache // Memory —— 单个渲染进程 const [generating, setGenerating] useCache(chat.web_search.searching, false) // Shared —— 所有窗口 const [activeSearches, setActive] useSharedCache(chat.web_search.active_searches) // Shared主进程拥有 —— 只读观察 引用稳定的 fallback const EMPTY_JOB_PROGRESS: JobProgress { progress: 0 } const progress useSharedCacheValue(jobs.progress.${jobId}) ?? EMPTY_JOB_PROGRESS // Persist —— 通过 localStorage 跨重启存活 const [pinned, setPinned] usePersistCache(ui.tab.pinned_tabs) // Template 键schema: scroll.position.${topicId}: number const [scrollPos, setScrollPos] useCache(scroll.position.${topicId})上述键chat.web_search.searching、chat.web_search.active_searches、jobs.progress.${jobId}、ui.tab.pinned_tabs等均可在 cacheSchemas.ts 的DefaultUseCache、DefaultSharedCache与DefaultRendererPersistCache中找到对应 schema 定义与默认值。CacheService 直接使用渲染进程在非 Hook 场景事件处理器、命令式一次性读取、只写调用点下直接使用单例cacheServiceimport { cacheService } from data/CacheServiceMemory 层级// Schema 键Fixed 或 Template—— 类型自动推断 cacheService.set(chat.web_search.searching, true) cacheService.set(chat.web_search.searching, true, 30_000) // 带 TTL毫秒 cacheService.get(chat.web_search.searching) // boolean cacheService.has(chat.web_search.searching) cacheService.hasTTL(chat.web_search.searching) cacheService.delete(chat.web_search.searching) // Casual仅 Memory 层级不允许匹配 schema cacheService.setCasualTopicCache(topic:${id}, data, 30_000) cacheService.getCasualTopicCache(topic:${id}) cacheService.hasCasual(topic:${id}) cacheService.hasTTLCasual(topic:${id}) cacheService.deleteCasual(topic:${id})关于get与 TTL 的实现细节见 Renderer CacheServiceMemory 的 TTL 采用惰性清理——getInternal在读取时检查entry.expireAt Date.now() entry.expireAt命中过期则删除条目并通知订阅者返回undefined。因此get返回undefined有两种情况键不存在或 TTL 已过期。需要始终有值的 UI 请使用useCacheHook它会自动回退默认值。set同样做了同值短路用isEquales-toolkit/compat比较新旧值内容相等则仅当 TTL 变化时更新expireAt否则完全跳过通知CacheService.ts。Shared 层级// Fixed 键 cacheService.setShared(chat.web_search.active_searches, map) cacheService.getShared(chat.web_search.active_searches) // Template 键schema: web_search.provider.last_used_key.${providerId}: string const k web_search.provider.last_used_key.${providerId} as const cacheService.setShared(k, api-key-id-1) cacheService.getShared(k) cacheService.hasShared(k) cacheService.hasSharedTTL(k) cacheService.deleteShared(k)观察 vs 一次性读取的选择要响应式观察共享值用 Hook要做命令式一次性读取用 TTL 感知的getShared。没有面向业务代码的TTL 盲读消费者 API——Hook 内部的快照读取器getSharedSnapshot不是给业务代码用的。从实现看getSharedSnapshot是纯物理读取不评估 TTL、不修改存储、不通知不广播专为useSyncExternalStore的getSnapshot契约设计CacheService.ts。初次同步前的行为在 Main 的初始同步完成之前getShared()返回undefined。同步前的写入会先本地生效并广播同步时刻由 Main 优先级覆盖见下文Shared Cache Ready State。syncSharedCacheFromMain的实现确实如此同步时跳过sharedKeysUpdatedDuringInitialSync集合中的键即本窗口在同步完成前已更新的键其余一律采用 Main 的值Main-priority overrideCacheService.ts。Persist 层级cacheService.setPersist(ui.sidebar.width, 300) // Updater 形式 —— prev 是写入时解析的最新持久化值。 // 这正是只写调用点不订阅也能正确写入的方式。 cacheService.setPersist(ui.tab.pinned_tabs, (prev) [tab, ...prev.filter((t) t.id ! tab.id)].slice(0, 10)) cacheService.getPersist(ui.sidebar.width) cacheService.hasPersist(ui.sidebar.width) // 表示已被覆盖而非已存储 —— 每个键都被播种 cacheService.deletePersist(ui.sidebar.width) // 重置为 schema 默认值键是固定的永不删除Persist 的语义需要特别注意Persist 存在即被覆盖而非已存储。loadPersistCache会先把所有 schema 键播种为默认值CacheService.ts因此getPersist永远返回存储值或默认值绝不返回undefined。hasPersist报告的是有效值与默认值是否不同即是否被覆盖过deletePersist实现为把键重置回默认值CacheService.ts。对应设计不变量 #6。写入防抖 350ms 并在beforeunload时冲刷schedulePersistSave使用PERSIST_SAVE_DEBOUNCE_MS 350的防抖定时器beforeunload事件处理器确保脏数据在退出前落盘CacheService.ts。localStorage 单 origin 约 5MB 上限——请保持 Persist 值小而精。实现中还有一个内部预警序列化后的 JSON 超过约 2MB 时savePersistCache会打印警告日志CacheService.ts。Main 进程使用主进程通过依赖注入获取单例import { application } from application const cacheService application.get(CacheService)Main 进程不暴露 casual 方法。Main 拥有独立的 persist 存储——即{userData}/cache.json通过getPersist/setPersist/hasPersist访问与渲染进程的localStoragepersist 完全独立、永不共享。渲染进程发起的 persist 同步仅作为 IPC 中继流经 Main。内部缓存与 Shared 访问// 内部缓存仅 Main自由字符串键 cacheService.set(myService.scratch, value, 30_000) cacheService.getMyType(myService.scratch) // Shared 缓存schema 类型化以 Main 为权威 cacheService.setShared(chat.web_search.active_searches, map) cacheService.getShared(chat.web_search.active_searches) cacheService.hasShared(chat.web_search.active_searches)订阅变更Main 侧提供模板感知的订阅 API。订阅返回的取消函数应包进this.registerDisposable(...)以便 teardown 自动完成// 精确键内部缓存 this.registerDisposable( cacheService.subscribeChangenumber(myService.counter, (newValue, oldValue) { logger.info(counter changed, { oldValue, newValue }) }) ) // 精确键Shared 缓存 this.registerDisposable( cacheService.subscribeSharedChange(chat.web_search.active_searches, (newValue, oldValue) { // 响应来自任何窗口以及 Main 自身的写入 }) ) // Template 键 —— 对每个匹配的具体实例都触发 const tpl web_search.provider.last_used_key.${providerId} as const this.registerDisposable( cacheService.subscribeSharedChange(tpl, (newValue, oldValue, concreteKey) { const providerId concreteKey.split(.).pop()! logger.info(provider ${providerId} rotated, { from: oldValue, to: newValue }) }) )订阅触发语义完整的触发语义、重入规则与占位符/字符集契约见 cache-overview.md 的设计不变量一节。要点如下只在显式set/delete/setShared/deleteShared以及经 IPC 中继的渲染进程写入时触发。从源码看Main 的subscribeChange/subscribeSharedChange内部用isEqual守卫同值写入不通知见 Main CacheService 与 Main CacheService。订阅后不会立即触发——初始状态请自己调用get()/getShared()。同值写入被抑制isEqual来自 es-toolkit/compat。回调错误会被捕获其他订阅者仍然照常触发重入安全见设计不变量 #9。Shared Cache Ready State新窗口启动后Shared 缓存的初始状态来自 Main 的getAllShared()同步。在同步完成前需要判断或等待就绪状态if (cacheService.isSharedCacheReady()) { // Main 的初始同步已完成 } const unsubscribe cacheService.onSharedCacheReady(() { // 已就绪则立即触发否则在同步完成后触发一次 })HookuseSharedCache在就绪前也能正常工作——它们先返回本地initValue/ schema 默认值待 Main 的状态到达后更新。从实现看markSharedCacheReady会置位标志并清空回调队列CacheService.ts同步过程中还有专门的同步前已更新键保护集合避免覆盖本窗口的抢先写入。缓存统计调试cacheService.getStats() // 汇总条目数、TTL 状态、hook 引用数、估算字节数 cacheService.getStats(true) // 每个层级的逐条目详情getStats的实现会逐层memory / shared / persist统计totalCount、validCount、expiredCount、withTTLCount、hookReferences与estimatedBytes汇总结果还包含人类可读的estimatedSize如 1.23 KBincludeDetails为true时每个条目会给出hasValue、hasTTL、isExpired、expireAt、remainingTTL、hookCount字段CacheService.ts。这是排查为什么这个键还在/没了/占用多少的第一利器。常见实战模式1. 缓存昂贵计算function useExpensiveData(input: string) { const [cached, setCached] useCache(entity.cache.input_${input}) useEffect(() { if (!cached.loaded) setCached({ loaded: true, data: expensiveCompute(input) }) }, [input, cached, setCached]) return cached.data }注意这里entity.cache.input_${input}属于 Template 模式键按输入参数自然分片每个输入各有一份缓存。2. 跨窗口协同// 窗口 A —— 函数式 updater 基于本窗口最新本地值派生 const [active, setActive] useSharedCache(chat.web_search.active_searches) setActive((prev) ({ ...prev, [searchId]: state })) // 窗口 B 在下一次 Main 中继时自动重渲染。它只展示值 // 因此只读观察 —— 不播种默认值不钉住键。 const EMPTY_SEARCHES: ActiveSearches {} // 模块级引用稳定的 fallback const active useSharedCacheValue(chat.web_search.active_searches) ?? EMPTY_SEARCHES3. 观察主进程拥有的键只读Main 发布本窗口只展示。可写 Hook 会把 schema 默认值回写挂载竞争期间覆盖拥有者并钉住键——请使用只读观察者 引用稳定的本地 fallbackconst EMPTY_JOB_PROGRESS: JobProgress { progress: 0 } function useJobProgress(jobId: string): JobProgress { return useSharedCacheValue(jobs.progress.${jobId} as const) ?? EMPTY_JOB_PROGRESS } // fallback 依赖 props先无条件求值 Hook再 ?? const cached useSharedCacheValue(key) const fallback useMemo(() getDefaultStatus(isActive), [isActive]) return cached ?? fallback // 绝不能cached ?? useMemo(...) —— 条件式 Hook 调用4. 聚合多个主进程拥有的键只读选择器动态数量的键无法用逐个 Hook 观察React 规则禁止在循环中调用 Hook。当 N 个值需要合并成一个派生结果时使用useSharedCacheSelectorkeys既是订阅集合也是唯一的快照读取集合选择器接收对应顺序的值元组缺失为undefined且不得自行访问cacheServiceconst EMPTY_TOOLS: McpTool[] [] // 模块级引用稳定的 fallback function useMcpToolsByServer(serverIds: readonly string[]): Recordstring, McpTool[] { // 从同一个 memo 数组派生 keys 和 zip 来源 const uniqueIds useMemo(() Array.from(new Set(serverIds)).sort(), [serverIds]) return useSharedCacheSelector( uniqueIds.map((id) mcp.tools.${id} as const), // 无需额外 useMemo (values) Object.fromEntries(uniqueIds.map((id, i): [string, McpTool[]] [id, values[i] ?? EMPTY_TOOLS])) ) }isEqual默认Object.is加上对数组/普通对象的一层逐项比较在选择结果层面决定是否触发重渲染Map/Set或领域值选择需要显式提供比较器。与useSharedCacheValue相同的零副作用契约不回写默认值、不钉住。完整的消费者纪律见useCache.ts中该 Hook 的 JSDocuseCache.ts。5. 有界的最近列表Persistconst [pinned, setPinned] usePersistCache(ui.tab.pinned_tabs) // 函数式 updater 基于最新存储值派生 —— 即使 pin() 与其他写入竞争 //例如在 await 之后触发也是正确的。 const pin (tab: Tab) setPinned((prev) [tab, ...prev.filter((t) t.id ! tab.id)].slice(0, 10))6. 观察模板键的每个实例仅 Main一条订阅覆盖所有 provider包括运行时才注册的const tpl web_search.provider.last_used_key.${providerId} as const this.registerDisposable( cacheService.subscribeSharedChange(tpl, (next, prev, concreteKey) { const id concreteKey.split(.).pop()! // 响应 provider id 的 key 轮换 }) )7. 非 Hook 读取路径上的 TTL// Main 服务或非 Hook 代码路径 cacheService.set(search.recent_query_hash, hash, 60_000) // ... 重算前先检查 if (!cacheService.has(search.recent_query_hash)) recompute()Type-Safe 与 Casual 的取舍场景使用键在设计期已知Fixed 键 类型安全方法键具有固定模式 可变部分Template 键 类型安全方法键到运行时才真正未知getCasual/setCasual仅 Memory需要跨窗口动态键Shared 层级的 Template 键——没有getSharedCasualCasual 方法在具体键匹配到任何 schema 模式时会报类型错误——这是有意设计它强制你把可预期的动态键升级为 Template schema 键把类型安全恢复到编译期。最佳实践清单按生命周期选层级而非按作用域Memory 可再生成Shared 可再生成的跨窗口状态Persist 跨重启值得保留的数据。TTL 只用于非 Hook 读取路径Hook 路径下使用 TTL 会打警告且值可能在两次渲染之间过期。Shared 的过期是最终一致的——被观察的值可能在 TTL 过后短暂残存直到 Main 的墓碑tombstone到达上限为 TTL 10 分钟 GC 周期对应设计不变量 #4。不要设计值必须在 TTL 时刻立即消失的 UI。按写入来源选择 Shared Hook本窗口写 →useSharedCache其他进程拥有、本窗口只展示 →useSharedCacheValue 引用稳定的??fallback。优先级 Fixed Template Casual把反复出现的 Casual 键升级为 Template schema 键。保持 Persist 值小而精——localStorage 单 origin 约 5MB且超过约 2MB 时已有告警日志。Main 进程响应缓存变更时始终把subscribe*的返回值包进this.registerDisposable(...)让 teardown 自动完成。同值写入是零成本的——不要在set/setShared外面自己加相等性守卫isEqual短路已经替你处理。结语Cherry Studio 的缓存系统以分层 schema 驱动 进程职责分明为核心三层缓存各有生命周期与权威方键的类型安全从编译期就得到保障Hook 层通过引用计数钉住条目、通过只读观察者隔离写入与展示Main 进程则以权威身份完成跨窗口的中继、冲突收敛与最终一致的 TTL 清理。实践中最值得记住的三条准则是按生命周期选层级、按写入来源选 Shared Hook、把 TTL 留给非 Hook 读取路径。理解了这些不变量详见 cache-overview.md你在多窗口 Electron 应用中处理可再生成数据时就能写出既正确又高效、且易于跨进程协作的代码。【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300 assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表