浏览器调试方法:别只会 console.log
组里刚转正的一个新人这周开始独立跟需求,遇到问题的第一反应几乎都是往代码里加 console.log。接口返回不对,加一行;样式没生效,加一行;点击没反应,还是加一行。我帮他看一个「点击按钮没反应」的问题时,他已经加了七八处日志,输出摊了满满一屏,却还是没定位到——事件确实绑上了,回调也确实执行了,卡住的地方是回调里一个被同名变量覆盖的判断条件,这种问题日志能告诉你「走到这里了」,却告诉不了你「为什么没走到你以为的分支」。
这周正好利用中午和他过一遍 DevTools 里常用的几个面板,顺带把自己这几年攒的调试顺序整理一下。console.log 本身没问题,它只是工具箱里最初级的那一把,遇到复杂点的问题,需要换趁手的工具。
日志式排查有个天然的局限:你得先猜到问题可能出在哪一行,才知道该在哪加日志。可现实里很多问题恰恰是"猜不到在哪",样式被谁覆盖了不知道,请求卡在哪个环节不知道,页面为什么突然卡顿也不知道。这种"不知道从哪下手"的时候,DevTools 里每个面板其实对应的是一种"不用猜,直接看"的手段——Elements 能看真实渲染结果、Network 能看请求的完整生命周期、Sources 断点能看执行到某一行时的完整现场、Performance 能看时间都花在了哪、Application 能看存储和缓存的真实状态。挨个熟悉一遍,遇到问题时才能第一时间反应过来该打开哪个面板,而不是回到加日志这条老路上。
Elements 看的是真实 DOM,不是源码
样式问题第一步永远是打开 Elements,而不是回去看 .vue 或 .tsx 源文件。
组件库、条件渲染、插槽、指令都会在运行时改写最终结构,源码里写的 <div class="card"> 到了浏览器里可能被组件库包了三层,也可能因为 v-if 直接不存在。看真实渲染出来的 DOM,才是排查的起点。重点看这几件事:
元素是否真的存在于 DOM 里;class 是否按预期加上了;某条样式规则是否被划掉(划掉说明被更高优先级的规则覆盖,鼠标悬停能看到覆盖它的具体选择器);盒模型的四层尺寸是否符合预期;是否被父元素的 overflow: hidden 裁掉了一部分。
框架组件"看起来渲染了、实际没渲染"这类问题尤其考验对真实 DOM 的判断力。之前有个下拉菜单,业务代码里逻辑写得没毛病,v-if 条件也确认为真,但页面上就是看不到。打开 Elements 才发现节点确实生成了,只是组件库内部把它 teleport 到了 body 末尾一个独立的容器里,而这个容器被另一处全局样式设置了 z-index: -1。这种问题如果只盯着组件自己的模板代码看,永远找不到线索,因为源码层面完全正确,问题出在运行时真实的 DOM 位置和层叠上下文上。
今年 Chrome 稳定版里 Elements 面板新增了一个 CSS 网格可视化工具,选中 display: grid 的元素后,面板里会有个小图标,点开就能在页面上直接叠加网格线和轨道编号,调 grid-template-columns 的时候不用再靠肉眼数格子。这几个月中后台里用 Grid 布局做仪表盘的地方越来越多,这个工具省了不少来回改数值再刷新看效果的功夫。Flex 容器同样有类似的叠加层,选中 display: flex 的元素能看到主轴方向、对齐线,排查"为什么这几个卡片没有等宽"这类问题时,比在 Computed 面板里一条条翻 flex-basis、flex-grow 直观得多。
Styles 面板右上角那个加号能新建一条规则,写一个新的类名或者选择器进去,这个功能平时排查"我要验证如果这里加一条 !important 会不会解决"这种假设时很好用,不用去改源码再重新构建。上个月 Chrome 105 也跟进支持了 :has() 选择器(Safari 从 3 月就已经支持),Elements 面板顺带把父选择器高亮这类关联选择器的调试体验往前推了一步——写选择器验证匹配范围时,面板会实时把命中的元素在页面上高亮出来。这几周开始往项目里试探性地用 :has() 替代一些原来只能靠 JS 判断子元素状态再手动切父元素 class 的场景,这种"选择器对应关系可视化"的思路,对平时调试普通的组合选择器覆盖问题也一样有用。
有一类样式问题看着诡异,其实是被继承坑了:父元素设置了 color 或者 font-size,子元素没显式覆盖,于是"继承"了一个自己完全没写过的值。Computed 面板里点开某条计算后的属性,会显示这个值具体是从哪条规则、哪一层继承下来的,追继承链路比在样式表里人肉往上找父元素方便得多。
布局问题可以在 Elements 里临时改样式验证猜想,但验证完一定要回到代码里按照正确的方式改,而不是在 DevTools 里调到「看起来对了」就直接收工——这种改法过一次刷新就没了,而且往往没搞清楚到底是哪条规则的问题,下次同样的坑还会再踩一遍。
z-index 失效:层叠上下文比数值大小更重要
排查"明明 z-index 设置得比别人大,却还是被压在下面"这类问题,单看 Styles 面板里的数值经常看不出问题,因为 z-index 的比较只在同一个层叠上下文内部才有意义,跨层叠上下文比较数值大小是没有意义的。父元素只要设置了 transform、opacity 小于 1、filter、或者 will-change 这几类属性中的任意一个,就会创建一个新的层叠上下文,子元素的 z-index 从此只能在这个新上下文内部排队,不管数值写多大,都翻不出父级这层天花板。
Elements 面板里点开 Layers 标签(在更多工具里,不是默认展示的标签页),能看到当前页面被拆分成了哪些合成层,以及每一层是因为什么原因被单独提升出来的(会标注具体原因,比如 will-change、3D transform、position: fixed 之类)。之前团队里一个悬浮弹层被压在了页面主体下面,查了半天以为是弹层自己的 z-index 写小了,最后是在 Layers 面板里发现问题出在弹层的父容器上——为了做一个进场动画,父容器加了 transform: translateY(0) 的初始状态,这个属性本身就会创建新的层叠上下文,把弹层锁在了一个数值再大也逃不出去的小圈子里。找到这类问题,靠数值大小去猜是猜不出来的,得靠层叠上下文这个概念,再用 Layers 面板去验证。
Network 查的不只是接口返回了什么
Network 面板经常被当成「看 response body」的地方,但它能看到的信息远不止这些:请求到底有没有真的发出去、请求方法和参数、status code、cache 命中情况、timing 分解、资源体积、以及 initiator(这个请求是被谁发起的)。
接口慢的时候不要只看总耗时那一个数字。点开某条请求的 Timing 标签,能看到 DNS 解析、建立连接、SSL 握手、发出请求后等待响应(TTFB)、下载响应体分别花了多久。上周排查一个列表接口,前端同学一开始以为是接口本身慢,点开 Timing 才发现 TTFB 只占了十几毫秒,大头是 Content Download——服务端把没分页的全量数据一次性吐了回来,体积压过去了,这跟"接口逻辑慢"完全是两码事,排查方向也完全不同。
如果某个请求你确定该发出去,却在 Network 里根本找不到,原因通常是前端逻辑没走到这一步、被浏览器某个策略拦下来了、或者 CORS 预检(OPTIONS)没通过。这时候 Console 里往往会有对应的报错,Network 和 Console 一起看,比对着代码瞎猜靠谱得多。
CORS 报错是最容易让人抓瞎的一类,因为浏览器出于安全考虑,fetch/axios 拿到的错误信息非常笼统,大多数时候只在 Console 里甩一行"已阻止跨源请求"之类的提示,业务代码里的 catch 拿到的往往只是一个网络层面的失败,看不出具体是哪个 CORS 规则没满足。这种情况必须回到 Network 面板,把请求方式换成非简单请求触发的那条 OPTIONS 预检请求点开,看它的响应头里 Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers 具体返回的是什么——大多数联调环境的 CORS 问题,都是后端只放行了 GET/POST 里的某几个,前端新加了一个自定义请求头(比如埋点用的 X-Trace-Id),没有被加进 Access-Control-Allow-Headers 白名单,预检就直接被挡在门外,实际业务请求根本没有机会发出去。
缓存问题也要回到 Network 里确认。状态码是 200 不代表这次请求真的走了网络——Size 那一列如果显示 (memory cache) 或 (disk cache),说明是本地缓存命中,压根没发出去;如果背后挂了 Service Worker,还可能是 SW 拦截后自己返回的响应,这种情况 initiator 那一列会标出来,不看这一列很容易把"缓存生效"误判成"接口坏了"。
把整段加载过程的 Network 面板切到瀑布图视图,还能看出请求之间的依赖关系——哪些资源是并行发的,哪些是等前一个请求返回后才触发的。首屏慢的页面经常是这种串行链路在拖后腿:HTML 解析完才发现要请求一个配置接口,配置接口回来才知道用户信息接口该请求哪个,一层套一层。把这种依赖关系在瀑布图里标出来,再看能不能改成并行请求或者提前预取,比单纯说"接口要优化"更有说服力。
瀑布图左边那一栏的颜色也值得留意:蓝色是文档,黄色是脚本,紫色是样式表,绿色是媒体和图片。首屏加载排查的时候,把瀑布图从上到下扫一遍颜色分布,很容易发现"某个不影响首屏的图片资源排在了关键 CSS 前面"这类顺序问题——浏览器请求的发起顺序取决于 HTML 里资源出现的位置和有没有加 defer/async,顺序不对会直接拖慢首次渲染。
Network 面板右上角还能勾选节流选项,把网络模拟成慢速 3G 或者慢速 4G。团队里评审需求的时候经常会忽略弱网场景,平时办公室 Wi-Fi 下体感流畅的页面,切到慢速 3G 复现一遍,才能真切感受到某个接口没做 loading 态、或者某张没压缩的图片有多影响体验。这个开关比让人真的跑去信号差的地方测试方便太多。
请求头(Request Headers)和响应头(Response Headers)这两栏平时容易被忽略,但排查跨域、鉴权、缓存策略的时候是关键信息:Authorization 有没有带上、Content-Type 是不是和后端约定的一致、Access-Control-Allow-Origin 返回的域名对不对、Cache-Control 和 ETag 具体的取值是什么。有一次联调环境突然所有接口都返回 401,最后是在 Request Headers 里发现登录态刷新逻辑把 Authorization 头覆盖成了一个空字符串,这种问题光看 Console 报错文案看不出根源,得回到请求头本身。
某条请求右键菜单里的 Copy 系列选项平时用得也很勤:Copy as cURL 能把这次请求原样导出成一条 cURL 命令,发给后端同学复现问题时,比口头描述"我传了什么参数"精确得多,后端拿到命令直接在终端跑一遍,能立刻确认是前端传参有问题还是服务端处理有问题;Copy as fetch 导出的是一段能直接粘到 Console 里执行的 fetch 代码,验证"如果我把这个参数改一下,接口会不会返回不一样的结果"时,不需要跳回业务代码改了再触发一次交互,在 Console 里改完参数直接回车就有结果。Network 面板里还能对某条已经完成的请求点右键选择 Replay XHR,原样重新发一次,复现那种"必须先有一次成功请求才能触发的边界情况"时很方便。
过滤栏里输入 is:from-cache 能只看命中缓存的请求,输入具体的接口路径关键字能只看某一类接口,请求一多的页面(尤其是列表页一次性发几十个图片请求)不做过滤基本没法看,这个输入框比想象中更常被用到。
Sources 断点:复杂逻辑用它,别用日志硬扛
逻辑复杂的地方,到处加 log 只会把自己绕晕。断点能看到当前的调用栈、作用域里的变量、闭包捕获的值、以及异步调用链路上下文,这是打印语句给不了的。
常用的几种断点,用途不太一样:普通行断点适合已知大概位置、想看执行到这一行时的状态;条件断点适合循环里只想在某个特定值出现时停下来,不用手动点很多次继续;DOM 变化断点(attributes / subtree modification / node removal)适合"某个元素属性莫名其妙被改了但不知道是谁改的";XHR/fetch 断点适合"确定发了这个请求,但不知道是哪段代码触发的"。
前面提到的新人那个点击没反应的问题,最后是靠事件监听器断点定位的——在 Elements 面板选中按钮,Event Listeners 标签页里能看到绑定在它上面的所有 click 回调,一个个展开断点,点击时依次停在每个回调入口,很快就看到问题出在一个被同名局部变量覆盖的布尔判断上,回调其实正常执行了,只是提前 return 掉了。这种问题日志能告诉你"走到这里了",但很难告诉你"为什么这个分支没走"。
断点停住之后,Scope 面板会把当前作用域链摊开,Local、Closure、Global 分层列出来,比在代码里数缩进猜哪个变量属于哪层作用域直观得多。遇到闭包相关的问题——比如循环里用 var 声明的变量被异步回调捕获,最后全部指向循环结束后的值——把断点打在回调内部,展开 Closure 那一层,能直接看到当前捕获的是哪个值,不用在脑子里模拟执行顺序。
调用栈(Call Stack)面板同样值得多看一眼。断点停下来的时候,调用栈从上到下列出了"这一行代码是被谁调用的、又是被谁调用的",一路往上翻能看到最初的触发点。这在框架代码和业务代码交织的场景特别有用——比如一个 watch 回调莫名其妙触发了两次,调用栈能告诉你第二次触发到底是被哪个地方的赋值语句引起的,而不用在几十个 .vue 文件里一个个排查。
Sources 面板里还有个容易被忽略的功能是 Blackbox(黑盒脚本)。第三方库或者框架自身的代码经常很长,断点单步执行时会不小心跳进 node_modules 打包出来的代码里,把这些文件加入黑盒名单之后,单步执行会自动跳过它们,直接停在你自己写的业务代码里,省去很多来回按"跳出"的操作。
异步代码打断点容易犯一个直觉上的错——以为断点会按代码书写顺序一步步停下来,实际上 await 之后的代码是排到微任务队列里等当前同步代码跑完才执行的。举个具体例子:
1async function loadDetail(id) { 2 console.log('start', id) 3 const res = await fetchDetail(id) 4 console.log('got data', res.data) 5 return res.data 6} 7 8loadDetail(1) 9loadDetail(2) 10console.log('sync end')
如果在 console.log('got data', ...) 那一行打断点,两次调用停下来的顺序不一定是 1 先到、2 后到,取决于两次 fetchDetail 各自的响应先后。断点停住之后,调用栈这时候往往显示成一段异步栈(标注着 (async) 分割线),而不是一路连续的同步调用链,这一点第一次看容易懵。Sources 面板里勾选 Async(部分版本叫 Async stack traces)之后,调用栈会把触发这次异步调用的原始位置也补全显示出来,不然只看断点当下这一帧,压根看不出这次调用最初是从哪个用户操作触发的。
排查竞态问题(旧请求覆盖新页面这类)的时候,给 fetchDetail 内部打条件断点、加上 id === 2 这种条件,能只在关心的那次调用上停下来,不用两次调用都手动跳过一遍无关的那次。
Performance 面板:卡顿不能靠感觉判断
页面感觉卡,不能凭经验去猜"是不是组件太多了"或者"是不是没做防抖",得录一段 Performance 火焰图。
录制方式很简单:点击左上角圆点开始录制,做一遍复现卡顿的操作,停止录制,面板会给出主线程(Main)时间轴、每一帧的耗时、以及一段调用栈的火焰图。重点看这几个地方:主线程上是不是有超过 50ms 的长任务(火焰图上会标红色小三角);Scripting、Rendering、Painting 各自占的比例;火焰图里哪个函数块最宽,那就是耗时的大头;是否触发了多次强制同步布局(Recalculate Style 和 Layout 交替出现,并且带有紫色警告标记,通常是先写后读几何属性导致的布局抖动)。
上个月排查一个表格页面切换 tab 卡顿,一开始怀疑是接口慢,录了一段火焰图才发现接口早就回来了,真正占满主线程的是切换后触发的一次全量重新渲染,里面有个没做 key 优化的长列表整个重新创建了 DOM 节点。Performance 面板最大的价值就是能把"接口慢"和"渲染慢"这两件经常被混为一谈的事情彻底分开,不用靠猜。
火焰图看多了会发现,长任务不一定是某个函数本身写得慢,很多时候是同一个函数被高频调用了几百次——比如没做节流的 scroll 回调、或者每次输入都触发的全量校验。这种情况火焰图上会是密密麻麻一排差不多宽的小色块,而不是一个特别宽的大色块,判断思路和优化单个慢函数完全不一样,后者得靠算法或者缓存,前者靠的是控制调用频率。
火焰图上方那条 FPS 曲线也值得留意,曲线掉到红色区域就意味着这段时间掉帧了,配合下面 Main 时间轴上同一时间点的调用栈,能直接定位是哪次操作引起的掉帧,不用整段录制完之后再回头对时间轴。Performance 面板录制的时候勾选上 Screenshots 选项,还会在时间轴上方铺一排页面截图,某一帧画面卡住的时候,直接能看到当时页面停在什么状态,配合下面的调用栈一起看,复现路径想不起来的时候特别有用。
如果怀疑卡顿和内存有关——比如页面开着开着越来越卡、最后甚至卡死——用的是 Memory 面板而不是 Performance。录一段 Allocation instrumentation 或者直接前后各拍一次堆快照(Heap Snapshot)做对比,能看到哪些对象数量在持续增长。之前排查过一个后台页面切换路由几次之后越来越卡,堆快照对比下来发现是每次进入页面都新建了一个定时器,离开页面时组件卸载了但定时器没清掉,持有的闭包引用越攒越多,这类问题日志和火焰图都不容易发现,得靠内存快照的对比。
具体操作是先在稳定状态下拍一次快照当基准,然后重复几次"进入页面、离开页面"这个操作,再拍第二次快照,用 Comparison 视图对比两次快照,按对象数量增长排序,增长最多的构造函数名字通常就直接暴露了问题——是某个 DOM 节点没有被回收、还是某个自定义类的实例在堆积。第一次看堆快照的界面会有点陌生,里面 Shallow Size 和 Retained Size 这两个概念容易搞混:前者是这个对象自己占的内存,后者是把它删除后能连带释放掉的内存总量(包含它独占引用的下游对象),真正要找的内存大头,看 Retained Size 排序更准。
Application 面板:存储和缓存的落地情况
Application 面板里能查到 localStorage、sessionStorage、IndexedDB、Cookie、Cache Storage、Service Worker 这几类数据的实际状态。登录态莫名丢失、主题设置不生效、页面用的还是离线缓存的旧版本,这类问题都得从这里查起,光看 JS 代码逻辑经常看不出问题,因为代码逻辑是对的,只是实际存储的数据和预期不一样。
牵扯到 Service Worker 的问题尤其要靠这个面板。团队内部工具用了 Service Worker 做静态资源离线缓存,发布新版本后偶尔有同事反馈页面还是旧样式,十有八九是旧的 SW 还在控制页面——Application 里的 Service Workers 那一栏能看到当前生效的 SW 版本和状态,是不是卡在 waiting(等待旧页面全部关闭才会激活)一目了然;Cache Storage 里能直接展开看某个缓存名下具体存了哪些资源,是不是该失效的没失效。遇到这种情况,手动点一下 Update 或者勾选 Bypass for network 之后刷新,通常就能确认到底是不是缓存的问题,而不用先怀疑代码。
localStorage 和 sessionStorage 这两栏支持直接双击某个键去改值,验证"如果这个字段变成另一个值,页面会不会变成我猜的那样",不用在 Console 里敲 localStorage.setItem 再手动刷新。IndexedDB 那一栏能展开具体的对象仓库(object store),按索引浏览存进去的记录,调试离线数据、草稿箱这类功能的时候比只靠代码打印一整个对象方便,尤其是数据量大的时候,面板里能分页看,不用把几百条记录一次性塞进 Console 输出里。
Application 面板左侧还有个 Frames 分类,能看到当前页面加载了哪些资源、每个 iframe 各自的情况。嵌入了第三方页面或者拆分了微前端子应用的场景,排查"到底是主应用的样式还是子应用的样式生效了",从这里能理清楚层级关系。
Cookie 那一栏排查登录态问题时也有讲究,不只是看值对不对,HttpOnly、Secure、SameSite 这几个属性同样关键。有个真实的坑是内部工具挂在一个子域名下,登录态 Cookie 设置的时候域名写死成了主域名而不是带通配符的形式,导致子域名页面读不到这个 Cookie,表现出来就是"明明登录了,一刷新又要求重新登录",这种问题光看请求带没带 Cookie 头看不出根源,得回到 Application 面板里 Cookie 那一栏,把域名、路径这几列展开对照才能看清楚。
Source Map:生产环境报错定位到具体源码行
线上错误监控上报的堆栈,默认是压缩混淆之后的行号列号,类似 app.a3f21c.js:1:48213,人是看不出来对应源码哪一行的。构建的时候如果打开了 Source Map(生产环境一般只上传到错误监控平台,不对外暴露,避免源码泄露),DevTools 里手动把对应的 .map 文件挂上,或者直接在 Sources 面板里打开一条压缩后的报错堆栈,浏览器会自动把它映射回原始的 .vue 或 .ts 文件和具体行号,断点也能直接打在源码上,而不是在压缩后那一整行几千个字符里找。
这一步经常被忽略,团队里之前有个同学线上报错查了大半天,后来发现只是本地开发环境切换到了不带 sourcemap 的生产构建产物,浏览器里堆栈全是乱码般的坐标,配置一下 devtool: 'source-map'(或者根据体积权衡换成 hidden-source-map,不在浏览器里直接暴露源码,只用于上传监控平台解析)问题立刻就清楚了。
Source Map 也不是配上就万事大吉,版本对不上是最容易踩的坑:发布了新版本却忘记同步上传对应的 .map 文件到监控平台,或者监控平台按文件 hash 匹配、但构建流程里 hash 生成方式改过,都会导致映射出来的行号对不上,反而比看压缩代码更容易误导排查方向。团队现在的做法是把 Source Map 上传这一步做进发布流水线,和构建产物一起打包上传,避免人工操作漏掉这一步。
Rendering 面板:把重绘和布局偏移画出来
Elements 面板边上还有个不太起眼的 Rendering 标签(命令面板搜 Rendering 就能唤出),里面几个勾选项平时用得不算特别频繁,但排查特定问题时很直接。Paint flashing 勾上之后,页面上任何触发重绘的区域都会被绿色方框实时闪一下,滚动页面时如果发现一大片区域跟着频繁闪烁,说明这块内容的重绘范围没有控制好,可能是某个 position: fixed 的元素被不必要地重新计算了,也可能是一个大面积的阴影或者滤镜效果每帧都在重绘。
Layout Shift Regions 勾上之后,凡是触发了布局偏移的区域都会短暂显示蓝色高亮,图片没有提前设置宽高占位、字体加载完成后文字换了个体积、广告位异步插入挤开了下面的内容,这些制造布局抖动的元凶都能在这个视图里直接看到是哪块区域在跳。今年 Core Web Vitals 里的 CLS(累积布局偏移)已经是团队做性能优化时会主动去关注的一项指标,光看最终的分数不知道具体是哪个元素造成的,打开这个开关复现一遍页面加载过程,问题区域会自己亮出来。
Rendering 面板里还有个 FPS meter,悬浮在页面角落实时显示当前帧率,配合上面提到的 Paint flashing 一起开,一边操作一边盯着帧率往下掉的瞬间,基本能同步定位是哪个动作触发的重绘拖慢了帧率,不需要专门录一段 Performance 之后再回放分析,适合那种"就是感觉这里滑动不跟手"但又说不清是哪个环节的场景做快速排查。
常见的几个误判
排查问题时最容易走弯路的,往往不是不知道用哪个面板,而是一开始就想错了方向。这段时间自己犯过、也见新人犯过几次比较典型的误判,记一下:把"接口返回慢"和"渲染慢"混为一谈,不看 Timing 和 Performance 就直接下结论说是接口问题;把"样式被覆盖"当成"样式没写",在源码里翻来覆去改同一行,却没想到去 Elements 里看有没有更高优先级的规则在起作用;把"缓存生效"当成"接口坏了",看到 Network 里请求耗时几乎为零就以为服务端没处理,其实是命中了本地缓存;把"z-index 不够大"当成层级问题的唯一原因,却没想到父级可能已经开了一个新的层叠上下文;以及把"移动端问题"直接归咎于"手机太差",没有先用远程调试确认到底是性能问题还是行为差异问题。这几类误判的共同点是省略了打开对应面板确认这一步,直接凭经验下判断,而经验有时候是错的。
反过来看,这几类误判也都有一个共同的纠正方式:先打开对应的面板把现象确认一遍,再决定往哪个方向深入,而不是先在脑子里构建一个"大概率是什么"的假设,然后带着这个假设去找支持它的证据——带着假设去找证据,很容易只看见自己想看见的那部分现象,把无关的信息也解释成支持假设的证据,反而拖得更久。
事件冒泡和捕获:同一个元素上到底谁先执行
排查"点击一个按钮,弹窗却先关闭了又立刻打开"这类问题,本质上很多时候是事件冒泡阶段的执行顺序没理清楚。Elements 面板的 Event Listeners 标签页,不仅能看到某个元素绑了哪些事件,还会标出这个监听器是注册在捕获阶段还是冒泡阶段——大多数框架默认用的是冒泡阶段,但如果代码里手写过 addEventListener(type, handler, true) 或者用了框架提供的 .capture 修饰符,行为就会不一样。
一个真实的例子:弹窗打开时,在 document 上绑定了一个"点击弹窗外部就关闭"的监听器,这个监听器如果注册在冒泡阶段,点击触发弹窗的那个按钮时,click 事件会先在按钮上触发打开逻辑,再冒泡到 document 触发关闭逻辑,表现出来就是弹窗一闪而过。想验证这个执行顺序,不用靠猜,直接在两处回调里各打一个断点,点击复现,断点会按真实的触发顺序依次停下来,配合调用栈能看清楚这次点击到底先执行了谁。这类问题修复起来通常是判断点击目标是否在弹窗内部,或者用 stopPropagation 掐断冒泡,但排查阶段先得靠 Event Listeners 面板确认监听器注册在哪个阶段、以及断点验证真实的触发顺序,不然改起来容易改错方向。
表单相关的问题也有类似的坑,比如中文输入法拼音过程中被提前触发的校验逻辑。中文输入法在拼音还没上屏确认之前,input 事件其实已经在不断触发,如果校验逻辑直接挂在 input 上,会在用户还没打完字的时候就弹出"格式不正确"的提示。这类问题在 Elements 的 Event Listeners 里能看到 compositionstart/compositionend 有没有被正确处理,断点打在校验函数入口,输入拼音的过程中观察断点触发的时机,能直观看到问题出在校验逻辑抢在了 compositionend 之前执行。
Console 不只是 log,还有几个常被忽略的用法
console.log 用久了容易只记得最基础的写法,其实 Console API 里还有几个平时排查问题很好用的方法。console.table 打印数组或者对象数组,会自动渲染成表格,字段对不对齐、某一行是不是缺了字段,一眼就能看出来,比一整坨嵌套对象在 Console 里展开折叠方便得多。console.group 和 console.groupEnd 能把一段相关的日志折叠成一组,排查一次请求里牵扯多个步骤的流程时,分组之后 Console 面板不会被刷屏。
console.trace 打印当前调用栈,不需要真的打断点,适合"想知道这个函数到底是被谁调用的,但又不想为了看一次调用栈就停下来挂断点"这种场景。console.time 和 console.timeEnd 配一对 key 能测量两行代码之间的耗时,写起来比手动 Date.now() 相减再打印方便,而且不会忘记减法写反。console.assert 只在条件不成立时才打印,适合埋一些"理论上不应该发生"的断言,不用每次手动 if 判断再 console.error。
Console 里还能直接用 $0 引用 Elements 面板里当前选中的那个 DOM 节点,$1 到 $4 分别对应之前选中过的节点,不用现写一遍 document.querySelector。配合 $$('selector')(等价于 querySelectorAll 并转成数组)和 $('selector'),在 Console 里临时验证一个选择器命中了哪些元素,比切回代码里改完再刷新快得多。
Coverage 面板:找出没用上的代码和样式
项目做久了,CSS 和 JS 里免不了堆积一些用不上的代码——废弃功能的样式没删干净、某个 utility 函数早就没人调用了。Sources 面板里的 Coverage 标签(通过命令面板 Cmd/Ctrl+Shift+P 搜索 Coverage 唤出)能录一段页面加载和操作过程,结束后按文件列出每份 CSS/JS 里有多少字节被实际执行过、多少字节完全没用上,红色部分就是未覆盖的代码。
这个面板平时不是天天用,但排查"为什么这个页面的 CSS 体积这么大"或者评估要不要拆分某个大 bundle 的时候很有说服力——比起口头说"这个文件感觉有点冗余",直接甩出一张覆盖率截图,红色占了六七成,推动清理工作会容易很多。也有反过来的用法:怀疑某段代码是死代码、但又不敢直接删,先跑一遍关键操作路径的 Coverage,确认它确实全程没被执行到,删起来更放心。
用 Coverage 面板评估的时候有个前提要注意:它统计的是"这次录制过程里有没有被执行到",不是"这段代码是不是永远用不上"。如果只操作了首页就去看整个应用的覆盖率,那些只在特定页面、特定权限角色下才会走到的代码,自然会显示成未覆盖,不能直接当成死代码删掉。真正要拿它做清理依据,得把主要业务路径尽量走全,或者只针对某个明确要评估的单一页面来看,结论才可靠。
移动端问题:响应式模式模拟不出真机
DevTools 的响应式设计模式能模拟屏幕尺寸,但模拟不出真实手机上的一整套行为差异:设备物理像素比(DPR)导致的图片模糊或清晰度差异、输入法弹起挤压页面布局、iOS Safari 特有的橡皮筋滚动和地址栏收起展开、嵌套滚动容器的表现、触摸事件和鼠标事件在触发时机上的差异、刘海屏和圆角带来的安全区域、以及公司内部 App 里 WebView 内核和独立浏览器之间的差异。
这些问题能真机复现就尽量真机复现。iOS 用 Safari 的远程调试(Mac 上打开 Safari 的开发菜单,手机开启 Web 检查器,USB 连接后能直接用 Safari 的 Web Inspector 调试手机上的页面);Android 用 Chrome 的 chrome://inspect,同样是 USB 连上手机,能拿到和桌面几乎一样的完整 DevTools。桌面浏览器缩小窗口只能验证布局断点对不对,验证不了上面这些真机才会暴露的行为差异。
公司内部 App 的 WebView 排查起来比独立浏览器麻烦一层,因为不一定能像 Safari/Chrome 那样直接连远程调试——安卓这边如果 WebView 内核本身开启了 setWebContentsDebuggingEnabled,还是能通过 chrome://inspect 连上去;iOS 的 WKWebView 只要客户端没有特意屏蔽,也能出现在 Safari 的开发菜单里。真的连不上远程调试的场景,只能退回到最原始的手段:在页面里临时挂一个可见的调试面板,把关键状态用文字直接画在屏幕上,或者把日志上报到一个能实时看的后台,这种方式虽然笨,但确实能应付那些连不上调试工具的机型。
设备像素比引发的问题值得单独说一句:同一张 2 倍图,在 DPR 为 1 的设备上会显得偏大且模糊边缘,在 DPR 为 3 的设备上又可能因为没有对应倍数的图源而拉伸模糊。Elements 面板的 Rendering 标签里有个 Emulate a focused page 之类的模拟选项,也能临时切换 DPR 做初步验证,但最终图片清晰度这类问题,还是得挑几台不同 DPR 的真机过一遍才放心。
接口报错要保留上下文,而不是只打一个 error
调试线上接口问题,前端这边的错误提示如果只打一个 error 对象、不带任何上下文,排查效率会很低。习惯上会把请求相关的信息一起带上:
1catch (error) { 2 console.error('请求失败', { 3 code: error.code, 4 requestId: error.requestId, 5 path: location.pathname, 6 params: error.config?.params, 7 }) 8}
给用户看的界面文案要友好,不能把后端的原始堆栈直接糊到页面上;但调试用的日志要尽量把上下文留全,requestId 这类字段能让排查时直接拿去查后端日志,不用再靠猜测复现条件。这两者不冲突,一个是给用户看的,一个是给排查问题的人看的,分开处理就行。
这一条最近也顺手写进了组里的代码评审 checklist——但凡是新加的 catch 块,评审时会顺口问一句"这里出错了,排查的人能靠这段日志查到什么",如果答案是"只能看到一句失败了",就会被要求补上下文。这个习惯不需要额外的工具,靠的是评审时多问一句,但省下来的排查时间比大多数工具本身带来的收益都直接。
这段 catch 里的信息,拿到 Network 面板去对照也很关键:requestId 通常是后端在响应头或者响应体里回传的一个唯一标识,前端报错日志和后端服务端日志能靠这个字段对上号,不然前端说"某个用户某个时间点报错了",后端要在成千上万条日志里凭时间范围硬找,效率很低。团队里现在的约定是,所有走统一请求封装的接口,失败时都会自动把 requestId、接口路径、当前路由 path、以及请求参数的摘要一起塞进上报的错误对象里,不需要每个业务代码自己手写这一段,出错时能直接在监控平台看到这几项,配合 Network 面板里同一个 requestId 的原始请求响应,基本不用靠用户口头描述"我点了什么按钮"来复现问题。
有一种更隐蔽的场景是错误被吞掉了——catch 里只做了 return 或者只弹了个 toast,没有把错误上报出去,线上出现问题的时候监控平台压根没有记录,只能等用户主动反馈。排查这类问题的时候,Console 面板右上角有个过滤选项能只看 error 级别的输出,复现一遍操作,能确认是不是异常真的被完全吞掉了、还是只是没走上报这一步。
Node 脚本和服务端渲染部分的调试
前端调试基本都在浏览器里,但项目里有一部分代码跑在 Node 里——构建脚本、SSR 渲染进程、本地的 mock server。这部分代码没有浏览器 DevTools 可用,长期以来只能靠 console.log 加得到处都是,排查起来比浏览器端麻烦。
Node 自带的调试方式是启动时加 --inspect(挂起等待调试器连接用 --inspect-brk),然后在 Chrome 里打开 chrome://inspect,能看到一个远程目标,点进去打开的就是一份几乎和调试网页一样的 DevTools,支持打断点、看调用栈、看变量。组里的 Node 脚本(生成路由配置、批量处理图标、发布前的环境检查)平时都是一次性跑完就退出,调试的时候更常用的做法是直接在 VS Code 里配一个 launch 配置,IDE 集成的调试面板和 DevTools 是同一套协议(Chrome DevTools Protocol),用起来手感是一致的,断点、监视表达式、调用栈都有,不需要额外学一套新工具。
团队里用了几个月的 Node 版本还停在 16,4 月发布的 18 虽然功能上已经可用,但要等到 10 月底才会转成官方 LTS,现在这个节点团队还在观望,等 LTS 标签落定、上游依赖都验证过兼容性之后再排期升级,不打算抢首发。
SSR 渲染进程的调试比纯 Node 脚本再麻烦一层,因为问题往往只在服务端渲染那一次执行里出现,浏览器端补水(hydrate)之后页面看起来一切正常,错误只在服务端的控制台日志里一闪而过。这类问题除了给渲染入口加条件日志,更稳的做法还是老老实实用 --inspect-brk 挂起进程,配合 chrome://inspect 连上去,在具体的渲染函数里打断点,能看到服务端这次请求进来时拿到的完整上下文——请求头、cookie、路由参数——这些信息在浏览器端调试的时候是看不到的,只有服务端这一侧的调试环境里才摸得到。
判断类型,再选面板
遇到问题先判断属于哪一类,再决定用哪个面板,而不是每次都从头挨个面板点一遍:
1样式布局:Elements + Computed + Box Model(配合 Grid/Flex 可视化工具) 2接口数据:Network(Response、Timing、Waterfall、请求/响应头) 3跨域鉴权:Network 里的 OPTIONS 预检 + Response Headers 4逻辑异常:Sources 断点(行 / 条件 / DOM / XHR / 事件监听 / 异步栈) 5性能卡顿:Performance 火焰图 + FPS 曲线 6重绘与偏移:Rendering(Paint Flashing、Layout Shift Regions) 7内存增长:Memory 面板堆快照对比 8缓存存储:Application(localStorage、IndexedDB、Cache Storage、Cookie 属性) 9Service Worker 异常:Application 里的 Service Workers 状态 10无用代码评估:Sources 里的 Coverage 11生产报错定位:Source Map 反查源码行号 12Node 脚本 / SSR:--inspect 或 IDE 集成调试 13移动端差异:真机远程调试(Safari Web Inspector / chrome://inspect)
跟新人过完这一圈,他自己又去翻了会 Network 的瀑布图,说以前一直以为「接口快慢就是看那个总时间的数字」,现在知道能拆开看是哪一段在拖时间。中午这一个小时讲下来,面板一个个数确实不少,没必要要求自己一次全记住,用得上的时候先想清楚这个问题属于样式、数据、逻辑、性能还是存储里的哪一类,面板自然就对上号了。那个被同名变量覆盖的布尔判断,后来他自己复盘的时候说,如果一开始就打个事件监听器断点,而不是加七八行日志,可能十分钟就能看出来。console.log 还会留着用,只是不再是唯一的手段了。