打开开发者工具准备看网络请求,页面“啪”一下停在一个 debugger 语句上,怎么点 Resume 都没用,停一次、继续一次、又停一次,循环往复,网页像是被下咒一样卡死在调试器里。这个场景搞前端分析、调接口参数、看响应数据的时候太常见了,很多资讯类、财经类站点为了防爬,会在前端脚本里埋“无限 debugger”。雪球网就是典型例子。网上关于怎么处理的技术贴其实不少,但大多都是“正派”路线——教你顺着调用栈去定位、加条件断点、一点点修复逻辑,费时间不说,遇到堆了十几层定时器和eval的混淆代码真的会想砸电脑。
我花了不少时间折腾过这类防护,最后的经验是:不必去和 debugger 硬碰硬。开发工具本身和浏览器运行机制里其实留了不少后门,只要思路换一下,可以做到不去管它的具体触发点,直接从机制上把它废掉。整套流程固定下来之后,处理速度非常快,而且不依赖某个特定站点的代码结构。那这篇就把我常用的几招“邪修”方法完整记录下来。
1. 先说清楚无限debugger是怎么让你卡住的
1.1 debugger语句的底层机制
debugger是JavaScript里的一个关键字,本身不复杂:代码执行到这一句时,JS引擎会主动通知调试器暂停执行。如果没有外部调试器挂在那里,这句话等同于不存在,用户正常打开网页不会有任何感觉。问题就出在,只要你开着DevTools,调试器是激活状态,那么每碰到一次debugger就会中断一次。
“无限”这两个字的关键在于触发方式。最朴素的一种写法是在JS里批量埋点:
javascript复制function blockDebug() {
debugger;
}
setInterval(blockDebug, 100);
每隔100毫秒来一次,每次你点击“继续执行”后,下一次又来了。还有更刁钻的写法,把debugger塞进eval字符串里动态执行:
javascript复制setInterval(function() {
(function() {}).constructor('debugger')();
}, 100);
这种方式写出来的debugger不是直接出现在源码层面,而是运行时通过Function构造函数动态生成的,你就算在源代码里全文搜索“debugger”都可能搜不到,因为源码里压根没有这个关键字,只有一段字符串拼接逻辑。这也是为什么很多人觉得这玩意儿诡异的原因——从静态代码里看,什么可疑的东西都不明显,一跑起来就不断暂停。
1.2 触发位置的主要几种类型
根据我拆过的一些案例,无限debugger的出现位置大致能归成三类,先分类再看解决方案会清晰很多:
| 类型 | 典型实现方式 | 调试时的表现 |
|---|---|---|
| 定时器触发型 | setInterval/setTimeout循环调用含debugger的函数 |
点击继续后马上再次暂停,间隔固定 |
| 递归调用型 | 函数内部调用自身,执行到debugger后继续递归 |
暂停点在同一个函数内来回跳,调用栈越积越深 |
| 动态构造型 | eval或Function构造器执行字符串拼接出的debugger |
静态代码里搜不到debugger,只能运行时定位 |
理解触发类型很重要,因为不同位置的写法适合用不同方案来绕。比如纯定时器型的,最快方式是直接在运行入口把定时器Hook掉;而动态构造型的,则需要往Function构造器那一层动手。
1.3 明确一下做这些事情的边界
开始说具体操作之前,先聊一句边界问题。这篇文章讨论的是开发调试过程中怎么摆脱无限debugger的干扰,用于你自己在合法场景下分析网页、排查脚本问题、学习前端工程实现。请别把它用去搞批量抓取、恶意攻击或者绕过人家服务端的安全控制。真正的爬虫对抗大头在服务端,前端这点debugger只是最基础的一道门槛,过了它后面还有接口签名、频率控制、行为识别一堆东西等着,心思花在这上面意义也不大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么说常规处理方式又慢又累
不少技术帖教的“正派”解法是定位到debugger所在的那一行,然后通过加条件断点、修改逻辑的方法让它不生效。听起来合理,实操的时候非常痛苦。
一是代码定位难。现在的生产环境JS基本都是压缩混淆过的,变量名是一两个字母,代码一长串挤在一行里,函数嵌套得跟千层饼一样。哪怕你通过调用栈找到了暂停位置,看到的也是一坨人肉没法读的东西,想从里面精准地找出是哪个逻辑触发了这个debugger,有时候跟大海捞针差不多。
二是循环触发让人身体被掏空。无限debugger的设计初衷就是消耗你的耐心,你每手动跳过一下,它马上又弹回来,几轮下来表格里的调用栈就已经堆得老高,看着头皮发麻。想找到源头,得在断点暂停的间隙里快速操作,手稍微慢一点就陷入下一轮暂停。
三是即便改了还不一定生效。有些站点会做完整性校验,你改了本地代码,页面一加载发现hash对不上,直接报错不干活;有些debugger不是写死在JS文件里,是服务端下发的配置或动态脚本生成的,你改静态文件根本没意义。
所以我的结论是:与其去和它斗智斗勇,不如用浏览器开发者工具自身的机制,或者趁着代码还没运行起来之前做手脚。后面这几招都是从这个思路出发的。
3. 邪修第一式:在调试器层面直接把断点功能“按死”
3.1 一个快捷键解决大部分定时器型debugger
最省事的一招,是让整个调试器暂时不响应任何断点。在DevTools的Sources面板里,有一个“Deactivate breakpoints”按钮,图标是一个断点图案上画了条斜线,快捷键是Ctrl+F8。
点一下之后,调试器会暂时忽略所有断点,包括debugger语句触发的暂停。本质上是把你调试器的“暂停开关”关了,代码里再怎么疯狂执行debugger都不会中断页面。这个方法更适合纯定时器触发型,因为它是靠不断暂停来恶心你的,禁掉之后反而清净了。
要注意一个细节:它把你自己精心设置的其他调试断点也一起禁掉了。如果你后面还想继续用断点跟踪逻辑,记得再按一次Ctrl+F8恢复。
不过实测下来,这个方案不是所有网站都有效。有些站点的无限debugger写在XHR回调或者事件回调的深水区,页面运行起来几乎没完没了地触发,即便你用了Ctrl+F8,还是偶尔会卡一下,这种情况就得配合下一招里的ignore list来用。
3.2 针对具体源码行的Never pause here
如果你定位到的debugger就固定在某个源文件的某一行,还有一个更精准的做法:在Sources面板里打开那个文件,鼠标移到debugger所在行的行号附近,右键菜单里会有一项“Never pause here”。
选了它之后,Chrome会在这一行自动加一个永远不满足的断点条件,实际执行到这行时不再触发暂停。这个操作相当于给这一行发了一张免死金牌。
但它的局限性也很明显:只对单一行有效,要是debugger分布在十个不同位置或者不断动态生成代码,你一个个点右键也够呛。所以我通常只把它当作临时救火的手段,比如我正在分析某个页面,其他代码都看明白了,就剩一处固定的debugger挡路,用这个最干净。
3.3 把整个脚本扔进Ignore List
DevTools还提供了一个更宏观的工具:Ignore List,中文界面叫“忽略列表”。以前旧版本叫Blackbox,作用类似。把某个脚本文件加进忽略列表后,调试器会把这个文件视为“不值得关注”的第三方代码,今后任何调试操作都不会在该文件里暂停,debugger语句也会被跳过。
操作步骤不复杂:
- 在Sources面板左侧文件树找到触发暂停的JS文件;
- 右键该文件名,选“Add script to ignore list”;
- 页面刷新后再执行到该文件的debugger时不会暂停。
这个方案相当于告诉调试器:整个文件别管了,干别的页面逻辑去。适合那种“某个第三方脚本里塞了一大堆debugger”的场景。
但这里得给你提个醒,这招有概率无效。我碰到过一种情况:主业务脚本本身没加入忽略列表,但是它在里面调用了另一个忽略列表里的函数,函数里有个debugger,结果操作时还是会暂停到调用栈上。这种边角情况需要结合下一节的源码掉包法彻底解掉。
4. 邪修第二式:把JS源码直接“掉包”,从根上删除debugger
4.1 用DevTools本地覆盖实现脚本替换
最彻底的手段,其实就是趁代码加载前把它换掉。Chrome DevTools自带的Local Overrides(本地覆盖)功能,能让你把线上加载的JS文件替换成本地修改过的版本,页面刷新时会优先使用你本地这份。
具体操作流程如下:
- 在Sources面板里找到包含无限debugger的JS文件;
- 在编辑器区域右键,选择“Save for overrides”或“Override content”;
- 第一次使用会要求你选一个本地文件夹存放覆盖文件,选好后DevTools会在该目录下生成保存的文件;
- 在Sources里的文件树中会有一个带圆点标记的同名文件,打开它,把
debugger;语句删掉或者注释掉,保存; - 返回到页面硬刷新(Ctrl+Shift+R),再打开调试器就不会在那个脚本里暂停了。
它背后做的事情其实和代理重写差不多,只不过省去了配置代理的繁琐步骤,直接在开发工具层面拦截了请求。页面加载JS的URL不变,但对浏览器来说,拿到的内容已经被你替换过一版了。
操作时有几个关键点值得注意。硬刷新这一步很关键,因为普通刷新可能命中本地缓存,加载的还是旧JS。另外,如果你想让某个站点始终用本地覆盖,需要保持DevTools处于打开状态;关闭DevTools后,覆盖不生效。
4.2 大量debugger怎么批量清理
刚才说的是单个文件的场景。但现实情况里,被混淆过的脚本可能只有一行,但这一行里重复出现了几十个debugger。手动删不现实,直接整体删又可能误伤正常逻辑。
我的习惯做法是先用编辑器打开这个覆盖文件,用正则全文替换把debugger;都清掉。在DevTools的Sources编辑器里没法做多光标操作,所以我一般先把覆盖文件在本地编辑器(比如VS Code)里打开,搜索debugger然后全文替换成空字符串,保存后回DevTools刷新页面。
需要注意的是别手滑把所有“debugger”相关字符串都换了。如果某些变量名或字符串里含有debugger子串,直接全局替换会破坏代码结构。你可以先搜一下看看出现位置,再决定是替换debugger;还是debugger加换行等更精确的模式。
4.3 被替换后脚本自校验怎么办
本地覆盖有一个绕不过去的坑:如果服务端返回内容带有完整性校验,比如内容里嵌了hash,跟原文件对不上,页面运行就会报错。我在一些站点上遇到过这种反制措施,表现是:
- 脚本加载后报语法错误;
- 页面功能全挂但没报错;
- 刚开始能用,过几秒触发一个异常检测逻辑,页面初始化失败。
遇到这种场景,不要死磕覆盖法。可以考虑只在去掉debugger时尽量保持文件的其他字节完全一致,修一行算一行,这样hash如果只是针对整段代码的固定算法,改了内容还是会变,照样过不去。所以这种方法更适用于没有做内容校验的站点。很多时候,静态JS没有校验,动态下发的代码片段反而校验很严。
如果这种方案受阻,那我建议直接用下一招,从执行入口上把问题代码截胡,不依赖改动文件内容。
5. 邪修第三式:在运行时拦截执行入口,让debugger根本没机会跑
5.1 Hook定时器,废掉定时触发型debugger
对于setInterval或者setTimeout反复触发debugger的场景,可以在页面执行到那段逻辑之前,把定时器函数换掉。我们用Hook的方式做一层过滤,凡是检测到回调函数体里含有“debugger”关键字,就拒绝把定时器注册进去。
javascript复制const originalSetInterval = window.setInterval.bind(window);
window.setInterval = function (fn, delay, ...args) {
if (typeof fn === 'function') {
const source = fn.toString();
if (source.indexOf('debugger') !== -1) {
console.warn('[blocked] setInterval callback contains debugger');
return 0;
}
}
return originalSetInterval(fn, delay, ...args);
};
执行这段代码的时机很重要。如果页面已经把定时器注册好了,你后补Hook是拦不住已经排入队列的任务的。所以要在页面脚本运行之前执行,或者至少在那个包含定时器的JS执行之前执行。
实际落地手段有这么几种:
- 如果网页使用了比较清晰的模块加载顺序,你可以在Console里尽早执行Hook,然后手动刷新生效,但这要看运气;
- 在DevTools里通过Sources面板的Snippets新建一个脚本,专门放这段Hook,需要的时候点一下运行;
- 如果想把Hook固定到目标站点每次加载都执行,那就需要借助本地覆盖的方式把一个提前注入的小脚本插到页面里,或者用浏览器扩展里的content script机制。
这个方案有它的弱点。如果定时器的回调函数体非常庞大,里面含有大量正常的业务逻辑,只是其中某一行藏着一个debugger,那么fn.toString()检测会把整个函数拦截掉,正常业务也被你一起误杀了。这种情况就不能一刀切拒绝,而是要把回调包一层,在真正执行之前临时禁用debugger。
5.2 Hook Function构造器,拦住字符串拼接出来的debugger
动态构造型的无限debugger往往长这样:
javascript复制(Function('debugger'))()
这种写法每次都会动态生成一个新的函数来执行debugger。如果能拦截Function构造过程,把函数体里的debugger关键字去掉,不就等于从源头拆弹了么。
javascript复制const originalFunction = window.Function;
window.Function = function (...args) {
if (args.length > 0) {
const body = String(args[args.length - 1]);
if (body.indexOf('debugger') !== -1) {
args[args.length - 1] = body.split('debugger').join('');
}
}
return originalFunction.apply(this, args);
};
window.Function.prototype = originalFunction.prototype;
这段代码的大致思路是:所有通过new Function或Function(...)创建的动态函数,在执行前先检查函数体字符串,发现debugger就把它去掉,再把清理过的参数交给原生的Function构造器执行。
在实际操作中要注意,Function构造器和普通的函数声明不太一样,它的this上下文没有绑定的意义,所以直接用originalFunction.apply(this, args)也没有太大问题。但如果遇到的是(function(){}).constructor('debugger')()这种写法,本质上是Function构造器的另一种调用路径,也会经过我们替换后的window.Function。
我在几个做了这层防护的网页上试过,注入后效果明显,能直接观察到控制台不再断点。这个方法对静态代码里直接写死debugger的情况无效,因为静态代码不是经过Function构造器执行的。
5.3 直接把可疑函数替换成空函数
还有一种更无脑但偶尔有效的邪修方式:通过调用栈看清楚是哪个函数带出了debugger,然后在全局作用域或对应的命名空间里把这个函数重新赋值为空函数。
javascript复制window.someNamespace.blockFunction = function() {};
这个办法只能对付一些函数保存得比较“浅”的场景。如果相关函数被包在闭包里,或者没有暴露到全局变量上,你在Console里根本拿不到它的引用。而且生产环境的代码大多经过模块化打包,函数往往存在于模块作用域中,根本不在window上,想替换都找不到入口。
所以我一般把它当作快速试验手段,来看看“如果这段逻辑不执行会不会影响后续页面功能”,而不是作为稳定解法。
6. 实战复盘:处理一个财经站点无限debugger的完整过程
6.1 现象表现
拿雪球网这类财经站点来举例。打开行情页面,我按下F12想看一下当前页面的异步请求情况,结果页面立刻暂停,控制台底部提示的信息是类似“Paused on debugger statement”。点一下继续执行,走了不超过零点几秒,又停一次。此时看Call Stack面板,能看到一串都是压缩后的短文件名,全是xxx.js:1这种痛苦的行号,基本没有任何阅读性。
这个场景就是典型的无限debugger防护在生效。用户平时正常浏览没有任何感知,但只要打开调试器,脚本就开始反复暂停。
6.2 定位触发源
我不急着去翻调用栈。先到Sources面板里把当前正在执行的脚本文件列表过一遍,找到刚才暂停点所在的文件。
然后我再做一次全量的字符串搜索,在Sources面板里按Ctrl+Shift+F,输入“debugger”搜索当前加载的所有JS代码。如果静态代码里能搜到,说明是写死的;如果搜不到,那基本可以确定是通过Function构造器动态生成的。
如果你在页面里搜代码不方便,也可以先在自己电脑上把那份JS文件下载下来,用编辑器打开快速搜索。大多数时候,下载之后去掉debugger再配合本地代理工具使用是可行的。
6.3 我选择的对抗方式
像这类财经站点,我的处理顺序大致是:先试Ctrl+F8,如果页面不再暂停,就直接用了;如果还是会偶尔暂停,我就把当前触发暂停的核心脚本文件加入Ignore List;如果加了Ignore List也不理想,那就进入最终的掉包环节,用Local Overrides把当前文件里的debugger清理一遍。
实际操作里,对于这个典型案例,我更倾向于直接用Hook Function构造器的方式处理,因为这类内容站往往有一部分debugger是动态生成的,静态覆盖之后偶尔还会冒出漏网之鱼。在Console里手工执行一次Hook脚本后,刷新页面,几乎可以稳定保持不暂停。
6.4 处理完之后能做什么
过了无限debugger这一关,页面才会恢复成“正常开发调试”的状态。你这时候才能在Network面板里安心查看各种资源加载情况、在Console里执行自己的表达式、在Elements里查看DOM结构。如果只是想单纯分析页面,这个阶段已经足够了。
不过我也必须说清楚:无限debugger只是前端调试经验里的一环,处理掉也不代表这个网站的所有数据访问都对你开放。很多站点在服务端还有接口鉴权、签名参数、行为风控等更深的保护链路,那不是靠绕过一两个关键字能解决的,也不属于前端调试范畴。把目标定为“页面逻辑能正常调试了”就行,别总想着越走越远。
7. 常见问题与排查记录
实际操作中经常踩坑,我把常见的几类问题整理成一张速查表,方便你卡住的时候直接对号入座:
| 问题现象 | 可能的原因 | 处理方式 |
|---|---|---|
| Ctrl+F8之后还是不断暂停 | 部分带条件断点的特殊debugger不受全局禁用影响,或站点主动调用了debugger修复功能 | 换用Local Overrides或Hook方案 |
| 右键行号时找不到“Never pause here” | 当前不是编辑器界面,或行号区域点击位置不对 | 确认在Sources的代码编辑区,鼠标放在断点圆点右侧后再右键 |
| 加了Ignore List但还是暂停 | 暂停发生在未忽略的调用方脚本或动态生成的代码上 | 将调用方脚本也加入Ignore List,或改为Hook Function构造器 |
| 本地覆盖不生效,刷新后仍是旧代码 | 没有硬刷新,或覆盖目录没授权 | 按Ctrl+Shift+R强刷;检查DevTools是否开启本地覆盖授权 |
| 覆盖文件改成空后页面报错 | 修改破坏了JS整体结构或语法 | 不要在编辑器里整段乱删,只删debugger关键字本身,并保存为纯文本格式 |
| Hook Function后仍能触发debugger | 注入时机太晚,或在注入之前定时器已经注册 | 尽量提前注入,放进Snippets并在页面加载前手动运行,配合刷新时机多试两次 |
除了上面的技术点,我的一个亲身体会是:处理这类问题要有一个“分层防御”的心态。第一层先试最省事的快捷键,第二层再试脚本和断点工具的组合,第三层才上本地覆盖或Hook这类侵入性强的操作。不要一上来就把各个角落拆个底朝天,因为很多网站的JS在开发环境里跑得好好的,你把debugger删得太多,反而有可能破坏原有逻辑,让页面处于一种“不卡了但功能也挂了”的异常状态,排查起来更费劲。
举个例子,某个JS文件里可能有debugger;和一个大业务函数在同一个闭包内,如果你粗暴地整段注释掉,那么这个业务函数也会被注释掉,页面初始化就断在这里了。保留业务函数,只把debugger关键字摘除,才是安全策略。
另外一个我反复踩过的坑是控制台Hook脚本被浏览器的“恢复执行”机制影响。你运行Hook脚本后,可能页面还处在一个暂停态,这时候刷新页面,Hook脚本已经无效了。所以正确顺序通常是:先把要注入的Hook代码放在Snippets里,再刷新页面,等页面脚本执行到需要使用定时器或Function之前,马上运行Snippet,然后再触发页面逻辑。这个过程多试几次就能找到最佳时机。
最后再多说一句,虽然标题叫“邪修办法”,但它其实就是剑走偏锋的工程技巧,用的全是开发者工具自带的正常功能和JavaScript语言本身的特性,没有太多黑魔法。关键是思路别被“反爬”两个字框住。你越是对抗它的规则,越容易被层出不穷的动态写法耗死;你直接从运行机制层面把它的触发条件弄没了,再牛的无限debugger也只能当一段普通代码跑过去。
这套组合拳现在已经成为我处理前端调试卡顿的固定工具,遇到类似恶心站点就用上几分钟解决战斗。希望我的这些经验能让你别在debugger上浪费太多时间,把精力花在真正想研究的问题上。
