Skip to content

2.1 本地状态契约 ​

阶段 1 已经有两组固定身份:

text
StageId
pregnancy | baby-0-6 | baby-6-12 | toddler-1-3

RecommendationId
rec-prenatal-checklist | rec-feeding-log | rec-pumpkin-puree
rec-soft-spoon | rec-picture-book

阶段 2 新增一个用户要求:切换阶段、收藏或打开详情后,refresh 不能丢状态。Storage 会把静态类型抹成字符串。先定义读写边界,再接 Pinia。

先定 v1 snapshot ​

Snapshot 只存身份,不复制 Recommendation 内容:

ts
export interface RecommendationStateSnapshot {
  readonly version: 1;
  readonly selectedStage: StageId;
  readonly favoriteIds: readonly RecommendationId[];
  readonly historyIds: readonly RecommendationId[];
}

固定 version: 1 后,decoder 才能拒绝不认识的结构。标题、摘要和注意事项仍从 client 取得;内容更新时,storage 不会留下一份旧副本。

Decoder 只认 contract ​

normalizePersistedStageId 不猜输入含义。四个 StageId 原样返回,其余值回 DEFAULT_STAGE_ID:

ts
export function normalizePersistedStageId(value: unknown): StageId {
  if (typeof value === "string" && isStageId(value)) {
    return value;
  }

  return DEFAULT_STAGE_ID;
}

decodeRecommendationState(raw) 还要处理三组规则:

  • 只有五个 RecommendationId 能进入收藏和历史;未知值与非字符串被丢弃;
  • 重复 ID 保留第一次出现的顺序,history 最多 10 条;
  • 返回的 snapshot 和两个数组都是冻结副本。

输入示例:

json
{
  "version": 1,
  "selectedStage": "future-stage",
  "favoriteIds": ["rec-pumpkin-puree", "rec-future-item", "rec-pumpkin-puree"],
  "historyIds": ["rec-soft-spoon", "rec-soft-spoon"]
}

输出使用 DEFAULT_STAGE_ID,收藏只剩 rec-pumpkin-puree,历史只剩 rec-soft-spoon。Decoder 不读 uni;给它 raw value,取回纯数据。

Storage 读取不能只返回默认值 ​

坏 JSON、未来版本和读取异常都可以给 UI 一份默认快照,但写入策略不同。只返回 snapshot 会丢掉这个判断。

ts
export type RecommendationStateReadStatus =
  | "loaded"
  | "empty"
  | "invalid"
  | "unsupported-version"
  | "read-error";

export interface RecommendationStateReadResult {
  readonly status: RecommendationStateReadStatus;
  readonly snapshot: RecommendationStateSnapshot;
}
Status读到的内容后续写入
loaded合法 v1允许
empty没有记录允许
invalid坏 JSON、非对象或缺少 own version先用默认值;下一次操作可重建 v1
unsupported-version对象带 own 非 v1 version阻止覆盖;保护状态会重新读取
read-errorstorage adapter 抛错阻止覆盖;保护状态会重新读取

unsupported-version 不能并入 invalid。未来版本可能保存了当前代码不懂的数据。read-error 也不能当成 empty;读不到不等于没有。

保护状态下,hydrate() 会继续读取。结果仍是 read-error 或 unsupported-version 时继续阻止写入;变为 loaded、empty 或 invalid 时解除保护。invalid 按默认状态恢复,下一次用户操作可以写入新的 v1。

分层后,纯函数负责 decode,adapter 负责 getItem / setItem 与异常归类,store 决定提示和写保护。Decoder test 不需要构造 uni 全局对象。

本课程按最终责任重组 2.1:把固定 Recommendation ID domain 与 persistence 收在一个六文件学员提交里。维护仓的实现由多次提交完成(细节见教练侧验收记录)。学员提交不对应某一单次维护 commit;Review 对齐最终 diff、测试和行为。

给 AI 的任务卡 ​

text
Task: add the closed RecommendationId contract and a versioned local-state boundary.

Allowed files:
- apps/web/src/domain/stages.ts
- apps/web/src/domain/stages.test.ts
- apps/web/src/domain/recommendations.ts
- apps/web/src/domain/recommendations.test.ts
- apps/web/src/persistence/recommendation-state.ts
- apps/web/src/persistence/recommendation-state.test.ts

