Skip to content

假设驱动调试方法论(Hypothesis-Driven Debugging)

核心原则

绝不依赖代码猜测,只依靠运行时数据做决策。

传统调试思路:看代码 → 猜原因 → 改代码 → 希望有效。 这种方式失败率高,因为代码只告诉你"写了什么",不告诉你"运行时实际发生了什么"。


完整流程

第一步:生成假设(多个,并行)

不是直接找原因,而是先列出 3-5 个可能导致问题的原因,并给每个假设编号(H-A、H-B...)。

要求:

  • 假设要具体,说明"为什么"而不仅是"什么"
  • 假设要覆盖不同子系统(CSS、JS 状态、DOM 结构、时序…)

示例(本次滚动失效 bug):

  • H-Aoverscroll-behavior 阻断了滚轮事件
  • H-B:容器 height: auto 导致无滚动区域
  • H-C:scroll 监听绑定时序错误
  • H-Dwindow.onresize 被覆盖引发错误

第二步:最小化埋点(并行验证所有假设)

在关键位置插入日志,每条日志标明它验证的假设编号。

关键原则:

  • 最少的日志覆盖最多的假设(3 条日志验证 4 个假设)
  • 记录函数入口/出口的关键值,不记录无关数据
  • 前端用 localStorage,后端用文件追加
js
// 验证 H-B、H-C:容器是否找到、高度是多少
localStorage.setItem('dbg_open', JSON.stringify({
  containerFound: !!container,
  containerH: container?.offsetHeight,
  containerScrollH: container?.scrollHeight,
  popupInnerH: popup.querySelector('.popup-inner')?.offsetHeight
}));

// 验证 H-A:overscroll 属性值
localStorage.setItem('dbg_container', JSON.stringify({
  overscroll: getComputedStyle(container).overscrollBehavior,
  containerH: container.offsetHeight,
  parentH: container.parentElement?.offsetHeight
}));

// 验证 H-C:scroll 事件是否触发
container.onscroll = () => {
  localStorage.setItem('dbg_scroll', JSON.stringify({ scrollTop: container.scrollTop, fired: true }));
};

第三步:让用户复现,收集数据

  1. 清空日志(避免旧数据干扰)
  2. 让用户执行复现步骤
  3. 读取日志数据
js
// 浏览器控制台一键读取
['dbg_open','dbg_container','dbg_scroll'].forEach(k => console.log(k, localStorage.getItem(k)));

第四步:用数据评判每个假设

dbg_open:      containerH=800, containerScrollH=1308  → H-B CONFIRMED(高度恢复正常)
dbg_container: overscroll=contain                     → H-A REJECTED(overscroll 正常)
dbg_scroll:    fired=true                             → H-C REJECTED(监听绑定成功)

关键:不是"看起来像是",而是用具体数字做判断。 containerH=800, scrollH=1308 直接证明了高度链恢复正常。


第五步:只改被确认的假设,不改其他

  • H-B CONFIRMED → 修复:补上 height: 90%; max-height: 800px
  • H-A REJECTED → 不动 overscroll-behavior
  • H-C REJECTED → 不动 JS 绑定逻辑

第六步:验证修复,再清除日志代码

保留日志代码,让用户再跑一次,对比修复前后数据。 确认成功后再删除调试代码。


对比表

对比点猜测式调试假设驱动调试
决策依据代码静态分析运行时实际数据
改动范围可能改多处只改有证据的地方
失败处理不知道为何失败日志指向下一步假设
可信度"应该有用"数据证明有用

日志工具选择原则

环境推荐方式
浏览器 JSlocalStorage.setItem()
Node.js / 后端fs.appendFileSync() 写 NDJSON
服务器进程标准输出 console.error()
跨网络POST 到本地日志服务(端口不通时降级为 localStorage)

埋点技巧与规范

埋点位置选择

每个埋点要对应至少一个假设,选择以下关键位置:

位置类型用途
函数入口 + 参数确认函数是否被调用、入参是否正确
函数出口 + 返回值确认返回结果是否符合预期
关键操作前后对比操作前后的状态变化
条件分支确认走了哪条 if/else 路径
异步回调内部确认异步任务是否真正执行了
可疑的边界值处捕获空值、0、undefined 等异常状态

埋点 payload 结构

每条日志包含以下字段,方便事后过滤和分析:

