关键词:分域存储 · 写锁串行 · 防抖持久化 · 版本迁移 · 适配器门面 · 损坏自愈 目标读者:前端 / 架构
一、业务目标
一期证明了「愿意记」,二期要证明「记得住、丢不了、还能长」。
一期的存储是散落的 wx.setStorageSync 调用,随着三期登录、四期同步的到来,会遇到四个致命问题:
- key 混乱:会话、设置、记录、同步状态混在一起,改一处要 grep 全工程;
- 并发覆盖:打卡 + 同步同时写
em_mood,后写的覆盖先写的; - 无法演进:数据结构一改,老用户直接崩;
- 无法扩展:页面直接依赖存储实现,加云同步要改所有页面。
二期的目标就是:在不引入任何框架的前提下,把这四件事一次性解决,并把「换数据源」的能力做成一等公民。
二、核心功能范围
| 能力 | 说明 |
|---|---|
| 分域存储 | 7 个域:meta mood period settings session sync guest |
| 写锁串行 | Promise 队列串行化所有写,杜绝并发覆盖 |
| 防抖持久化 | 500ms 防抖批量落盘;session sync 例外,同步直写 |
| 校验与迁移 | 结构校验 + schemaVersion 迁移 + 高版本库只读保护 |
| 事件广播 | 极简发布订阅:settings:changed session:changed sync:changed mood:changed period:changed |
| 损坏自愈 | 校验失败 → dump 到 em_corrupted_backup → 空库启动,不白屏 |
| 适配器门面 | Api.request(module, action, payload) 屏蔽 Local/Remote 差异 |
| deviceId 兜底 | 缺失即生成并强制回写,否则 (userId, deviceId) 唯一约束失效(四期同步命脉) |
三、涉及的技术模块
utils/
├── storage.js ★ L4 本地存储引擎(约 300 行)
├── data-adapter.js ★ Api 门面(单例)
├── adapters/
│ ├── data-adapter.js 契约:Result + DataAdapter 抽象类
│ ├── local-adapter.js ★ 本地实现(含校验、派生重算、导入导出)
│ └── remote-adapter.js (四期实现,本期预留)
└── constants.js SCHEMA_VERSION、KEY 前缀等
四、关键技术选型与实现要点
4.1 分域 key 设计
// utils/storage.js:7-17
const KEYS = {
meta: 'em_meta', // { schemaVersion, deviceId, ... }
mood: 'em_mood', // { records, streak, lastCheckInDate, totalRecords }
period: 'em_period', // { records, cache }
settings: 'em_settings', // { storageMode, theme, reminder*, customTags, ... }
session: 'em_session', // { userId, accessToken, refreshToken, expiresAt, refreshExpiresAt }
sync: 'em_sync', // { pullCursor, lastSyncAt, status, pendingOps }
guest: 'em_guest_backup' // 游客快照(合并前备份)
};
为什么按「域」而不是按「功能」分?
- 一个域 = 一次
setStorageSync= 一次序列化。域太大(如把 settings 塞进 meta)会导致每次改主题都要序列化全部记录; - 域太小(如每条记录一个 key)会导致遍历成本高、无事务性;
- 7 个域对应 7 种生命周期:
mood/period防抖写、session/sync立即写、settings中等频率、meta几乎不变、guest一次性。
4.2 写锁 + 防抖:两条正交的机制
// utils/storage.js:90-126
let writeQueue = Promise.resolve(); // 串行队列
let flushTimer = null;
const dirty = { meta: false, mood: false, period: false, settings: false };
function enqueueWrite(domain) { // ① 防抖:500ms 内的多次写合并成一次
dirty[domain] = true;
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(flush, 500);
}
function lockWrite(task) { // ② 串行:Promise 链把并发写排队
const run = writeQueue.then(task, task); // then(task, task):前一个失败不阻塞后续
writeQueue = run.catch(() => {});
return run;
}
function flush() { // 一次 flush 落盘所有脏域
flushTimer = null;
if (!dirty.meta && !dirty.mood && !dirty.period && !dirty.settings) return;
lockWrite(() => {
const jobs = [];
if (dirty.meta) jobs.push(() => wx.setStorageSync(KEYS.meta, store.meta));
if (dirty.mood) jobs.push(() => wx.setStorageSync(KEYS.mood, store.mood));
if (dirty.period) jobs.push(() => wx.setStorageSync(KEYS.period, store.period));
if (dirty.settings) jobs.push(() => wx.setStorageSync(KEYS.settings, store.settings));
jobs.forEach(j => { try { j(); } catch (e) { console.error('persist error', e); } });
dirty.meta = dirty.mood = dirty.period = dirty.settings = false;
});
}
function flushNow() { // 关键写(打卡、登录)立即落盘
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flush();
}
设计取舍:
| 决策 | 理由 |
|---|---|
| 防抖 500ms | 连续打卡、连点标签时避免每次都序列化全量记录 |
session sync 不防抖 | 登录态与离线队列丢不得,必须同步直写 |
then(task, task) | 单个域落盘失败(如超限)不应阻塞其他域 |
提供 flushNow() | 打卡后立刻落盘,防止用户秒退小程序导致丢失 |
⚠️ 已知缺陷:
flushNow()只是「取消防抖立即执行flush()」,而flush内部仍走lockWrite异步队列。因此flushNow()返回后,数据不保证已落盘。严格场景(如logout后立即wx.reLaunch)应增加await版本。
4.3 内存镜像 + 引用语义
// utils/storage.js:216-217
function getDomain(name) { return store[name]; }
function setDomain(name, value, immediate) {
store[name] = value;
enqueueWrite(name);
if (immediate) flushNow();
}
getDomain() 返回的是内存镜像引用,不是拷贝。两个 Adapter 都依赖「getDomain → 原地 mutate → setDomain 回写」的模式。
优点:零拷贝,大数据量下性能好。
风险:调用方绕过 setDomain 直接 mutate 引用,会导致内存与磁盘不一致(改动在内存里生效,但没标脏、没落盘,重启即丢)。
落地约束(写进团队规范):
// ✅ 正确
const mood = storage.getDomain('mood');
mood.records.push(newRecord);
storage.setDomain('mood', mood, true);
// ❌ 错误:改动不会持久化
const mood = storage.getDomain('mood');
mood.records.push(newRecord); // 就此结束,没有 setDomain
4.4 初始化与损坏自愈
// utils/storage.js:147-213 initStore()
// ① session / sync 独立加载,不参与结构校验
// refreshExpiresAt 已过期 → clearSession() 回游客态
// ② meta/mood/period/settings 任一缺失 → 建默认结构 + flushNow()
// ③ validateStructure 不过 → throw STRUCTURE_INVALID
// schemaVersion 不同 → migrate()
// ④ deviceId 缺失 → 生成并强制回写 meta(否则四期 (userId,deviceId) 唯一约束失效)
// ⑤ catch:dump 到 em_corrupted_backup,空库启动,返回 { ok:false, error }
function validateStructure(db) { // L129
return db.meta && db.mood && db.period && db.settings
&& Array.isArray(db.mood.records)
&& Array.isArray(db.period.records);
}
function migrate(db, from) { // L135
if (from === SCHEMA_VERSION) return db;
if (from > SCHEMA_VERSION) throw new Error('SCHEMA_AHEAD'); // 高版本库只读,绝不回写
// v1 → v2 → ... 逐级升级分支
db.meta.schemaVersion = SCHEMA_VERSION;
return db;
}
SCHEMA_AHEAD 是保命设计:用户从新版本回退到旧版本时,旧版本若强行写库会破坏数据。宁可只读 + 提示升级。
4.5 事件总线:极简但要够用
// utils/storage.js:30-42
const listeners = {};
function on(event, cb) { (listeners[event] = listeners[event] || []).push(cb); }
function off(event, cb) { /* ... */ }
function emit(event, payload) {
(listeners[event] || []).forEach(cb => {
try { cb(payload); } catch (e) { console.error('store listener error', e); }
});
}
踩坑提醒:emit 里必须 try-catch 包裹每个回调——一个订阅者抛异常会阻断其余订阅者,也会让写流程失败。
现状盘点:全工程只有 pages/index/index.js:78 注册了 mood:changed;period:changed、sync:changed、settings:changed 目前无订阅者。这意味着同步状态变化不会自动反映到 UI,只能靠设置页手动拉取。属于二期未完成项。
4.6 适配器门面:本期最重要的设计
// utils/data-adapter.js:17-36
class ApiFacade {
init(force) {
if (this.adapter && !force) return; // 懒加载 + 幂等
const logged = !!storage.getSession(); // 登录态决定数据源
this.adapter = logged ? new RemoteAdapter() : new LocalAdapter();
this.mode = this.adapter.mode;
}
request(module, action, payload) {
if (!this.adapter) this.init();
return this.adapter.request(module, action, payload);
}
isRemote() { return this.adapter instanceof RemoteAdapter; }
}
const Api = new ApiFacade();
// utils/adapters/data-adapter.js:4-18 契约
class Result {
constructor(ok, code, data, error) {}
// code: 'OK' | 'VALIDATION' | 'NOT_FOUND' | 'STORAGE' | 'NETWORK'
}
class DataAdapter {
get mode() { throw new Error('not implemented'); }
request(module, action, payload) { throw new Error('not implemented'); }
}
切换触发点(Api.init(true) 强制重建):
| 场景 | 位置 |
|---|---|
| 冷启动 | app.js:36 |
| 复用会话 | app.js:50 |
| 登录成功 / 合并完成 | app.js:127 |
| 登出 | app.js:202 |
| 设置页手动切换存储模式 | pages/settings/settings.js:53 |
为什么值得? 四期引入 RemoteAdapter 时,8 个页面、几十处调用一行未改。这是「依赖倒置」在小项目里最划算的一次实践。
4.7 LocalAdapter 的分发与派生
// utils/adapters/local-adapter.js
request(module, action, payload) {
return Promise.resolve()
.then(() => { /* mood | period | stats | data | setting 分发 */ })
.catch(e => Result.fail('STORAGE', e.message));
}
- 写操作后 必须 触发派生重算:
recalcStreak(); // L106-114 重算 streak / lastCheckInDate / totalRecords recalcPredict(); // L170-174 重算 period.cache storage.setDomain('mood', mood, true); // immediate = true,立即落盘 storage.emit('mood:changed'); data.import:overwrite全量替换;merge按 id 去重。stats.*全部调calc.*实时计算(local-adapter.js:177-206)。
五、数据流转与接口设计
5.1 完整数据流
┌── 页面层 ──┐
│ page.js │ Api.request('mood','create',{...})
└─────┬──────┘
▼
┌── 门面层 ─────────────────────────────────────────┐
│ ApiFacade: 选适配器 → adapter.request(...) │
└─────┬────────────────────────────────┬────────────┘
▼ 未登录 ▼ 已登录(四期)
┌─ LocalAdapter ─┐ ┌─ RemoteAdapter ─┐
│ ① validate │ │ ① http.post │
│ ② mutate 镜像 │ │ /sync/push │
│ ③ recalc 派生 │ │ ② 失败 → pending│
│ ④ emit 事件 │ │ Ops 入队 │
└─────┬──────────┘ └────────┬────────┘
▼ ▼
┌── storage 引擎 ───────────────────────────────────┐
│ dirty 标记 → 防抖 500ms → lockWrite 串行落盘 │
│ session / sync:同步直写,不防抖 │
└─────┬─────────────────────────────────────────────┘
▼
wx.setStorageSync('em_*')
5.2 storage 对外 API 契约
| API | 行号 | 语义 |
|---|---|---|
initStore() | 147 | 冷启动初始化,返回 {ok, error} |
getDomain(name) | 216 | 返回镜像引用(非拷贝) |
setDomain(name, value, immediate) | 217 | 写镜像 + 标脏,immediate 时立即落盘 |
getSettings() / updateSettings(patch, immediate) | 222 / 223 | settings 浅合并 + emit |
flushNow() | 123 | 取消防抖立即 flush |
getDeviceId() | 229 | 返回内存 _deviceId |
getSession / setSession / clearSession / isLogged | 232-243 | 会话管理 |
getSync() / updateSync(patch) | 246 / 250 | 同步状态(懒建默认值) |
backupGuest / getGuestBackup / clearGuestBackup | 257-270 | 游客快照 |
on / off / emit | 31-42 | 事件总线 |
_internals | 282 | 暴露 store 与工厂,供测试/迁移 |
5.3 与后端的数据契约(RecordMapper 同构)
// mood-backend/.../common/util/RecordMapper.java:22-78
moodToMap(m) // id 用 clientId(保证端云同一主键)
moodFromMap(m) // clientId 取 clientId 或 id 首个非空
容错解析(RecordMapper.java:102-167):
listVal:支持 JSON 数组或逗号分隔字符串;instVal:先试ISO_OFFSET_DATE_TIME,再Instant.parse;intVal/boolVal:空值兜底。
这套容错是必须的:端上历史数据格式不统一(早期版本 tagIds 存过字符串),服务端必须能兼容才不会在导入时大面积失败。
六、技术难点分析
难点 1:如何做到「写不坏」
三层防护:
- 写前:
validateMood/validatePeriod拒绝非法数据; - 写中:
lockWrite串行化,避免并发覆盖; - 写后/读时:
initStore校验失败 → 备份 + 空库启动。
残留风险:getDomain 的引用语义让「绕过 setDomain 的 mutate」成为可能。建议二期收尾时给开发版加一个 Object.freeze 版镜像(生产关闭),一运行就暴露违规调用。
难点 2:deviceId 的稳定性
docs/deviceId-稳定性对刷新链路的影响分析-v1.0.md 专门讨论了这个问题。核心矛盾:
refresh_tokens上有uk_user_device {userId, deviceId}唯一索引;- 如果
deviceId每次冷启动都变(例如用了不稳定的生成源),每次登录都会 upsert 出新文档,集合单调膨胀; - 如果
deviceId意外丢失/重置,会撞唯一索引导致用户被踢下线。
落地结论(写进代码注释):
// storage.js:185-192
// deviceId 缺失时必须回写 meta 并立即持久化,
// 否则 (userId, deviceId) 唯一约束失效
同时 AuthService.tryRotate 第 208-210 行注释明确:轮换时不更新 deviceId,否则会撞唯一索引。
难点 3:版本迁移的前向兼容
migrate 目前只有 v1 分支,但骨架已就绪:
if (from > SCHEMA_VERSION) throw new Error('SCHEMA_AHEAD'); // 只读保护
// 逐级:if (from <= 1) { /* v1→v2 */ } if (from <= 2) { /* v2→v3 */ }
规范:每次结构变更必须 ①SCHEMA_VERSION += 1 ②补一个升级分支 ③在 e2e 里加一条「老版本数据 → 新版本」的用例。
现存瑕疵:
VERSION_KEY = 'em_version'(storage.js:17)声明后从未使用,真实版本在meta.schemaVersion。建议删除该常量避免误导。
难点 4:session 与 sync 的特殊性
这两个域不能防抖、不能参与结构校验:
session丢了 = 用户被登出(体验灾难);sync.pendingOps丢了 = 离线写的数据永久丢失(数据灾难)。
因此它们走同步 setStorageSync 直写(L235、L252)。代价是写入频率高,但这两个域数据量极小(<1KB),完全可接受。
七、方案对比与推荐
7.1 存储方案
| 方案 | 包体 | 事务 | 迁移 | 结论 |
|---|---|---|---|---|
| 自研 L4 引擎(本项目) | 0 KB | 域级串行 | 内置 | ✅ 300 行换全部能力 |
| 直接散写 Storage | 0 KB | ❌ | ❌ | ❌ 不可演进 |
| 引入 Redux + persist | +20 KB | 弱 | 需插件 | ❌ 心智负担重 |
小程序 wx.cloud.database | 0 KB | ✅ | ✅ | ❌ 违背本地优先 |
7.2 跨层通信
| 方案 | 结论 |
|---|---|
| 极简事件总线(本项目) | ✅ 够用,30 行 |
全局 getCurrentPages() 手动刷新 | ❌ 耦合页面栈,易漏 |
| 状态管理库(Pinia-like) | 端上无必要 |
7.3 数据访问抽象
| 方案 | 结论 |
|---|---|
| 门面 + 适配器(本项目) | ✅ 页面零改动切换数据源 |
页面里 if (isLogged) http else storage | ❌ 每个页面都要写分支,必然腐化 |
| 统一走 HTTP,本地用 mock server | ❌ 离线不可用 |
八、性能与安全考量
性能
| 项 | 现状 | 优化建议 |
|---|---|---|
| 冷启动 | 7 次同步 getStorageSync | 可接受;如需优化可合并 meta+settings |
| 写入 | 防抖 500ms + 串行 | 已足够 |
| 大数据量 | getDomain 零拷贝 | ✅ 天然优势 |
| 序列化 | 域级全量 | 记录 >1 万条时考虑分页域 |
安全
- 本地明文:
wx.setStorageSync不加密,手机被物理接触即可读出。健康数据属敏感个人信息,设置页应如实告知; - 会话落盘风险:
accessToken/refreshToken存在本地。缓解:refresh 30 天 TTL + 服务端可吊销(logout-all); - 备份残留:
em_guest_backup与em_corrupted_backup在成功后必须清理(clearGuestBackup),否则旧数据长期驻留; - deviceId 不可用于指纹追踪:仅作设备维度的会话标识,不与用户身份绑定对外输出。
九、风险与应对
| 风险 | 影响 | 应对 |
|---|---|---|
绕过 setDomain 的 mutate | 内存/磁盘不一致,重启丢数据 | 开发版 Object.freeze 镜像 + 代码评审清单 |
flushNow() 非真正同步 | 极端时序下丢数据 | 提供 flushSync()(同步 setStorageSync 版)用于登出/退出前 |
| 迁移分支缺失 | 老用户升级后异常 | 强制 SCHEMA_VERSION +1 与迁移分支同 PR |
| Storage 超限(10MB) | 写入静默失败 | flush 内 try-catch + 上报;设置页展示占用 |
| 事件订阅者泄漏 | 页面销毁未 off,重复刷新 | 页面 onUnload 统一 off;或改用一次性事件 |
em_guest_backup 只写不读 | 合并失败无自动回滚 | 四期补「恢复备份」入口 |
十、验收标准与交付物
验收标准
- 连续快速打卡 10 次,最终记录数与内容正确,无丢失、无重复;
- 手动清空
em_mood后冷启动,自动建默认结构且功能正常; - 写入非法 JSON 到
em_period后冷启动:备份到em_corrupted_backup,空库启动不白屏; -
schemaVersion高于当前版本时,只读不回写,提示用户升级; - 删除 deviceId 后冷启动:自动生成并已持久化(杀掉重进不变);
- 通过
Api.init(true)模拟切换适配器,页面代码零改动即可工作; - 离线连续操作后杀进程重启,数据完整;
- 所有写路径最终都调用了
setDomain/updateSettings/updateSync(静态检查)。
交付物
| 类型 | 内容 |
|---|---|
| 代码 | utils/storage.js、utils/data-adapter.js、utils/adapters/* |
| 契约 | Result 错误码体系、DataAdapter 抽象 |
| 文档 | 详细设计文档 v5.0(本地优先版) |
| 诊断 | docs/deviceId-稳定性对刷新链路的影响分析-v1.0.md |
十一、后续优化方向
flushSync():为登出、退出、导入等「最后一步写」提供真同步 API;- 事件订阅治理:补齐
period:changed/sync:changed订阅者,让同步状态实时反映到 UI; - 容量面板:设置页展示各域占用与剩余,接近 10MB 时引导导出/上云;
- 加密域(可选):对
mood/period落盘前做 AES-GCM,密钥由登录态派生或系统生物识别保护——见第五期; - 删除未使用的
VERSION_KEY,避免误导后人。