Rules:
- keep exactly the four canonical StageId values;
- define exactly the five canonical RecommendationId values;
- unknown or non-string stage input uses DEFAULT_STAGE_ID;
- filter unknown recommendation ids and preserve first-appearance order;
- keep at most ten history ids;
- return copied, deeply frozen snapshots;
- keep the decoder pure and put uni storage behind an adapter;
- classify reads as loaded, empty, invalid, unsupported-version, or read-error;
- do not weaken, delete, or rewrite existing assertions.

Run:
npm run test:web -- recommendations.test.ts recommendation-state.test.ts

如果 diff 出现 page、store、component、package 或 lockfile,先退回范围。扩大 StageId / RecommendationId 为 string 也不能进入下一轮。

Review 第一版:数组还是可变的 ​

AI 初版可能只冻结外层对象:

ts
return Object.freeze({
  version: 1,
  selectedStage,
  favoriteIds,
  historyIds,
});

readonly 只约束 TypeScript 调用点。运行时仍能修改 snapshot.favoriteIds,而且 helper 把传入数组的引用直接挂到了返回值上。Reviewer 先看 diff,再固定冻结断言:

ts
expect(Object.isFrozen(snapshot)).toBe(true);
expect(Object.isFrozen(snapshot.favoriteIds)).toBe(true);
expect(Object.isFrozen(snapshot.historyIds)).toBe(true);

实现要在边界处复制并冻结数组:

ts
function createSnapshot(
  selectedStage: StageId,
  favoriteIds: readonly RecommendationId[],
  historyIds: readonly RecommendationId[],
): RecommendationStateSnapshot {
  return Object.freeze({
    version: 1,
    selectedStage,
    favoriteIds: Object.freeze([...favoriteIds]),
    historyIds: Object.freeze([...historyIds]),
  });
}

修完局部缺陷后,再读 readRecommendationState 的分支。至少补三项判断:读取抛错得到 read-error;损坏数据得到 invalid;own version 不是 1 得到 unsupported-version。三者都返回默认快照,写保护策略不能相同。

用 fresh 输出批准 GREEN ​

从教学仓 root 运行:

bash
npm run test:web -- recommendations.test.ts recommendation-state.test.ts
npm run test:web

当前 checkpoint 的实际结果:

text
src/domain/recommendations.test.ts (20 tests)
src/persistence/recommendation-state.test.ts (38 tests)
Test Files  2 passed (2)
Tests       58 passed (58)

Test Files  9 passed (9)
Tests       143 passed (143)

数字之后还要读断言。Domain 的 20 项守住五个固定 ID;persistence 的 38 项守住 canonical-only stage、ID 过滤去重、history 上限、深冻结、五种 read status、默认 adapter 和读写异常。

精确暂存这组文件 ​

这是按责任重组的学员提交,不是维护仓某个 SHA 的复刻。先检查 unstaged diff,再显式暂存任务卡允许的六个文件:

bash
git status --short
git diff -- \
  apps/web/src/domain/stages.ts \
  apps/web/src/domain/stages.test.ts \
  apps/web/src/domain/recommendations.ts \
  apps/web/src/domain/recommendations.test.ts \
  apps/web/src/persistence/recommendation-state.ts \
  apps/web/src/persistence/recommendation-state.test.ts

git add -- \
  apps/web/src/domain/stages.ts \
  apps/web/src/domain/stages.test.ts \
  apps/web/src/domain/recommendations.ts \
  apps/web/src/domain/recommendations.test.ts \
  apps/web/src/persistence/recommendation-state.ts \
  apps/web/src/persistence/recommendation-state.test.ts
git diff --cached --name-only
git diff --cached --check
git diff --cached

git diff --cached --name-only 必须正好列出这六个文件。Cached diff 中出现被删除或放宽的既有 assertion,就取消暂存并修正。检查通过后提交:

bash
git commit -m "feat(web): define local recommendation state contract"
git status --short

下一步:2.2 Pinia 与本地持久化。

晨悦 AI 实践手册