假设驱动调试方法论(Hypothesis-Driven Debugging)
核心原则
绝不依赖代码猜测,只依靠运行时数据做决策。
传统调试思路:看代码 → 猜原因 → 改代码 → 希望有效。 这种方式失败率高,因为代码只告诉你"写了什么",不告诉你"运行时实际发生了什么"。
完整流程
第一步:生成假设(多个,并行)
不是直接找原因,而是先列出 3-5 个可能导致问题的原因,并给每个假设编号(H-A、H-B...)。
要求:
- 假设要具体,说明"为什么"而不仅是"什么"
- 假设要覆盖不同子系统(CSS、JS 状态、DOM 结构、时序…)
示例(本次滚动失效 bug):
- H-A:
overscroll-behavior阻断了滚轮事件 - H-B:容器
height: auto导致无滚动区域 - H-C:scroll 监听绑定时序错误
- H-D:
window.onresize被覆盖引发错误
第二步:最小化埋点(并行验证所有假设)
在关键位置插入日志,每条日志标明它验证的假设编号。
关键原则:
- 最少的日志覆盖最多的假设(3 条日志验证 4 个假设)
- 记录函数入口/出口的关键值,不记录无关数据
- 前端用
localStorage,后端用文件追加
// 验证 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 }));
};第三步:让用户复现,收集数据
- 清空日志(避免旧数据干扰)
- 让用户执行复现步骤
- 读取日志数据
// 浏览器控制台一键读取
['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 绑定逻辑
第六步:验证修复,再清除日志代码
保留日志代码,让用户再跑一次,对比修复前后数据。 确认成功后再删除调试代码。
对比表
| 对比点 | 猜测式调试 | 假设驱动调试 |
|---|---|---|
| 决策依据 | 代码静态分析 | 运行时实际数据 |
| 改动范围 | 可能改多处 | 只改有证据的地方 |
| 失败处理 | 不知道为何失败 | 日志指向下一步假设 |
| 可信度 | "应该有用" | 数据证明有用 |
日志工具选择原则
| 环境 | 推荐方式 |
|---|---|
| 浏览器 JS | localStorage.setItem() |
| Node.js / 后端 | fs.appendFileSync() 写 NDJSON |
| 服务器进程 | 标准输出 console.error() |
| 跨网络 | POST 到本地日志服务(端口不通时降级为 localStorage) |
埋点技巧与规范
埋点位置选择
每个埋点要对应至少一个假设,选择以下关键位置:
| 位置类型 | 用途 |
|---|---|
| 函数入口 + 参数 | 确认函数是否被调用、入参是否正确 |
| 函数出口 + 返回值 | 确认返回结果是否符合预期 |
| 关键操作前后 | 对比操作前后的状态变化 |
| 条件分支 | 确认走了哪条 if/else 路径 |
| 异步回调内部 | 确认异步任务是否真正执行了 |
| 可疑的边界值处 | 捕获空值、0、undefined 等异常状态 |
埋点 payload 结构
每条日志包含以下字段,方便事后过滤和分析:
{
hypothesisId: 'H-B', // 对应哪个假设
location: 'file.js:42', // 代码位置
message: '容器高度检查', // 简短描述
data: { containerH: 800 }, // 关键数据,只记录必要字段
timestamp: Date.now() // 时间戳,用于排序多条日志
}浏览器端埋点模板
方案一:localStorage(最可靠,推荐)
try {
localStorage.setItem('dbg_KEY', JSON.stringify({
hypothesisId: 'H-X',
data: { /* 关键值 */ },
ts: Date.now()
}));
} catch(e) {}读取:
// 控制台一次性读取所有调试键
Object.keys(localStorage)
.filter(k => k.startsWith('dbg_'))
.forEach(k => console.log(k, JSON.parse(localStorage.getItem(k))));方案二:POST 到本地日志服务(跨会话可用)
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(最简单,但不可持久化)
console.error('[DBG H-B]', { containerH: el.offsetHeight, scrollH: el.scrollHeight });后端/Node.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. 用折叠区域包裹,保持代码整洁
// #region agent log
try { localStorage.setItem('dbg_open', JSON.stringify({ containerH })); } catch(e) {}
// #endregion2. 静默失败,绝不影响主逻辑
// 正确:包裹 try-catch,fetch 用 .catch(()=>{})
try { localStorage.setItem(...) } catch(e) {}
fetch(...).catch(() => {});
// 错误:日志代码抛错会掩盖真正的 bug
localStorage.setItem(...); // 如果 localStorage 满了会抛错3. 不记录敏感数据
// 错误:记录 token、密码、用户隐私
log({ token: user.token, password: form.password });
// 正确:只记录结构和状态
log({ tokenLength: user.token?.length, hasPassword: !!form.password });4. 每次新一轮调试前清空日志
// 清空所有调试键,避免旧数据干扰
Object.keys(localStorage)
.filter(k => k.startsWith('dbg_'))
.forEach(k => localStorage.removeItem(k));5. 验证通过后立即删除埋点代码
调试代码不提交到生产,确认修复后删除所有 #region agent log 块。
fetch POST 的完整操作方式
基本模板
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:// 或 localhost) | fetch POST ✅ 实时可见 |
| Shopify / 线上域名预览 | localStorage ✅ 最可靠 |
| Node.js / 服务端代码 | 文件写入 ✅ |
| 需要流式实时查看日志 | fetch POST ✅ |
降级原则: 能用 fetch 就用(实时);不能用就降级 localStorage(最可靠); 永远不要让日志工具本身阻塞调试进度。
实战教训
日志服务端口(localhost:7772)在浏览器环境无法访问(跨域/端口未开放), 导致第一轮 fetch 日志全部丢失。
降级原则: 遇到日志无法送达时, 优先选择最简单可靠的存储方式,不要阻塞调试进度。
