前端调试
前端调试
页面白屏、按钮点了没反应、接口数据不显示,这些问题光盯着代码看,多数时候是看不出来的。这篇笔记记录我自己摸索出来的排查路子:浏览器自带的开发者工具(也就是常说的 F12)里每个面板管什么,以及出问题时按什么顺序查。
读之前需要 HTML、CSS、JS 三篇的基础,最好已经跟着前端完整示例写过一个能跑的 Todo,因为调试只有对着真实页面练才学得会。
F12 是什么
写 Java 时程序出问题,我习惯看控制台、翻日志,或者在 IDEA 里打断点。刚写前端时我愣了一下:代码跑在浏览器里,日志在哪?后来才知道浏览器把整套调试工具都内置好了,叫开发者工具(DevTools)。按 F12 打开,或者 Ctrl + Shift + I,也可以在页面任意位置右键选“检查”。
打开后顶部有一排标签页,常用的是这五个:
- Console:控制台。看报错、打日志,前端版的
System.out.println - Elements:查看页面当前的 HTML 结构和每个元素生效的 CSS,还能当场改
- Network:页面发出的每一个网络请求都列在这里
- Sources:看 JS 源码、打断点单步执行,功能上对标 IDEA 的 Debug 视图
- Performance / Memory:查卡顿和内存泄漏,进阶再用
下面按我的使用频率从高到低一个个过。
Console:出问题先看这里
页面行为不对,第一反应永远是打开 Console 看有没有红色报错。JS 有个特点:一段脚本里某一行抛了错,这段脚本后面的代码就全不执行了,所以经常表现为“从某个操作开始整个页面都没反应”。报错信息右侧有 文件名:行号,点一下直接跳到出错的那行源码。
几个我常遇到的报错,先混个脸熟:
代码块收起展开
Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')意思是对 null 调用了 addEventListener,基本都是 querySelector 没找到元素:要么选择器写错了,要么 <script> 放在了元素前面,脚本执行时元素还没被浏览器解析出来(JS 篇讲过,<script> 放在 </body> 前面,或者加 defer)。
代码块收起展开
Uncaught ReferenceError: xxx is not defined变量名拼错了,或者用了还没声明的变量。
主动打日志
console.log 就是前端的 System.out.println,还更好用一些:打印对象时能整个展开看结构,数组、嵌套对象都一目了然。除了 log 之外还有一批变体,值得记几个:
代码块收起展开
// 对象数组用表格显示,比 log 直观得多
console.table([
{ name: '张三', age: 25 },
{ name: '李四', age: 30 }
])
// 一组相关日志折叠成一组,控制台不会被刷花
console.group('用户信息')
console.log('姓名:', '张三')
console.log('年龄:', 25)
console.groupEnd()
// 测一段代码的耗时
console.time('数据加载')
await fetchData()
console.timeEnd('数据加载') // 数据加载: 234.56ms
// 只在条件为 false 时输出,相当于埋一个检查点
console.assert(user.age > 0, '年龄不该小于等于 0')
// 数一数这行代码被执行了几次
console.count('点击次数')Console 本身还是一个可以直接敲 JS 的交互环境。比如敲一句 localStorage.getItem('todos') 看看存了什么,验证个小想法完全不用改代码。
Elements:样式不生效就查它
Elements 面板左边是页面实时的 HTML 结构,右边 Styles 区域列出选中元素身上所有的 CSS 规则。左上角有个箭头图标(快捷键 Ctrl + Shift + C),点一下再点页面上的任意元素,就能选中它。
我主要用它干两件事。一是排查“样式为什么不生效”:Styles 里被划横线的属性,就是被更高优先级的规则覆盖了,能直接看到是被哪条规则覆盖的(选择器优先级在 CSS 篇讲过:内联样式 > ID > 类 > 标签)。
二是当场试样式:在 Styles 里直接改数值、加属性,页面立即跟着变,调到满意再抄回代码里,比改一次代码刷新一次页面快得多。
顺带一提,Elements 里看到的是 JS 操作之后的实时 DOM,和右键“查看网页源代码”看到的原始 HTML 可能不一样。排查“JS 到底有没有把这个元素改掉”时,这点很有用。
Network:请求到底发出去没有
数据不显示,第二步就是看 Network 面板:请求发了吗?状态码是多少?返回了什么?
刷新页面,面板里会列出页面加载的所有资源和 fetch 请求。点开某个请求,右侧几个标签页各管一块:
代码块收起展开
Headers 请求头、响应头、状态码
Preview 格式化后的响应内容(JSON 会展开成树)
Response 原始响应文本
Timing 这个请求每个阶段各花了多久以前调后端接口,我都是用 Postman 发请求、翻服务端日志,等于站在服务器那边看。Network 面板是站在浏览器这边看同一件事,请求的完整报文、响应体、耗时全都有,前后端联调时两边对照着看特别方便。
请求太多时可以在过滤框里筛:
代码块收起展开
method:POST 只看 POST 请求
domain:api.example.com 只看这个域名
status-code:404 只看 404还有两个实用功能:Throttling 下拉框选 Slow 3G 可以模拟慢网络,测试加载中状态和超时逻辑;在请求列表上右键选 Save all as HAR with content,能把所有请求导出成一个文件发给别人一起排查。
Sources:像 IDEA 一样打断点
console.log 排查简单问题够用,逻辑复杂起来还是得断点单步。Sources 面板就是干这个的,体验和 IDEA 的 Debug 很接近:点行号打断点,代码执行到那里就停住,鼠标悬停在变量上能看当前值。差别是它不需要任何配置,刷新页面就能触发。
停住之后的操作快捷键:
代码块收起展开
F8 继续执行,跑到下一个断点(IDEA 里的 Resume)
F10 单步跳过,不进入函数内部(Step Over)
F11 单步进入函数内部(Step Into)
Shift+F11 跳出当前函数(Step Out)右侧的 Watch 面板可以添加表达式实时观察,比如 items.length、localStorage.getItem('token')。
普通断点之外还有几种,遇到对应场景再回来查:
- 条件断点:右键行号选 Add conditional breakpoint,填一个表达式,为真才停。循环跑一千次、只想看某一次的时候特别有用
- DOM 断点:在 Elements 里右键元素选 Break on,元素的子节点、属性被修改或元素被删除时停住,专治“不知道是哪段代码动了这个元素”
- XHR/Fetch 断点:Sources 右侧 XHR Breakpoints 里填 URL 片段,发出匹配的请求时停住
常见故障排查
下面是我自己踩过或者早晚会踩的几类问题,全部只用到目前学过的纯 JS 知识。
请求发了,拿到的却是 undefined
十有八九是异步没等。fetch 把请求发出去之后,代码不会停下来等结果,下一行立刻就执行了:
代码块收起展开
// 问题写法:console.log 执行时,请求还在路上
function loadData() {
let data
fetch('/api/data')
.then(res => res.json())
.then(d => { data = d })
console.log(data) // undefined
}
// 正确写法:async/await,等结果回来再往下走
async function loadData() {
const res = await fetch('/api/data')
const data = await res.json()
console.log(data) // 正常拿到
}异步的来龙去脉 JS 篇讲过,忘了就回去翻JavaScript基础。
数据改了,页面没变
纯 JS 阶段这不算 bug,是常态:JS 变量和页面之间没有自动同步,改了变量之后必须手动操作 DOM 去更新页面(Todo 示例里 render 函数干的就是这件事)。
排查时先 console.log 确认数据真的变了,再检查是不是漏了更新 DOM 的那一步。
这种“每改一次数据都要手动刷一遍页面”的麻烦,正是后面 Vue 要解决的核心问题。
连续搜索,旧结果覆盖新结果
搜索框每敲一个字发一次请求时,先发的请求可能后返回。比如先搜“ja”再搜“java”,“ja”的结果慢半拍回来,把“java”的结果盖掉了。这类问题叫竞态,特征是“偶尔不对,多试几次又好了”。解决思路是发新请求前把旧请求取消掉:
代码块收起展开
let controller = null
async function search(keyword) {
// 上一个请求还没回来,就先取消它
if (controller) controller.abort()
controller = new AbortController()
try {
const res = await fetch('/api/search?q=' + keyword, {
signal: controller.signal // 把“取消开关”挂到这个请求上
})
const data = await res.json()
renderResults(data)
} catch (err) {
if (err.name === 'AbortError') return // 被主动取消的,不算错误
console.error(err)
}
}定时器回调里 this 不对
代码块收起展开
// 问题写法:普通 function 的 this 在这里指向 window
setTimeout(function () {
this.count++ // 改的其实是 window.count
}, 1000)
// 正确写法:箭头函数没有自己的 this,沿用外层的
setTimeout(() => {
this.count++
}, 1000)页面越用越卡
两个方向。一是每次交互干的活太多,典型的是 scroll 这类高频事件里做大量 DOM 操作:
代码块收起展开
// 问题写法:滚动事件一秒触发几十次,每次都全量改几百个节点
window.addEventListener('scroll', () => {
items.forEach(item => {
item.style.opacity = computeOpacity(item)
})
})
// 改进:用时间戳限制频率,200ms 内最多处理一次(这招叫节流)
let last = 0
window.addEventListener('scroll', () => {
const now = Date.now()
if (now - last < 200) return
last = now
updateVisibleItems()
})排查这类卡顿用 Performance 面板:点录制,操作页面,停止,看火焰图(按函数调用层级上下堆叠的耗时条形图)里 Main 线程上标黄标红的长任务,展开调用栈就能找到耗时的函数。
二是内存泄漏,典型症状是页面开久了越来越慢甚至崩溃。最常见的源头就三类:没清理的 setInterval、没解绑的事件监听、被闭包一直引用着的大对象。闭包指函数记住了自己外层的变量,只要函数还被引用着,这些变量就回收不掉,前端核心的题集里有专门一题。
代码块收起展开
// 问题写法:弹窗打开时启动定时器,关闭时没人清理它
function openDialog() {
setInterval(updateTime, 1000)
}
// 正确写法:留住定时器编号,关闭时清掉
let timer = null
function openDialog() {
timer = setInterval(updateTime, 1000)
}
function closeDialog() {
clearInterval(timer)
}确认是否泄漏用 Memory 面板:Take snapshot 拍一张堆快照,把可疑操作(比如开关弹窗)重复十次,再拍一张,切到 Comparison 模式对比两张快照,看哪类对象的数量只增不减,泄漏源就在它身上。
Vue 项目的调试(学到 Vue 篇再回来看)
这一节涉及 Vue。我第一遍读到这里时直接跳过了,学完 Vue 主线(从Vue3开始)再回来看正好。
Vue DevTools
一个浏览器扩展,装好之后 F12 里会多出一个 Vue 面板:能看到整棵组件树,点开任意组件就能看它的响应式数据、props、computed,还能直接改值实时看页面变化。
用了 Pinia 的话还能看每个 Store 的当前状态和修改历史,点历史记录可以让状态回到之前某个时刻(所谓时间旅行调试)。
查“数据到底是不是我以为的那个值”,比到处 console.log 干净得多。
几个 Vue 特有的坑
列表用索引当 key,删除后内容错乱。 v-for 的 :key 如果用了 index,删掉第一项后索引整体前移,常见症状是列表里输入框的内容对不上号。用数据自带的唯一 id 当 key。详见01-Vue3核心与响应式。
路由参数变了,页面数据没刷新。 从 /user/1 跳到 /user/2 时组件被复用,onMounted 不会再执行一次,要改成监听参数变化:
代码块收起展开
watch(() => route.params.id, (newId) => {
loadUser(newId)
}, { immediate: true })路由的完整内容在03-Vue-Router与权限路由。
scoped 样式改不到子组件。 <style scoped> 只作用于当前组件,想影响子组件内部要用 :deep(.title) 这样的深度选择器。
接口返回 401 了,页面却没跳登录页。 Network 里能看到 401,说明后端行为正常,问题出在前端对 401 的处理上,通常是 axios 响应拦截器逻辑没写对,或者拦截器里用的 router 忘了导入。
请求层的封装见05-工程化、请求层与常用控件。
排查顺序
工具都认识之后,真正值钱的是查的顺序。我给自己定的流程:
- Console 有没有红色报错,有就先解决它
- Network 里请求发了吗,状态码对吗,响应体是预期的吗
- 数据本身对吗,用 console.log(Vue 项目用 Vue DevTools)确认
- 数据对但页面不对,查渲染那段逻辑,必要时打断点单步走
- 功能都对但是卡,上 Performance 和 Memory
前两步就能解决大部分问题。实在定位不到,还有一招土办法:注释掉一半代码看问题还在不在,在就继续对半砍,几轮下来总能把问题圈出来。