遇到“无限debugger”卡住页面,应该很多前端开发都经历过:打开Chrome DevTools想调试,结果控制台不断被debugger打断,点都点不完。这篇博文我直接分享几个我在实战里用得最顺手的绕过方式,重点说Hook和文件替换两条路线,也会讲一些DevTools自带的小技巧,希望能帮你从“一打开就卡死”的困境里解放出来。下文所有操作都基于Chrome DevTools,说的例子也都是我实际调过的页面形态,尽量把每一步的原理和坑都写清楚。
1. 无限debugger到底是怎么卡住你的
1.1 无限debugger的两种常见形态
先说最基础的一种,也是绝大多数人第一次碰到的情况。页面的JS里有一个无限循环,循环体内反复调用debugger,最常见的写法就是这样的:
function evilDebugger() { debugger; } setInterval(evilDebugger, 50);这种写法一旦你在DevTools里打开了Sources或Console,脚本每执行到debugger语句就会触发一次“Paused in debugger”。你点掉一次Resume,没过几十毫秒它又触发一次,页面看起来就像被“钉死”在了当前帧上。
第二种形态更恶心一点,它会把debugger藏进动态生成的新函数里:
setInterval(function() { (function() { new Function('debugger')(); })(); }, 100);注意new Function('debugger')()这行,它每次循环都会创建一个全新的函数实例再立即执行,执行到内部时依然会触发debugger。因为函数是动态创建的,你在原来的源码里甚至看不到清晰的“debugger”字样,只能在调用栈里看到一个VM开头的文件。
1.2 为什么它不是一行简单的debugger
很多人会困惑:不就是一行debugger吗?我直接右键“Never pause here”不就行了?问题在于,无限debugger通常不是静态的,而是动态注入的。
如果是固定写在某个文件里的debugger行,右键“Never pause here”确实能跳过。但只要代码是通过eval、new Function动态生成的,每次生成的代码位置都不一样,DevTools无法对“未来才会出现的某一行动态代码”做永久断点忽略。
还有一层:很多实现会把debugger包进try/catch里,比如:
try { new Function('debugger')(); } catch (e) {}这就把错误吞掉了。你不能指望通过全局异常捕获去拦截它。再加上setInterval持续触发,你每手动Resume一次,它就立刻重新进入暂停状态,操作空间被压缩得非常小。
打个比方,这就好比你正在安静看书,有人每隔几秒按一次门铃,你还没走到门口,铃声又响了。你要做的不是一次次去开门,而是直接把门铃拆了——这就是后文Hook和文件替换在做的事。
1.3 它通常出现在哪些场景
理论上任何网页都能写无限debugger,但实际里频繁出现的地方也就几类:
- 数据平台、报表系统,防止别人打开DevTools直接看接口请求;
- 在线文档、编辑器,防止内容被轻易复制和调试分析;
- 广告、埋点脚本,防止你摸清它的加载逻辑;
- 一些被混淆工具保护过的前端代码,用debugger给逆向分析增加干扰。
再说句公道话,不是所有无限debugger都是恶意设计。我也见过开发自己写了setInterval调试逻辑,上线时忘删了,结果用户一打开页面就卡。不管是哪种情况,当你需要调试自己负责或有权限访问的页面时,掌握绕过手段都是一项很基础的能力。
2. Chrome DevTools的快速破局三板斧
2.1 全局搜源码:先找到debugger的老巢
拿到一个被无限debugger卡住的页面,我建议第一件事不是急着Hook,而是先在DevTools里全局搜索一下debugger关键词。
具体操作:打开DevTools,切到Sources面板,然后按快捷键Ctrl+Shift+F(Windows)或Cmd+Option+F(Mac),在搜索框里输入debugger。这个操作会搜索当前页面加载的所有脚本,并把所有出现debugger的位置列出来。
如果代码是压缩成一行的那种,你先别急着读,点一下Sources面板左下角的{}格式化按钮,把代码展开成可读格式,再搜debugger就有了。
这里有个经验:如果搜索出来的位置都是同一个文件里的同一段代码,那大概率是静态debugger;如果搜索结果里出现了大量VMxxxx文件,或者根本搜不到,那就说明debgger是动态生成的,光靠搜索定位还不够,可能要走Hook路线。
2.2 禁用JavaScript与条件断点:能应急但不治本
如果页面实在卡得厉害,鼠标都没法点击,最快速的应急操作是:DevTools设置里找到Preferences -> Debugger -> Disable JavaScript,勾上之后页面就不再执行新的JavaScript代码了。无限debugger自然不会再触发,整个页面会变成静态状态。
这个做法只适合应急,因为禁用JavaScript会导致页面大部分交互失效,很多SPA单页应用甚至直接白屏。我一般只拿它来验证“这个页面是不是纯JS在作妖”,确认之后马上取消勾选,回到正常方案。
如果你已经定位到了具体的debugger所在行,可以右键行号,选择“Add conditional breakpoint”,在输入框里填false。这样当代码执行到这一行时,条件永远不成立,就不会暂停了。这个方法对付静态debugger特别好用,但它对动态生成的代码无效,因为动态代码的行号是临时出现的,断点没法提前挂上去。
2.3 顺手解决“不能复制对象”的调试痛点
调试过程中还有一个常遇的烦心事:你在Sources的作用域面板里看到某个对象,右键想复制,结果提示“不能复制对象”,或者复制出来是个空字符串。有人管这叫“载荷不能复制对象”,其实就是对象太复杂了,DevTools默认的复制逻辑处理不了。
我常用的解法是:先在作用域面板点开这个对象,右键任意子属性,选择“Store as global variable”,DevTools会给你生成一个temp1变量,在Console里输入这串名字,就能拿到对象引用。然后执行:
copy(JSON.stringify(temp1))如果对象里有函数、循环引用或undefined属性,JSON.stringify会失败或丢字段,这时可以用copy(Object.keys(temp1))先看结构,或者用console.dir(temp1)在Console里展开后手工拷贝。这个技巧在调试接口载荷时非常实用,能省掉你反复手敲字段名的时间。
3. Hook大法:让函数变成空气
3.1 Hook的基本原理:JS函数可以随时“换芯”
先讲一个JS的基础特性:函数是对象,而且函数可以像普通变量一样被赋值和替换。window.setInterval本质上是window对象的一个属性,你完全可以在页面脚本执行之前,把window.setInterval换成你自己的函数。
这就是Hook的核心思路:保留原函数引用,用一个新函数把原函数包起来,在新函数里做判断和过滤,必要时再调用原函数。页面里的其他代码并不知道setInterval已经被换了,它们只是在调用window.setInterval,实际上走到的是你的函数。
用一个生活类比解释:你家的快递柜原本是快递员直接投递,你Hook之后,所有快递都先经过你的手,你检查没问题再放进去,有问题的直接扔了。页面代码是“快递员”,你的Hook函数就是“门卫”。
3.2 实战:用一段Hook脚本干掉setInterval里的debugger
假设页面用setInterval无限触发debugger,我们可以在页面加载早期执行这么一段Hook:
(function() { const rawSetInterval = window.setInterval; window.setInterval = function(fn, timeout, ...args) { if (typeof fn === 'function' && fn.toString().includes('debugger')) { console.warn('[hook] 拦截到包含debugger的定时器'); return 0; } return rawSetInterval(fn, timeout, ...args); }; })();这段代码做什么?先把原始的setInterval存到rawSetInterval里,再重写全局的window.setInterval。每次页面代码调用setInterval,都会经过你的判断:如果回调函数体里包含了debugger字符串,说明这就是引发无限调试的“元凶”,直接返回一个假的定时器id0;如果是个正常的定时器,就调用原始方法,不影响业务。
这里有一个点必须说清楚:fn.toString()能不能看到debugger字符串,取决于函数是怎么写的。如果是明确写在函数体里的,toString能拿到;如果是new Function('debugger')这种动态构造,fn.toString()也能拿到,因为动态函数的源码就是这个字符串。但也存在函数体经过压缩混淆、字符串被拆开的情况,这时includes('debugger')就失效了,更稳妥的做法是把debugger关键字替换或者直接清除整个定时器。
3.3 hook重定向:从“拦参数”到“换实现”
比简单拦截更进一步的是hook重定向,热词里有人提过“hook重定向”,意思是:不等函数被调用时再判断,而是直接在源头把函数的引用整个换掉。
比如页面用eval或Function构造器生成debugger,你可以这样重定向:
(function() { const rawEval = window.eval; window.eval = function(code) { if (typeof code === 'string' && code.includes('debugger')) { console.warn('[hook] 拦截到含debugger的eval代码'); return undefined; } return rawEval(code); }; const rawFunction = window.Function; window.Function = function(...args) { const body = args[args.length - 1]; if (typeof body === 'string' && body.includes('debugger')) { console.warn('[hook] 拦截到含debugger的Function构造'); return function() {}; } return rawFunction(...args); }; })();这段代码把eval和Function两个入口都改掉了:遇到包含“debugger”的字符串就直接放弃执行,返回undefined或空函数;其他正常的eval照常执行。因为很多动态debugger都是通过这两个入口进入页面的,重定向之后等于在源头上拆了门铃。
Hook重定向的价值在于,它不一定依赖setInterval那种定时器结构,也不依赖你逐行去加条件断点。你只需要把“debugger”可能经过的所有入口都接管一遍:setInterval、setTimeout、eval、Function构造器。覆盖这四个,基本能拦住绝大多数常见的无限debugger实现。
3.4 写Hook脚本的三个要点
先说时机问题。Hook脚本必须在页面业务代码执行之前或至少同时执行,才能拦下后续的debugger注入。如果你在控制台手动粘贴,页面已经在卡死状态了,再粘贴也晚了。推荐做法有两种:一是用浏览器扩展脚本,设置文档开始阶段执行;二是把Hook脚本放进DevTools的Snippets里,在页面加载早期手动运行,运行完再刷新页面。
再说变量冲突问题。你重写的是window.setInterval这种全局变量,如果页面的混淆代码也检测了setInterval.toString(),发现它被改过,可能触发其他反调试逻辑。我一般把Hook函数写得尽量低调,比如只拦截目标函数,不打印一堆日志,或者用Object.defineProperty设置window.setInterval的不可枚举属性,减少被检测的概率。
最后强调一个度的问题。不要把所有定时器全部拦截,否则页面的轮询、动画、自动保存全都停了,你会发现虽然不卡了,但页面也废了。我通常只拦截回调函数体内确实包含debugger的定时器,其他全部放行。
4. 文件替换:最彻底的一劳永逸
4.1 Local Overrides是什么原理
Hook虽然好用,但它是“运行时拦截”,每次刷新页面都需要重新注入,而且如果页面代码里有对Hook本身的校验,容易被反制。
更干净利落的方式是文件替换。Chrome DevTools里有一个Local Overrides机制,原理很简单:你把线上加载的JS文件保存一份到本地目录,然后在本地修改这个文件,之后浏览器再次加载同URL的脚本时,会用你本地的版本替代远程版本。
可以理解为“本地优先”的静态资源代理。页面以为自己加载的是服务器上的bundle.js,实际上读的是你本地改过的bundle.js。这样一来,代码里所有让你不爽的debugger、检测逻辑、变量赋值,都能直接改掉,且刷新之后依然生效,不需要每次手动注入。
4.2 第一次做文件替换:完整操作流程
我来梳理一套完整步骤,按着做基本不会翻车。
第一步:在DevTools里打开Sources面板,切到“Overrides”标签页,点击“Select folder for overrides”,选一个本地空目录作为覆盖目录。DevTools会提示你授权,点允许就好。注意别选系统目录或需要高权限的路径,否则写文件会失败。
第二步:切到Network面板,刷新页面,找到你想替换的JS文件。右键这个文件,选择“Save for overrides”。这时DevTools会把远程文件保存到你刚才选的本地目录,并在文件列表里创建一个对应的“本地副本”。
第三步:回到Sources面板,找到这个本地副本,点击格式化{},再用Ctrl+Shift+F搜索debugger,把所有出现在定时器、动态函数里的debugger语句删除或注释掉。保存。
第四步:刷新页面,观察效果。如果一切顺利,页面不再触发无限debugger,而且其他功能保持正常。
这里有个细节:Sources里修改本地文件后,一定要按Ctrl+S保存。如果你直接改完就刷新,DevTools不会自动写回磁盘,等于没改。
4.3 替换大文件时的处理技巧
有些压缩后的JS文件动辄几百KB甚至几MB,在DevTools内置编辑器里直接修改,会卡到怀疑人生。我在调试时就遇到过很像“sql文件太大,无法执行全部替换操作”的情况,其实就是一次性在编辑器里替换太多内容导致的性能问题。
我的做法是:先通过“Save for overrides”把文件保存到本地目录,然后用VSCode或Notepad++等专业编辑器打开,用正则一次性批量替换debugger关键字,保存后回到DevTools刷新页面。由于本地文件已变化,DevTools会自动加载修改后的版本。
如果文件大到你连打开都费劲,可以更精准地操作:只定位到包含debugger的那一小段代码,手动删掉前后几行,不要用“全部替换”功能。对压缩代码来说,最小的改动往往是最不容易出问题的改动。
4.4 文件替换的边界与注意
文件替换也有几个容易踩的坑。
第一,Local Overrides生效的前提是DevTools保持打开且激活状态。很多人把DevTools关了再打开页面,发现覆盖不生效,还以为自己操作错了,其实不是,这个机制本来就必须在DevTools激活时才能拦截网络请求。
第二,线上代码更新后,你的本地覆盖版本可能已经过时,如果忘了清理,页面会继续用老文件运行,产生诡异的问题。我的习惯是调试结束后立刻删除Overrides目录,或者点在“overrides”状态的右键菜单里选择“Remove override”,避免影响后续调试。
第三,文件替换不仅对JS有效,CSS、JSON、HTML、图片都能覆盖。这意味着你不仅可以用它删debugger,还能用它造测试数据、改接口返回、Mock环境变量,甚至把一个复杂的模块整体替换成自定义实现。这是我调试前端问题时的另一个大杀器。
5. 一次完整实战:从卡死到自由调试
5.1 现场描述与第一步判断
有一次我在调试一个内部数据看板页面,打开DevTools准备看接口请求,结果页面直接“Paused in debugger”,下面还有个橙色提示条,Control台里滚满了VM开头的报错。
我第一反应没去点Resume,而是先按Ctrl+Shift+F搜索debugger,搜索结果指向了一个叫app.3f8a9c.js的混淆文件。我点进文件,按{}格式化之后,看到大概连续七八处debugger,而且外层包着一个setInterval循环。这就基本确定了:典型静态混合动态的无限debugger。
5.2 分步绕过记录
我按“先应急、再Hook、最后文件替换”的顺序处理。
第一步,临时在DevTools设置里打开“Disable JavaScript”,确认页面在禁用脚本后能正常显示静态骨架。确认后取消禁用。
第二步,在Sources的Snippets里新建了一个killDebugger脚本,把重写setInterval的那段代码粘进去,运行一次,再刷新页面。刷新后页面不再无限暂停,但Console里出现了一行我自己的[hook] 拦截到包含debugger的定时器。页面的大部分交互也恢复了,能正常点击菜单。
第三步,我没有就此罢休,因为如果刷新后页面又用了其他方式生成debugger,还是会卡。于是我把app.3f8a9c.js通过“Save for overrides”存到本地,搜索所有debugger字符串,删掉了两个关键循环里的debugger调用,保存,再刷新。这次页面从加载到可交互全程没有一次自动暂停,Network面板也能正常抓请求了。
5.3 最后的清理与验证
调试完成以后,我做了两件事。
第一,删掉Snippets里的killDebugger脚本,或者至少注释掉Hook逻辑。
第二,在Sources的Overrides面板里右键所有带“本地覆盖”标识的文件,选择移除。
这两步做完,再刷新页面,确认页面恢复到线上原始逻辑,没有我的debugger处理残留。验证方式很简单:打开DevTools,正常刷新,页面不会自动暂停;在Console输入1+1回车,能立刻得到结果,说明调试环境完全干净了。
6. 常见问题与排查技巧实录
6.1 问题清单与解决方案表格
我把平时被问得最多的问题整理成一个速查表,遇到类似情况可以直接对着查。
| 场景 | 可能原因 | 解决方案 |
|---|---|---|
| 打开DevTools就卡死在debugger | 页面里有setInterval或setTimeout循环触发debugger | 先Disable JavaScript应急,再搜索源码定位,最好直接用文件替换删除 |
| 删除debugger后刷新又回来了 | Local Overrides没生效,或页面加载了新文件URL | 确认DevTools处于激活状态,确认overrides目录已选择,确认保存文件名与请求URL一致 |
| Hook脚本执行了但没效果 | 注入时机太晚,页面业务代码已经先跑了 | 改用浏览器扩展脚本在document_start阶段执行,或者直接用Local Overrides替换文件 |
| 每次停在VMxxxx临时文件里 | debugger由eval或new Function动态生成 | Hook掉eval和Function构造器,或者用文件替换删除生成debugger的调用代码 |
| 作用域里对象右键“不能复制对象” | 对象包含不可枚举、Proxy或循环引用等 | 在Console里Store as global variable,再用copy(JSON.stringify(temp1)) |
| 替换大文件时编辑器卡死 | 一次性替换内容过多 | 先用专业编辑器处理本地文件,再回到DevTools刷新;避免在DevTools里对超大文件全量替换 |
| 页面不再暂停但功能也全部失效 | Hook范围太宽,把所有setInterval都拦了 | 精细化判断,只拦截包含debugger的回调,其他定时器放行 |
6.2 几个容易被忽略的细节
第一个细节:动态生成的debugger并不只在JavaScript文件里出现,它可能出现在<script>标签直接内联的代码中。遇到这种情况,全局搜索debugger时,Sources面板默认只搜加载的JS文件,不一定会把HTML里的内联脚本全部列出。你可以切到Elements面板,再按Ctrl+F搜索当前HTML中的“<script”,手工看内联脚本。
第二个细节:如果你用了文件替换,又改了请求头或缓存策略,刷新后覆盖不生效的概率很高。你在Network面板里能看到请求被标记成“from disk cache”还是“from overrides”,如果是前者,说明覆盖没接管,可以试着强制刷新或清缓存。
第三个细节:不要忽视条件断点这个最基础的武器。在复杂代码中,你不想改动源代码,只想在某一行暂停观察上下文,那就可以用“Add conditional breakpoint”,条件写成一个只有你预设值才成立的表达式,比如window.__debugFlag === true,运行到该行时检查条件,不满足就自动跳过。这种方式对静态debugger非常有效,而且比全局Disable JavaScript要精细得多。
第四个细节:Hook脚本写完之后,建议把代码保存到Snippets里,这样下次遇到类似的无限debugger,切到Snippets一键运行就能复用。我的Snippets里就存了好几个版本,分别针对setInterval、eval、Function构造器和纯静态debugger,按场景选择使用。
我在实际调试中最大的体会是:无限debugger并不可怕,可怕的是没有一套清晰的排查顺序。先定位是静态还是动态,再决定用条件断点、Hook还是文件替换;能用文件替换解决的问题,尽量不要靠Hook去“打补丁”,因为Hook会让你和页面代码处于一种微妙的对抗状态,调试环境不够干净。反过来,如果你只是想快速验证一个逻辑,Hook往往是五分钟内见效最快的方案。
最后再分享一个小技巧:很多反调试逻辑会同时检测DevTools是否打开,一旦检测到就额外注入更多debugger或清空控制台。遇到这种状况,可以先别急着在DevTools里操作,用文件替换把检测逻辑一并删掉,再重新打开DevTools调试,这样能省下大量和它“躲猫猫”的时间。