状态管理与数据迁移策略
学习目标
- 理解 Cherry Studio 基于 Redux Toolkit + redux-persist + Dexie IndexedDB 的三层状态管理架构
- 掌握 20+ Redux Slice 的组织方式与各自职责边界
- 理解
redux-persist持久化配置与黑/白名单策略 - 掌握 Redux 状态迁移(
migrate.ts)的编写模式 - 掌握 Dexie IndexedDB Schema 版本升级与数据迁移流程
- 理解主进程与渲染进程之间的
StoreSyncService同步机制 - 理解 v2 重构冻结策略的设计动机与取舍
项目实践
三层状态管理架构概览
Cherry Studio 的状态管理分为三层,每层承担不同的职责:
三层架构的分工如下:
| 层级 | 技术方案 | 职责 | 数据规模 | 访问模式 |
|---|---|---|---|---|
| 第一层 | Redux Toolkit | 运行时内存状态、UI 渲染驱动 | 小(KB 级) | 高频读写 |
| 第二层 | redux-persist | 轻量配置持久化、跨会话恢复 | 中(MB 级) | 低频写入、启动时读取 |
| 第三层 | Dexie IndexedDB | 大容量结构化数据持久存储 | 大(百 MB 级) | 按需查询、批量写入 |
第一层:Redux Store 与 Slice 组织
Store 初始化模式
Store 初始化采用 configureStore 结合 persistReducer 的标准模式:
configureStore({ reducer: persistReducer(persistConfig, rootReducer), middleware: [ persistMiddleware, thunkMiddleware, 自定义序列化中间件(处理二进制 / 特殊类型) ], serializableCheck: { 忽略 redux-persist 相关 action 路径 }})持久化配置核心参数:
persistConfig = { key: "cherry-studio", storage: localStorage 适配器, whitelist: ["settings", "llm", "assistants", ...], // 明确列出需要持久化的 slice blacklist: ["runtime", "selectionStore", ...], // 运行时态不持久化 serialize: 自定义序列化函数(处理 Set、Map、Buffer 等), deserialize: 自定义反序列化函数}Slice 职责矩阵
20+ Slice 按功能域分为以下类别:
各 Slice 的关键设计要点:
| Slice | 核心 State 结构 | 典型 Action | 特殊处理 |
|---|---|---|---|
assistants | 助手列表 + 当前选中 ID | 增删改查、排序 | 与消息系统联动 |
settings | 嵌套配置对象(外观 / 行为 / 通知等) | 单项更新、批量导入导出 | 白名单持久化 |
llm | 多提供商配置树 | 切换提供商、模型列表刷新 | 敏感信息加密存储 |
mcp | 服务器列表 + 连接状态 | 添加 / 删除 / 重启服务器 | 异步连接状态同步 |
messageBlock | 消息块渲染类型映射 | 注册自定义渲染器 | 热插拔扩展点 |
knowledge | 知识库条目列表 + 索引状态 | 添加条目、重建索引 | 大容量数据下沉 IndexedDB |
paintings | 图像生成任务队列 | 提交任务、轮询状态 | 图片 Blob 不存入 Redux |
memory | 记忆策略配置 | 切换策略、清除记忆 | 与 LLM 上下文窗口联动 |
websearch | 搜索引擎配置 + API Key | 切换引擎、测试连接 | Key 加密存储 |
shortcuts | 快捷键映射表 | 自定义快捷键、恢复默认 | 与系统快捷键冲突检测 |
tabs | 标签页列表 + 激活状态 | 打开 / 关闭 / 切换标签 | 窗口级状态 |
backup | 备份策略 + 远程目标 | 触发备份、恢复数据 | 与坚果云等集成 |
selectionStore | 当前选中文本 + 位置 | 捕获选区、清除 | 不持久化 |
runtime | 运行时标志位(加载中 / 错误等) | 状态切换 | 不持久化 |
preprocess | 预处理管道配置 | 启用 / 禁用预处理步骤 | 管道式链调用 |
note | 笔记列表 + 编辑状态 | 增删改笔记 | 大容量下沉 IndexedDB |
ocr | OCR 引擎配置 | 切换引擎、设置语言 | 图片临时缓存 |
codeTools | 代码工具链配置 | 配置解释器路径 | 路径校验 |
nutstore | 坚果云 OAuth 状态 | 授权 / 撤销 / 同步 | OAuth 流程管理 |
toolPermissions | 工具调用权限表 | 授予 / 撤销权限 | 安全策略 |
migrate | 迁移进度与状态 | 触发迁移、回滚 | 迁移完成后自动清除 |
第二层:redux-persist 持久化配置
持久化策略
Cherry Studio 对不同的 Slice 采取差异化的持久化策略:
白名单 Slice(需要持久化的配置类数据):
settings、llm、assistants、mcp、knowledge、paintings、memory、websearch、shortcuts、tabs、backup、preprocess、note、ocr、codeTools、nutstore、toolPermissions
黑名单 Slice(运行时态,不持久化):
runtime、selectionStore
自定义序列化器
针对非标准 JSON 类型,Cherry Studio 实现自定义序列化/反序列化逻辑:
序列化伪代码: function customSerialize(state): for each key, value in state: if value is Set: state[key] = { __type: "Set", __value: Array.from(value) } else if value is Map: state[key] = { __type: "Map", __value: Array.from(value.entries()) } else if value is Buffer or ArrayBuffer: state[key] = { __type: "Buffer", __value: base64Encode(value) } else if value is Object: customSerialize(value) // 递归 return state
反序列化伪代码: function customDeserialize(state): for each key, value in state: if value is Object with __type field: if value.__type == "Set": state[key] = new Set(value.__value) else if value.__type == "Map": state[key] = new Map(value.__value) else if value.__type == "Buffer": state[key] = base64Decode(value.__value) else if value is Object: customDeserialize(value) // 递归 return stateRedux 状态迁移(migrate.ts)
迁移模式
migrate.ts 负责在 Redux 持久化状态 schema 发生变更时,安全地将旧版本状态升级到新版本:
迁移函数的典型结构:
迁移函数伪代码: function migrate(state, currentVersion): targetVersion = 最新版本号
if state._persist.version < targetVersion: if state._persist.version < 2: state = migrateV1ToV2(state) if state._persist.version < 3: state = migrateV2ToV3(state) if state._persist.version < 4: state = migrateV3ToV4(state) // ... 依此类推
state._persist.version = targetVersion
return state单个版本迁移的典型操作:
migrateV2ToV3(state) 示例伪代码: // 操作 1:字段重命名 state.settings.oldFieldName -> state.settings.newFieldName
// 操作 2:新增字段默认值 state.llm.newProviderSettings = 默认配置对象
// 操作 3:数据结构转换 state.assistants.list = 将旧数组格式转换为新对象映射格式
// 操作 4:废弃数据清理 delete state.deprecatedSlice
// 操作 5:嵌套结构重构 state.mcp.servers = 将扁平列表重构为分组树结构
return state迁移安全策略
关键安全措施:
- try-catch 包裹:每个版本迁移函数独立 try-catch,单一版本迁移失败不阻塞后续版本
- 防御性默认值:迁移后的状态必须满足类型约束,所有新增字段提供合理默认值
- 版本标记:
_persist.version字段标识当前持久化状态版本 - 降级兜底:迁移失败时回退到默认初始状态,不阻塞应用启动
第三层:Dexie IndexedDB 结构化存储
数据库 Schema 定义
Dexie 数据库初始化采用版本递进的 Schema 定义模式:
Dexie 数据库初始化伪代码: db = new Dexie("CherryStudio")
db.version(1).stores({ files: "++id, name, path, size, createdAt", topics: "++id, title, assistantId, createdAt, updatedAt" })
db.version(2).stores({ files: "++id, name, path, size, createdAt, mimeType", // 新增 mimeType 索引 topics: "++id, title, assistantId, createdAt, updatedAt", settings: "++id, key, value, updatedAt" // 新增表 })
db.version(5).stores({ files: "++id, name, path, size, createdAt, mimeType, hash", topics: "++id, title, assistantId, createdAt, updatedAt", settings: "++id, key, value, updatedAt", knowledge_notes: "++id, knowledgeBaseId, title, content, createdAt", translate_history: "++id, sourceLang, targetLang, sourceText, translatedText, createdAt", quick_phrases: "++id, category, content, usageCount" }).upgrade(upgradeToV5)
db.version(7).stores({ files: "++id, name, path, size, createdAt, mimeType, hash", topics: "++id, title, assistantId, createdAt, updatedAt", settings: "++id, key, value, updatedAt", knowledge_notes: "++id, knowledgeBaseId, title, content, createdAt, tags", translate_history: "++id, sourceLang, targetLang, sourceText, translatedText, createdAt", quick_phrases: "++id, category, content, usageCount", message_blocks: "++id, messageId, type, content, createdAt" }).upgrade(upgradeToV7)
db.version(8).stores({ files: "++id, name, path, size, createdAt, mimeType, hash, embedding", topics: "++id, title, assistantId, createdAt, updatedAt", settings: "++id, key, value, updatedAt", knowledge_notes: "++id, knowledgeBaseId, title, content, createdAt, tags, embedding", translate_history: "++id, sourceLang, targetLang, sourceText, translatedText, createdAt", quick_phrases: "++id, category, content, usageCount", message_blocks: "++id, messageId, type, content, createdAt", translate_languages: "++id, code, name, usageCount" }).upgrade(upgradeToV8)表结构与索引设计
| 表名 | 主键 | 核心索引 | 数据量级 | 访问频率 |
|---|---|---|---|---|
files | ++id | name, path, hash | 百 - 千级 | 中 |
topics | ++id | assistantId, updatedAt | 千级 | 高 |
settings | ++id | key | 百级 | 中 |
knowledge_notes | ++id | knowledgeBaseId, tags | 千 - 万级 | 高 |
translate_history | ++id | sourceLang, targetLang | 千 - 万级 | 中 |
quick_phrases | ++id | category | 百级 | 低 |
message_blocks | ++id | messageId, type | 万级 | 中 |
translate_languages | ++id | code | 百级 | 低 |
Schema 升级函数模式
每个 upgrade 函数接收一个 upgradeTransaction 对象,可在迁移过程中操作旧数据:
典型升级函数实现模式:
upgradeToV5 伪代码: function upgradeToV5(transaction): // 场景 1:新增字段,为已有记录填充默认值 filesTable.eachEntry(entry): if entry.mimeType is undefined: entry.mimeType = detectMimeType(entry.name) transaction.files.put(entry)
// 场景 2:新表初始化 // knowledge_notes 和 translate_history 为新表,无需数据迁移
upgradeToV7 伪代码: function upgradeToV7(transaction): // 场景 1:为已有表新增索引字段 knowledgeNotesTable.eachEntry(entry): if entry.tags is undefined: entry.tags = [] // 默认空标签 transaction.knowledge_notes.put(entry)
// 场景 2:新建 message_blocks 表 // 从历史消息中提取 block 数据(如果需要)
upgradeToV8 伪代码: function upgradeToV8(transaction): // 场景 1:添加 embedding 向量字段 filesTable.eachEntry(entry): if entry.embedding is undefined: entry.embedding = null // 懒加载,按需生成 transaction.files.put(entry)
knowledgeNotesTable.eachEntry(entry): if entry.embedding is undefined: entry.embedding = null transaction.knowledge_notes.put(entry)
// 场景 2:新增 translate_languages 字典表 // 预填充常用语言列表StoreSyncService:主进程与渲染进程同步
IPC 同步架构
Electron 架构中,主进程(Main Process)和渲染进程(Renderer Process)之间的状态通过 StoreSyncService 进行同步:
同步策略
StoreSyncService 核心逻辑伪代码: class StoreSyncService: // 渲染进程侧 function init(store): // 订阅 Redux Store 变更 store.subscribe(() => changedKeys = 计算变更的 key 集合 if changedKeys 在白名单中: 通过 IPC 发送变更到主进程 )
// 监听主进程推送的更新 IPC.on("STORE_UPDATED", (payload) => store.dispatch(同步 Action, 合并主进程数据) )
// 主进程侧 function init(): // 监听渲染进程变更 IPC.on("SYNC_STORE", (payload) => 写入本地 store.json 文件 广播到其他渲染进程窗口 )
// 启动时加载持久化状态 savedState = 从 store.json 读取 通过 IPC 发送给渲染进程v2 重构冻结策略
冻结背景
Cherry Studio 计划在 v2.0.0 进行一次大规模架构重构。在此背景下,项目对 Redux Slice 和 IndexedDB Schema 采取了冻结策略:
冻结标记规范
在源码中使用标准 JSDoc 标记废弃组件:
@deprecated 标记模式: /** * @deprecated Scheduled for removal in v2.0.0 * 原因:v2 将使用新的状态管理方案替代 * 替代方案:(如有则说明) */被标记的组件在 v2 之前的版本中继续维护但不扩展:
- 修复 Bug:允许
- 性能优化:允许
- 新增功能 / 修改 Schema:禁止
- 重构内部实现:禁止(除非存在严重安全漏洞)
问题与规避
问题一:redux-persist 序列化失败
症状:控制台报 TypeError: state is not serializable 错误,应用启动时状态丢失。
根因:Slice 状态中包含 Set、Map、Buffer、Date、自定义类实例等非标准 JSON 类型,redux-persist 默认序列化器无法处理。
规避方案:
- 在
serializableCheck.ignoredPaths中排除包含非标准类型的路径 - 实现自定义序列化/反序列化函数(见上文”自定义序列化器”部分)
- 尽量在 Slice 中使用纯 JSON 兼容类型,避免在 Redux 状态中存储类实例
问题二:迁移函数遗漏字段导致运行时错误
症状:迁移后应用崩溃,报 Cannot read properties of undefined。
根因:迁移函数未为新增字段提供默认值,导致下游组件访问 undefined。
规避方案:
- 每个迁移函数编写前,先列出所有新增字段及其默认值清单
- 迁移函数末尾执行完整性校验(
invariant或手动断言) - 在迁移函数外围包裹 try-catch,失败时回退到默认初始状态
- 为每个迁移函数编写单元测试,覆盖旧版本状态输入
问题三:Dexie 升级中断导致数据库损坏
症状:IndexedDB 打开失败,报 UnknownError 或 VersionError。
根因:upgrade 函数中抛出未捕获异常,导致事务回滚,数据库处于中间状态。
规避方案:
upgrade函数中使用 try-catch 捕获所有异常- 使用事务的原子性:所有写操作在同一个事务中,任一失败则全部回滚
- 为每个版本升级编写独立测试,使用内存 IndexedDB 模拟(如
fake-indexeddb) - 保留降级脚本:在极端情况下可以从新版本回退到旧版本
问题四:StoreSyncService 同步冲突
症状:多窗口场景下状态不一致,一个窗口的修改在另一个窗口未生效或被覆盖。
根因:两个窗口同时修改同一字段,后到达的写操作覆盖了先到达的。
规避方案:
- 采用乐观更新 + 冲突检测:每个状态变更附带时间戳或向量时钟
- 主进程按时间戳排序,保留最新的变更
- 对关键字段(如助手配置)采用锁定机制,同一时刻只允许一个窗口写入
- 用户可见的冲突提示:当检测到冲突时通知用户手动选择
问题五:localStorage 容量超限
症状:QuotaExceededError,redux-persist 写入失败。
根因:白名单中包含了大容量数据(如图片 Base64、大量消息记录),超出 localStorage 5-10MB 限制。
规避方案:
- 严格审查白名单,仅包含轻量配置数据
- 大容量数据(图片、文件元数据、消息记录)下沉到 Dexie IndexedDB
- 实现自动清理策略:定期清理过期的持久化数据
- 监控 localStorage 使用量,接近上限时发出警告
设计取舍
为什么选择三层架构而非单一方案?
| 方案 | 优势 | 劣势 | Cherry Studio 的取舍 |
|---|---|---|---|
| 纯 Redux(无持久化) | 简单一致,开发体验好 | 刷新页面丢失状态 | 不可接受,用户配置必须持久化 |
| Redux + localStorage 手动存取 | 简单,无额外依赖 | 序列化复杂,容易遗漏 | 不可扩展,20+ Slice 手动管理不现实 |
| Redux + redux-persist | 自动化持久化,版本迁移 | 仅支持 localStorage,容量受限 | 接受,用于轻量配置持久化 |
| Redux + Dexie 全量存储 | 容量无限制,支持复杂查询 | 异步操作复杂,与 Redux 同步模式不一致 | 折中:大容量数据下沉,小配置仍走 redux-persist |
| 三层架构(最终方案) | 各层职责清晰,容量与性能兼顾 | 架构复杂度高,维护成本大 | 采用:Redux 驱动 UI,redux-persist 持久化配置,Dexie 存储大容量数据 |
为什么冻结 v1 的 Slice 和 Schema?
| 方案 | 优势 | 劣势 | Cherry Studio 的取舍 |
|---|---|---|---|
| 持续迭代 v1 | 渐进式改进,风险低 | 历史债务累积,架构越来越臃肿 | v2 将重构状态管理方案,继续迭代会增加迁移成本 |
| 立即废弃 v1 转向 v2 | 无历史债务 | v2 开发周期长,中间版本功能停滞 | v2 尚未就绪,不可立即切换 |
| 冻结 v1 + 并行开发 v2 | v1 稳定运行,v2 不受约束 | 维护两套代码,短期成本高 | 采用:v1 仅修 Bug 不新增,v2 完成后一次性切换 |
冻结策略的核心逻辑:在 v2 完成之前,任何对 v1 状态管理方案的修改都将成为 v2 迁移时的额外负担。通过冻结,确保 v1 到 v2 的迁移路径清晰、可控。
为什么 Dexie Schema 逐步递进而非一步到位?
| 方案 | 优势 | 劣势 | Cherry Studio 的取舍 |
|---|---|---|---|
| 一次性定义完整 Schema | 无版本升级逻辑,代码简洁 | 无法适应需求变化,已安装用户数据丢失 | 不可行,用户需求持续变化 |
| 逐步递进版本(最终方案) | 平滑升级,已有用户数据无损迁移 | 升级逻辑复杂,需要维护多个版本迁移函数 | 采用:每个功能迭代对应一个版本升级 |
| 按需动态 Schema | 最灵活 | Dexie 不支持运行时 Schema 变更 | 不可行 |