模块四 · 协作与表达
测试与调试:把问题缩小到一行
调试能力决定了修复速度。这一节讲清断点与网络面板的正确用法、错误监控的三层捕获、最小单测的写法,以及"先复现再二分"的排查顺序。
读完这一节,你能做到
- 用条件断点与日志点替代满屏
console.log。 - 从 Network 与 Performance 面板判断问题属于哪一层。
- 用
try/catch、onerror、unhandledrejection三层捕获线上错误。 - 用 Vitest 给纯函数写可维护的单测。
一、调试的第一原则:先复现
没有稳定复现步骤的 bug,修好了也不知道是不是真的修好。复现要记录四件事:
| 要素 | 例子 |
|---|---|
| 环境 | Chrome 120 / iOS Safari / 特定账号 |
| 步骤 | 打开列表 → 切到第 3 页 → 删除最后一条 |
| 现象 | 列表变空,但总数没变 |
| 期望 | 回退到第 2 页并刷新 |
能稳定复现后,再按"由外到内"缩小范围:
页面整体 → 哪个组件/模块 → 哪个函数 → 哪一行
二分法是缩小范围最有效的手段:注释掉一半逻辑、在中间打日志、用 git bisect 找引入问题的提交。
二、断点比 console 强在哪
| 手段 | 优点 | 局限 |
|---|---|---|
console.log | 随手可用 | 要改代码、会刷屏、异步时序难看清 |
| 普通断点 | 能看调用栈与作用域 | 循环里会停很多次 |
| 条件断点 | 只在满足条件时停 | 条件表达式写错会一直不停 |
| 日志点(Logpoint) | 不暂停、不改代码、能打表达式 | 不能交互修改变量 |
debugger 语句 | 精确定位 | 忘删会阻塞用户 |
三个实用技巧:
- 条件断点:在循环里写
item.id === "123",只在目标数据上停。 - 日志点:右键行号选 "Add logpoint",输入
JSON.stringify(state),页面不中断。 - 异步调用栈:Sources 面板勾选 Async,能看到
await之前是谁调用的。
三、三类问题,三个面板
| 现象 | 先看 | 关键信息 |
|---|---|---|
| 接口 404/500 | Network | 请求 URL、状态码、响应体、请求头 |
| 页面白屏/报错 | Console | 第一条红色错误,不是最后一条 |
| 交互卡顿 | Performance | 长任务、强制同步布局、主线程占用 |
| 内存持续上涨 | Memory | 堆快照对比、Detached DOM |
| 样式不对 | Elements → Computed | 最终生效的规则与覆盖来源 |
Network 面板要看四个字段:状态码、耗时(TTFB/内容下载)、尺寸(是否命中缓存)、响应体。很多"接口慢"其实是前端在等锁、重复请求或串行瀑布。
Console 的第一条错误往往才是根因,后面的错误多是它的连锁反应。先修第一条。
四、线上错误怎么捕获
// 1. 同步运行时错误
window.addEventListener("error", (e) => {
report({
type: "error",
message: e.message,
stack: e.error?.stack,
file: e.filename,
line: e.lineno,
});
});
// 2. 未处理的 Promise 拒绝
window.addEventListener("unhandledrejection", (e) => {
report({
type: "unhandledrejection",
reason: String(e.reason),
stack: e.reason?.stack,
});
});
// 3. 资源加载失败(img/script/link)
window.addEventListener(
"error",
(e) => {
const el = e.target;
if (el instanceof HTMLImageElement || el instanceof HTMLScriptElement) {
report({ type: "resource", tag: el.tagName, src: el.src });
}
},
true, // 资源错误不冒泡,必须用捕获
);
三个细节:
- 资源加载错误不冒泡,必须用捕获阶段监听,这是最常见的遗漏。
- 跨域脚本的错误信息会被浏览器脱敏成 "Script error.",需要脚本响应头带
crossorigin且服务端允许 CORS。 - 上报本身要有兜底:
sendBeacon在页面卸载时更可靠;上报失败不能影响业务。
线上堆栈是压缩后的,需要 Source Map 还原。Source Map 不要公开部署,上传到监控平台,或放在需要鉴权的路径。
五、最小单测:只测纯逻辑
import { describe, it, expect } from "vitest";
import { formatAmount } from "@/utils/format";
describe("formatAmount", () => {
it("保留两位小数并加千分位", () => {
expect(formatAmount(1234.5)).toBe("1,234.50");
});
it("处理 0 与负数", () => {
expect(formatAmount(0)).toBe("0.00");
expect(formatAmount(-12)).toBe("-12.00");
});
it("非法输入返回占位符", () => {
expect(formatAmount(Number.NaN)).toBe("--");
});
});
写测试的顺序建议:
- 先写正常路径。
- 再写边界:0、空、负数、超长。
- 最后写异常:
NaN、null、接口缺字段。
三条原则:
- 测行为不测实现:断言输出,不断言内部调用了几次。
- 一个用例一个断言主题:失败时能直接看出问题。
- 测试名描述业务规则:"保留两位小数并加千分位"比"test1"有用。
涉及定时器要用假时钟:
import { vi } from "vitest";
vi.useFakeTimers();
debounced("a");
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledWith("a");
vi.useRealTimers();
六、调试顺序:一张排查表
| 步骤 | 做什么 | 产出 |
|---|---|---|
| 1 | 稳定复现 | 步骤、环境、频率 |
| 2 | 确认现象属于哪一层 | 网络 / 渲染 / 逻辑 / 数据 |
| 3 | 缩小范围 | 二分、条件断点、日志点 |
| 4 | 提出假设并验证 | 一次只改一个变量 |
| 5 | 修复并回归 | 补一条测试或写清验证步骤 |
最常见的错误是跳过第 2 步,直接改代码。改了三处之后问题消失,却不知道是哪一处起的作用,下次还会复现。
七、常见坑
- 满屏
console.log,异步日志顺序看错,得出错误结论。 - 只看最后一条错误,忽略第一条根因。
- 资源 404 用冒泡监听,永远收不到。
- 线上只上报 message 不上报 stack,无法定位。
- Source Map 公开部署,源码泄露。
- 测试断言内部函数调用次数,重构就红。
- 用
setTimeout等异步,CI 上随机失败。 - 修完不补测试,同样的问题下次换个入口再来一遍。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个循环 1000 次的函数,用条件断点只停在
id === "500"那次,并打出当时的完整状态。 - 给页面加三层错误监听,故意触发资源 404 与未处理 Promise,确认都能上报。
- 给两个工具函数写单测,覆盖正常、边界、异常三类输入。
- 用 Performance 录制一次滚动,找出一个长任务并说明优化方向。
② 让 AI 写同一题
把第 2 题(定位一个偶发 bug)交给 AI,补一句 先给复现与缩小的步骤,再给修法。
③ 对比要盯的点
- 它是不是直接给了“可能的修复”,跳过了复现?
- 定位手段它是二分、条件断点,还是加一堆
console.log? - 它有没有区分“复现了”和“猜想是对的”?
④ 一句话取舍
它给的修法,你能设计一个用例证明 bug 真的没了吗?
九、自测
- 条件断点与日志点分别适合什么场景?
- 资源加载错误为什么必须用捕获阶段?
- 为什么只看最后一条控制台错误是错的?
- 跨域脚本错误为什么变成 "Script error."?
- 单测应该断言什么,不该断言什么?
参考答案要点:循环内精确暂停 / 不中断打日志;资源错误不冒泡;第一条往往是根因;浏览器脱敏;断言输出行为,不断言实现细节。
附:Performance 面板的三个信号
录制一次交互后,火焰图信息量很大,但只需盯三个信号就能判断问题类型:
| 信号 | 表现 | 说明 | 优化方向 |
|---|---|---|---|
| 长任务 | 超过 50ms 的黄色块 | 主线程被占住,输入无法响应 | 拆分任务、减少同步计算、用 Worker |
| 强制同步布局 | 紫色 Layout 块带警告三角 | JS 读布局后又写样式,反复触发 | 先读后写,批量修改 |
| 大量重绘/合成 | 绿色 Paint 块密集 | 频繁改颜色、阴影、透明度 | 用 transform/opacity,减少绘制面积 |
三者的排查顺序不同:
- 输入卡顿先看长任务。如果长任务里是业务计算,考虑分片;如果是一次大循环渲染,考虑虚拟列表。
- 滚动卡顿先看布局与绘制。列表项用了
box-shadow或filter,滚动时每帧都在重绘。 - 动画卡顿先看是否动了布局属性。改
top、height一定比transform慢。
两个实用技巧:
- Performance 面板的 “Bottom-Up” 视图按耗时排序,能直接看到哪个函数最贵,比在火焰图里找更省时间。
- 录制时勾选 “Screenshots”,能对应看到每一帧的画面,判断掉帧发生在哪个交互阶段。
最后一条经验:先确认瓶颈在渲染层还是网络层。包体从 2MB 降到 1MB 但首屏没变快,说明瓶颈在网络延迟或服务端响应,继续减包没有意义。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解