Skip to content

09|状态调试 ​

第五章概览 / 09。目标:从“最终值不对”退回完整因果链,判断错误发生在输入、写入、异步竞态、派生依赖还是渲染。

先记录一条最小时间线 ​

text
点击保存
  → profile.save({ revision: 3 })
  → PATCH /api/profile(requestId=p-17)
  → 用户切换到 revision 4
  → p-17 返回
  → 旧响应是否被忽略?

只打印最终 $state 看不到“谁先发生”。复现记录至少包含:初始 state、用户输入、action 名与参数、请求 id/开始结束、mutation 类型、最终可见结果。敏感字段先脱敏。

用 $onAction() 观察意图 ​

ts
const stopActions = store.$onAction(({ name, args, after, onError }) => {
  const startedAt = performance.now()
  console.debug('[action:start]', store.$id, name, sanitize(args))

  after(result => {
    console.debug('[action:end]', store.$id, name, {
      durationMs: performance.now() - startedAt,
      result: sanitize(result),
    })
  })

  onError(error => {
    console.error('[action:error]', store.$id, name, {
      durationMs: performance.now() - startedAt,
      message: error instanceof Error ? error.message : String(error),
    })
  })
})

after 会等待 action 返回的 Promise;onError 观察抛出的异常。它适合定位“哪个业务动作”触发变化。调用完成后不会自动停止应用级 detached 监听,所以 owner 必须保存 stopActions。

用 $subscribe() 观察提交 ​

ts
const stopMutations = store.$subscribe((mutation, state) => {
  console.debug('[mutation]', {
    storeId: mutation.storeId,
    type: mutation.type,
    revision: state.revision,
  })
}, { detached: true, flush: 'sync' })

订阅能看到 direct/object patch/function patch,但不能告诉你 direct mutation 属于哪个用户意图;所以 action 时间线和 mutation 时间线解决不同问题。不要永久记录整个 state,尤其不要复制 token、表单内容或大列表。

Vue DevTools 看什么 ​

  1. 在组件树确认当前组件实际消费哪个 store。
  2. 在 Pinia 面板检查 store id、state/getter 与 action 时间线。
  3. 用时间旅行回到某个 mutation,确认 bug 是输入已错,还是后续动作覆盖。
  4. 重放时把网络、时间、随机数等外部输入固定;纯 state 快照无法重放服务端副作用。

时间旅行只是开发诊断,不是业务撤销。数据库写入、localStorage 和已经发送的埋点不会随 state 快照自动回滚。

响应式依赖调试 ​

当 mutation 正确而视图没更新,检查是否错误解构 store;当视图更新过多,开发模式可用响应式调试钩子:

ts
const total = computed(
  () => store.lines.reduce((sum, line) => sum + line.quantity, 0),
  {
    onTrack(event) { console.debug('tracked', event.key) },
    onTrigger(event) { console.debug('triggered', event.type, event.key) },
  },
)

computed/watch/watchEffect 的 onTrack/onTrigger 以及组件的 onRenderTracked/onRenderTriggered 只用于开发调试。它们显示响应式 get/set/add/delete 等触发关系,不替代性能分析。定位后删除临时日志,或把发现的输入序列固化成测试。

快照要可比较、可恢复 ​

调试快照只选纯数据字段,并在比较前规范化时间、排序等不稳定值。Vue reactive proxy、函数、AbortController、DOM 节点不能当作可移植快照。恢复快照也要走明确入口,避免绕过新版本 schema。

ts
function snapshotCart(store: ReturnType<typeof useCartStore>) {
  return {
    lines: store.lines.map(line => ({
      productId: line.productId,
      unitPrice: line.unitPrice,
      quantity: line.quantity,
    })),
  }
}

这里逐行投影的是购物车正文已经声明的三个原始字段,生成普通数组和普通对象。直接把 store.lines 放进 structuredClone 会因它仍是 Proxy 而抛 DataCloneError;只把最外层包成 { lines } 也不能去掉内部代理。验证时修改 snapshot.lines[0].quantity,store 中对应数量必须保持原值。若后续增加嵌套字段,应继续按 schema 投影,不能假定一次浅复制足够。

排错顺序 ​

  1. 固定最小初始状态和输入,确认可重复。
  2. 查 owner:是否有重复副本或错误 store 实例。
  3. 查 action 与 mutation 顺序,标注请求身份。
  4. 查缓存 key、失效和旧响应覆盖。
  5. 最后才查渲染依赖与性能;不要用更多 watch 掩盖上游错误。
  6. 修复后让原复现失败、正确路径通过,并停止/删除诊断监听。

练习:定位旧响应覆盖 ​

给一个 search(term) action:连续搜索 vue 和 pinia,前者后返回并覆盖后者。要求写可控 Promise 复现;用 $onAction 记录 action 起止,用 request id 或 AbortController 修复;最终结果必须是 pinia,旧请求不得把 status 改回 success/error。

验收还包括:日志不含完整响应和用户输入;两类停止函数在测试结束调用;修复后的测试能在颠倒完成顺序时稳定通过。

参考答案:第 09 题。

本页结论 ​

状态调试不是多打几个 console.log,而是重建 owner 与时序。action 表示意图,mutation 表示提交,请求 id 表示并发,响应式钩子表示视图依赖。

用心记录每一步成长