11 · 存储、设置与模板管理
扩展的状态不是一个 JSON:小而可同步的偏好、大而隐私敏感的历史、高亮正文、运行时 tab 状态分别有不同生命周期。
一、三层状态
内存态
generalSettings / tab mode / UI selection / caches
storage.sync
vaults / general / highlighter / reader / interpreter
property types / stats / migration version / templates(由 manager 管理)
storage.local
highlights / clip history / 页面体量较大的本地数据分类标准不是“方便”,而是生命周期、容量、同步价值与隐私。
二、generalSettings 是运行时快照
storage-utils.ts 导出一个可变模块变量 generalSettings,先带默认值,loadSettings() 后替换为清洗过的加载结果。
优点:各模块读取简单,不必层层传 settings。
代价:
- 调用依赖初始化顺序;
- 多 bundle 各有自己的模块实例;
- storage change 后需要显式 reload/update;
- 测试必须 reset mock storage 与模块状态。
三、加载不是简单 spread
loadSettings() 做了四类工作。
1. 完整默认值
每个字段都有默认值,尤其 Reader 的 14 个子字段。这样旧用户升级后缺失新字段也能运行。
2. Migration version
CURRENT_MIGRATION_VERSION = 1。若 storage 没版本或较旧,写入当前版本。当前迁移很轻,但建立了后续 schema evolution 的钩子。
3. 输入清洗
- vaults 必须是 string array;
- models/providers 必须是对象且有 string id;
- 其他字段用 nullish coalescing 合并默认;
- 旧 boolean
openBehavior映射成embedded/popup。
4. 分 key 聚合
storage 中的 general_settings、reader_settings 等被组合成完整 Settings。
四、为什么不用一个大 key
分组保存有几个收益:
- settings page 可只监听关心的 section;
- 旧版本迁移更局部;
- 某组写入不必覆盖其他组;
- 数据结构与 UI section 对齐;
- sync 冲突粒度更小。
缺点是加载/保存样板代码较多,新增字段必须同时更新 interface、default、load、save 和 UI。
五、保存行为
saveSettings(settings?) 可先 shallow merge partial,再把聚合对象拆回 storage keys。
这里要警惕 shallow merge:若传 { readerSettings: { fontSize: 18 } } 而类型绕过检查,可能覆盖整个 nested object。实际 Reader 多直接保存完整 reader_settings 或自己的 key,调用方应传完整嵌套对象。
六、历史与统计
incrementStat(action, ...):
- load latest settings;
stats[action]++;- save sync;
- 如果有 URL,调用
addHistoryEntry()。
History:
- 新记录 unshift 到头部;
- 最多保留 1000;
- 包含 datetime/url/action/title/vault/path;
- 存
storage.local。
这既支持最近剪藏 UI,也避免 sync quota 被 URL 历史占满。
七、模板管理器
managers/template-manager.ts 是 Template persistence 入口,负责:
- load templates;
- 无模板时创建 default template;
- 新建/复制/删除/保存;
- ID 与默认字段;
- 把 UI 对象转回稳定 JSON。
template-ui.ts 负责表单与验证,drag-and-drop.ts 负责排序,import-export.ts 负责分享/迁移。把持久化、编辑器 UI、交互分开,避免 settings controller 继续膨胀。
八、设置页的 manager 架构
core/settings.ts 更像 composition root:
initialize sidebar/menu/i18n/icons
├─ initializeGeneralSettings()
├─ initializeInterpreterSettings()
├─ initializeReaderSettings()
├─ initialize template UI/validation
├─ initialize property types
├─ initialize drag/drop + auto save
└─ route URL section每个 manager 管一块 DOM 与 storage 映射,settings.ts 只组织生命周期。
九、自动保存
auto-save.ts 配合 debounce,在 settings 输入变化后延迟写 storage。重要原则:
- 高频 input 不应每个 keypress 写 sync;
- 显式 import/delete 等结构变化应立即保存;
- 页面 unload 前要确保 pending save 不丢;
- storage listener 引起的 UI 更新不能再次触发保存循环。
十、模板 import/export
导入路径必须把外部 JSON 当不可信数据:
- JSON parse;
- 检查必要字段;
- 补 id/default;
- 处理同 ID/同名冲突;
- 运行 tokenizer/parser validation;
- 用户确认后写入。
导出可保存文件或复制压缩文本。lz-string 适合分享模板 URL/文本,但解压后仍要执行同样验证。
十一、Property types 管理
property-types-manager.ts 维护 property name → Obsidian type。它解决:模板 property 本身可能没写 type,但用户希望同名属性在所有模板中统一为 date/list/checkbox。
优先级通常是 property 显式 type 与全局 mapping 合并;API 也允许调用方传 propertyTypes 覆盖/补充。
十二、Storage change 的跨页面一致性
popup、settings、reader 都可能同时打开。browser.storage.onChanged 用于:
- background 更新 action popup/openBehavior;
- popup refresh fields 或 settings;
- Reader 应用新排版;
- highlights library 更新主题/数据。
不能假设写 storage 的页面也是唯一消费者。
十三、调试入口
开发环境提供 window.debugStorage(key?):
await window.debugStorage()
await window.debugStorage('reader_settings')它只读 sync storage,并把结果打印/返回。此入口对排查迁移与字段落盘很有用;生产 debug log 则由构建常量裁剪。
十四、迁移策略建议
未来新增破坏性迁移时,应:
- 读取 migrationVersion;
- 按版本逐步迁移,不直接跳到最终结构;
- 每步幂等;
- 先写新 key,再删旧 key;
- 对大 local data 分批;
- 失败保留旧数据并记录;
- 给旧 boolean/string enum 加兼容窗口;
- 用 fixture 测旧版本 storage → 新 Settings。
十五、本章检查点
- 能解释 sync/local/memory 的状态边界;
- 能说明 loadSettings 的 default、sanitize、migration、aggregate;
- 能识别 shallow nested merge 风险;
- 能说出 settings manager 的分工;
- 能解释 history 为什么不 sync;
- 能列出新增 settings 字段必须同步修改的位置。