js
{
  hypothesisId: 'H-B',          // 对应哪个假设
  location: 'file.js:42',       // 代码位置
  message: '容器高度检查',        // 简短描述
  data: { containerH: 800 },    // 关键数据,只记录必要字段
  timestamp: Date.now()          // 时间戳,用于排序多条日志
}

浏览器端埋点模板

方案一:localStorage(最可靠,推荐)

js
try {
  localStorage.setItem('dbg_KEY', JSON.stringify({
    hypothesisId: 'H-X',
    data: { /* 关键值 */ },
    ts: Date.now()
  }));
} catch(e) {}

读取:

js
// 控制台一次性读取所有调试键
Object.keys(localStorage)
  .filter(k => k.startsWith('dbg_'))
  .forEach(k => console.log(k, JSON.parse(localStorage.getItem(k))));

方案二:POST 到本地日志服务(跨会话可用)

js
fetch('http://127.0.0.1:PORT/ingest/SESSION_ID', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ hypothesisId: 'H-X', data: {}, ts: Date.now() })
}).catch(() => {}); // 静默失败,不影响主逻辑

降级原则:端口不通时自动降级为 localStorage,永远不要让日志工具本身阻塞调试。

方案三:console.error(最简单,但不可持久化)

js
console.error('[DBG H-B]', { containerH: el.offsetHeight, scrollH: el.scrollHeight });

后端/Node.js 埋点模板

js
const fs = require('fs');
const log = (data) => {
  fs.appendFileSync('/tmp/debug.ndjson',
    JSON.stringify({ ...data, ts: Date.now() }) + '\n'
  );
};
log({ hypothesisId: 'H-A', location: 'server.js:55', data: { userId, status } });

埋点代码规范

1. 用折叠区域包裹,保持代码整洁

js
// #region agent log
try { localStorage.setItem('dbg_open', JSON.stringify({ containerH })); } catch(e) {}
// #endregion

2. 静默失败,绝不影响主逻辑

js
// 正确:包裹 try-catch,fetch 用 .catch(()=>{})
try { localStorage.setItem(...) } catch(e) {}
fetch(...).catch(() => {});

// 错误:日志代码抛错会掩盖真正的 bug
localStorage.setItem(...); // 如果 localStorage 满了会抛错

3. 不记录敏感数据

js
// 错误:记录 token、密码、用户隐私
log({ token: user.token, password: form.password });

// 正确:只记录结构和状态
log({ tokenLength: user.token?.length, hasPassword: !!form.password });

4. 每次新一轮调试前清空日志

js
// 清空所有调试键,避免旧数据干扰
Object.keys(localStorage)
  .filter(k => k.startsWith('dbg_'))
  .forEach(k => localStorage.removeItem(k));

5. 验证通过后立即删除埋点代码

调试代码不提交到生产,确认修复后删除所有 #region agent log 块。


fetch POST 的完整操作方式

基本模板

js
fetch('http://127.0.0.1:PORT/ingest/SESSION_ID', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    hypothesisId: 'H-A',
    location: 'file.js:42',
    message: '描述',
    data: { key: value },
    ts: Date.now()
  })
}).catch(() => {}); // 必须加,静默失败

使用前提

需要一个本地 HTTP 服务来接收日志。Cursor Debug 模式会自动启动这个服务, 端口(如 7772)和 Session ID(如 d91968)会在系统提示里自动注入, 直接使用注入的值即可,无需手动配置。

两个关键点

1. .catch(() => {}) 必须加

端口不通时 fetch 会抛 Promise rejection,不加 catch 会在控制台产生报错, 甚至影响主逻辑执行。

2. 浏览器有跨域 / CSP 限制

Shopify 开发预览域名(*.myshopify.com*.shopifypreview.com)发出的请求 会被浏览器的 CORS 策略或 Content Security Policy 阻断,导致日志静默丢失。 这就是本次调试时日志全部丢失的根本原因。

什么时候用 fetch,什么时候用 localStorage

场景推荐方式
本地 HTML 文件(file://localhostfetch POST ✅ 实时可见
Shopify / 线上域名预览localStorage ✅ 最可靠
Node.js / 服务端代码文件写入 ✅
需要流式实时查看日志fetch POST ✅

降级原则: 能用 fetch 就用(实时);不能用就降级 localStorage(最可靠); 永远不要让日志工具本身阻塞调试进度。


实战教训

日志服务端口(localhost:7772)在浏览器环境无法访问(跨域/端口未开放), 导致第一轮 fetch 日志全部丢失。

降级原则: 遇到日志无法送达时, 优先选择最简单可靠的存储方式,不要阻塞调试进度。