
上线第三天活动页的券码算法被人整段复制到一个空白 HTML 里跑通连本地生成签名的顺序都被完整还原了。那一刻我才意识到前端代码从来就不是藏在浏览器里别人看不见的东西只要按一下 F12开发者工具一开Sources 面板里所有被压缩过的 js 都能格式化、加断点、单步跟。于是我开始认真研究js 检测开发者工具是否打开这类方案想搞清楚它到底能挡住什么、挡不住什么、代价是什么。这篇就把我这几年前前后后试过的写法、踩过的坑、以及最后为什么放弃了一部分手段完整讲一遍。内容适合两类人看一类是做前端保护、防爬、防篡改的同学想知道代码怎么写、阈值怎么定另一类是纯粹好奇浏览器里那些检测到开发者工具已打开请关闭后刷新页面继续访问提示是怎么弹出来的。全文不涉及任何绕过授权系统的用途只讨论技术原理和工程落地。1. 从一次前端逻辑被人整段扒走说起1.1 那个把检测需求逼出来的真实场景事情本身不复杂。我们的活动页需要在浏览器端根据用户等级、渠道来源、时间戳算出一个参与资格然后带着签名去请求发券接口。算法写在前端压缩混淆做了变量名改成了 a、b、c但逻辑结构没变。对方用开发者工具打开页面在 Sources 里找到被打包后的文件点一下美化再在 Network 里照着请求参数一个个对照半天时间就把流程摸清楚了。这里最关键的一点是前端代码对访问者是完全公开的。它不像服务端代码跑在你自己的机器上前端代码是下载到别人的浏览器里执行的只要你打开页面代码就已经躺在对方的磁盘缓存里了。压缩、混淆、字符串加密这些都只是把阅读难度从看一眼就懂抬到要花几个小时看懂而不是让它变成看不懂。所以在我这里开发者工具检测的定位从来不是安全防护而是成本控制——让随手按 F12 的人多一道心理门槛让批量扒站的脚本多一层不确定性。它是个减速带不是防盗门。想清楚这一点后面很多取舍就好做了既然只是减速带那就不该为了它牺牲正常用户的体验也不该指望它挡住真正有心的人。1.2 为什么检测到开发者工具这句话本身就有歧义很多人第一次写这个功能都会踩一个概念坑他以为浏览器提供了某个 API能告诉你开发者工具现在是不是开着。实际上没有。浏览器厂商明确不想让你知道这件事因为知道这件事对用户是有风险的——它可以被用来做指纹识别、用来判断你是不是在分析广告逻辑。所以我们能做的一切检测本质都是间接推断通过观察一些开发者工具打开后会产生的副作用反过来猜它开没开。这些副作用包括窗口可视区域变小、console 里的对象被读取了某些属性、debugger语句变慢了等等。既然是推断就一定会误判——用户把浏览器窗口拖小、系统缩放调大、装了某些插件都可能产生一模一样的信号。理解了没有官方 API这个前提你就不会再去搜最准确的检测方法了因为你找不到。你要找的是误报率可接受、实现成本可控、被绕过后影响不大的方法组合。1.3 什么场景值得上什么场景纯属给自己找麻烦我的判断标准很粗暴就三条前端有值得保护的资产比如付费内容的分片密钥、券码生成规则、模型推理的权重参数。如果前端只是些表单校验和样式切换那不值得真的不值得。误伤的代价你能承受如果错误地把正常用户判成打开了开发者工具并阻断访问会掉多少转化客服要接多少投诉这个账要提前算。你有降级方案检测失败、报错、被绕过的时候页面必须还能正常用。任何时候都不该让检测逻辑成为主流程的强依赖。反过来有三种情况我建议直接放弃面向企业内部员工的系统员工本来就有权看代码、纯展示型官网没有可保护的东西、以及移动端为主的产品尺寸检测在移动端基本全废后面会细说。2. 五种检测思路的原理拆解2.1 窗口尺寸差值法最早普及也最容易失效这是流传最广的一种。原理是开发者工具在 Chrome 里默认以停靠方式显示在窗口右侧或底部停靠时会占用一部分窗口空间导致页面的可视区域变小但窗口外框尺寸不变。于是window.outerWidth - window.innerWidth就会出现一个明显的大于零的差值。const THRESHOLD 160; function checkBySize() { const widthGap window.outerWidth - window.innerWidth; const heightGap window.outerHeight - window.innerHeight; return widthGap THRESHOLD || heightGap THRESHOLD; }这个阈值为什么是 160 而不是 0因为正常情况下浏览器自身的 UI 也在占空间顶部有标签栏、地址栏、书签栏底部可能有下载条右侧可能有滚动条。这些加起来通常有几十像素的差值全屏模式下甚至接近零。而开发者工具停靠时最少也要占 200 到 300 像素。160 这个值是社区里试出来的经验中间值能滤掉大部分正常的浏览器 UI 干扰。但它的问题也很致命只在停靠模式下有效。用户只要把开发者工具拖出来变成独立窗口Chrome 里点右上角三个点选择 Dock side 取消停靠页面可视区域就恢复了差值为零检测直接失效。而且这个属性在高分屏、系统缩放 125% 或 150% 的机器上数值会失真误报率明显上升。2.2 console 对象陷阱重写 toString 和 getter 钓鱼这一类思路更巧妙利用的是开发者工具在渲染 console 内容时会执行一些额外操作这个特性。最早的一个版本是检测正则的toString。做法是定义一个正则对象把它的toString方法改掉然后console.log出去。在没有打开开发者工具的浏览器里console 的内容根本不会被渲染toString不会被调用而一旦打开了开发者工具控制台会尝试预览这个对象就会调用toString于是我们的钩子就被触发了。let detected false; function probeByRegex() { const re /test/; re.toString function () { detected true; return probe; }; console.log(re); console.clear console.clear(); return detected; }后来更流行的是getter 钓鱼法思路是构造一个 DOM 元素给它定义一个id属性的 getter然后把它console.log出去。开发者工具在渲染这个 DOM 节点时会去读取诸如id、className这类属性来生成预览getter 就被触发了。let hit false; function probeByGetter() { const el document.createElement(div); Object.defineProperty(el, id, { get() { hit true; return probe-id; } }); console.log(el); return hit; }这种方法的优点是它不依赖窗口布局全屏、独立窗口、双屏都不影响。缺点是它在部分浏览器版本上会被优化掉而且频繁往控制台打日志本身会拖慢页面还会把用户正常的 console 输出冲掉。另外Vue、React 这类框架的开发版本自身也会往 console 打东西容易互相干扰。2.3 debugger 时间差威力最大副作用也最大debugger语句的作用是告诉浏览器如果开发者工具开着并且启用了断点就在这里停下来。如果没开工具它就是一行为空的语句几乎不耗时如果开了工具并且没禁用断点页面线程会被挂起直到用户点击继续。于是就有了这种写法function probeByDebugger() { const start performance.now(); // 这里加个循环是为了让优化器不敢把 debugger 挪走 for (let i 0; i 1; i) { debugger; } const cost performance.now() - start; return cost 100; }我实测下来的感受是它的判定准确率是最高的但代价也是最重的。因为开了工具的开发者会被这个断点反复拦住体验极差而如果检测逻辑放在setInterval里每秒跑一次页面性能会肉眼可见地掉。更麻烦的是有些用户装了对开发者友好的扩展或者自己把断点禁用了那这个检测就静默失效。所以我最后的做法是debugger只作为低频的最后一道校验比如 30 秒跑一次而且只在其他信号已经积累到一定程度时才启用。绝不把它当成常驻的轮询手段。2.4 Function.prototype.toString 与原型篡改的痕迹检测这一类不是检测工具开没开而是检测有人动过我的运行环境。常见的检查点有三个console.log.toString()里是否还包含[native code]。真实的原生方法在上报自己的源码时会有这个标记如果被人重写成了普通函数标记就没了。Function.prototype.toString本身有没有被替换。因为上面那个检查会被重写 toString 让它返回假的原生字符串这种方式骗过所以要再校验一下toString自己。某个对象属性描述符是否被改成了 getter/setter。function checkNative(fn) { return /\{\s*\[native code\]\s*\}/.test(Function.prototype.toString.call(fn)); } function checkEnvIntact() { return checkNative(console.log) checkNative(Function.prototype.toString); }这类检测的价值在于它标志的是环境可信度而不是工具是否打开。两者结合起来判断准确率会明显提升单独一个尺寸信号可能是用户拖了窗口但如果尺寸信号加console 被重写同时出现那基本可以确定有人在动环境了。2.5 五种思路的横向对比我把上面这些方法整理成一张表方便按场景取舍。方法原理优点主要缺陷建议权重窗口尺寸差值停靠 devtools 占用可视区域实现最简单零副作用独立窗口失效高分屏误报低console 正则/Getter 钓鱼渲染 console 时触发钩子不依赖布局判定快有版本兼容问题污染控制台中debugger 时间差断点挂起导致耗时变长准确率最高严重阻塞体验差仅在最后校验用原生方法完整性toString 标记是否被改能识别环境被动手脚会被伪造原生字符串骗过中console.memory 等特性嗅探工具打开时特定对象才存在声明式代码短各浏览器支持不一致易变低看这张表能得出一个结论没有任何一项的准确率高到可以单独用。这也是我后面要讲组合判定模块的原因。3. 写一个能上生产的检测模块3.1 为什么我不再推荐任何单一信号的方案刚开始我是拿尺寸检测直接上线的阈值设了 160。上线第二天就收到反馈有个用 4K 屏、系统缩放设成 150% 的用户每次打开页面都提示检测到开发者工具已打开请关闭后刷新页面继续访问但他根本不知道什么是开发者工具。这种事故的性质很糟糕——你把一个完全正常的用户挡在门外而且给的理由他看不懂。单一信号的第二个问题是它的失效是静默的。你的检测返回 false你并不知道是因为真的没开还是因为开着但方法失效了。你没有任何中间信息来判断这条防线还在不在。所以我改成了多信号加权打分每个检测项返回一个 0 到 1 之间的置信度乘上各自的权重后累加只有总分超过阈值才判定为打开了。这样做的好处是单个信号的抖动不会直接触发误报你还能把每次的得分打点上报观察真实用户群体里的分数分布。3.2 权重分配和冷却时间的设计思路权重怎么定我的经验是按这个信号的误报率倒推。尺寸检测误报率高给 0.2getter 钓鱼误报率低但有时不触发给 0.3debugger耗时几乎不误报给 0.5。总分阈值定在 0.65 左右也就是说必须有两个以上信号同时命中才会判定。冷却时间同样重要。判定为已打开之后不能每次都回调否则用户会看到提示框疯狂闪烁。我通常设两个状态进入检测状态时触发一次回调退出检测状态时触发一次恢复回调中间的过程只在后台记录。const CONFIG { interval: 1200, // 轮询间隔太小会掉帧 threshold: 0.65, // 判定阈值 debuggerEvery: 20, // 每 20 轮才跑一次 debugger 校验 sizeThreshold: 160 };轮询间隔 1200 毫秒是权衡出来的结果。800 毫秒以下的时候在低端安卓机上能明显感觉到滚动卡顿2000 毫秒以上又太迟钝用户开了工具半天页面才有反应。3.3 一个可直接改造的完整实现下面这个类是我目前还在用的结构拆掉了业务相关的部分只保留检测骨架。它的核心设计是每个探针返回分数累计判定带状态机和冷却。class DevtoolsDetector { constructor(options {}) { const cfg Object.assign({ interval: 1200, threshold: 0.65, debuggerEvery: 20, sizeThreshold: 160, onOpen: function () {}, onClose: function () {} }, options); this.cfg cfg; this.timer null; this.round 0; this.active false; this.lastScore 0; this.probeEl this.buildProbeElement(); } // 构造一个带 id getter 的元素用作 console 钓鱼 buildProbeElement() { const self this; const el document.createElement(div); let touched false; Object.defineProperty(el, id, { get() { touched true; return p; } }); el.__touched function () { return touched; }; el.__reset function () { touched false; }; return el; } probeSize() { const w window.outerWidth - window.innerWidth; const h window.outerHeight - window.innerHeight; if (w 0 h 0) return 0; // 全屏模式直接放弃这个信号 const gap Math.max(w, h); if (gap this.cfg.sizeThreshold) return 0; // 差值越大置信度越高封顶 1 return Math.min(1, (gap - this.cfg.sizeThreshold) / 200); } probeConsole() { const el this.probeEl; el.__reset(); console.log(el); if (el.__touched()) return 1; return 0; } probeNative() { const isNative function (fn) { try { return /\{\s*\[native code\]\s*\}/.test( Function.prototype.toString.call(fn) ); } catch (e) { return false; } }; let score 0; if (!isNative(console.log)) score 0.5; if (!isNative(Function.prototype.toString)) score 0.5; return Math.min(1, score); } probeDebugger() { const start performance.now(); // eslint-disable-next-line no-debugger debugger; return performance.now() - start 100 ? 1 : 0; } tick () { this.round 1; let score 0; score this.probeSize() * 0.2; score this.probeConsole() * 0.3; score this.probeNative() * 0.2; if (this.round % this.cfg.debuggerEvery 0) { score this.probeDebugger() * 0.5; } this.lastScore score; if (!this.active score this.cfg.threshold) { this.active true; this.cfg.onOpen(score); } else if (this.active score this.cfg.threshold * 0.5) { this.active false; this.cfg.onClose(); } } start() { if (this.timer) return this; this.timer setInterval(this.tick, this.cfg.interval); return this; } stop() { clearInterval(this.timer); this.timer null; return this; } }用法上很简单new DevtoolsDetector({ onOpen() { document.body.classList.add(devtools-open); }, onClose() { document.body.classList.remove(devtools-open); } }).start();有几个细节我要强调。第一console.log(el)之后我没有清空控制台因为console.clear在某些浏览器里会影响用户的正常调试记录而我们的目的是保护自己的代码不是骚扰别人。第二probeDebugger里我用的是裸debugger语句而不是包在eval里实测下来在现代浏览器里效果是一致的且更容易被代码审查工具识别出来方便后续维护。第三退出判定的阈值我设成了进入阈值的一半0.65 * 0.5这是为了防止分数在阈值附近反复横跳导致提示框闪烁——这叫滞回做传感器的人应该很熟。3.4 检测到之后到底该做什么这是我见过最多人做错的地方。很多实现一检测到就直接location.reload()甚至跳转到一个空白页。结果就是用户不小心按了 F12 想看看图片地址页面开始无限刷新他连关掉工具的机会都没有。我的处理顺序是这样的首选软提示。给body加一个 class用 CSS 遮住核心内容区中间显示一句检测到调试环境部分内容暂不可用。用户可以继续操作只是看不到关键区域。其次降级。如果被遮住的是付费内容就让接口返回一个不含真实数据的版本前端渲染占位符。用户看到的页面是完整的只是内容是空的。最后才是阻断。只有在非常明确的场景下比如高频爬取已经在日志里暴露出来了才会做阻断而且阻断要带一个明确的恢复路径比如关闭工具后点击这里继续。注意任何形式的整页刷新循环都要绝对避免。它会让浏览器历史记录被塞满用户按返回键都出不去这种体验事故比代码被看到严重得多。4. 实测中的翻车现场4.1 Chrome 停靠模式和独立窗口模式的差异我做过一个小范围的对照测试让十来个同事分别在两种模式下打开同一个页面。停靠模式下尺寸差值稳定在 280 到 420 之间判定毫无压力切成独立窗口之后差值直接掉到 5 到 20只剩浏览器自身的 UI 差值尺寸信号完全失效。而 console 钓鱼探针在两种模式下都能命中因为它走的是渲染路径跟布局无关。这个结果直接改变了我的权重设计尺寸信号从原来设想的 0.5 降到 0.2console 探针从 0.2 提到 0.3。因为一个只在特定模式下有效的信号无论它在那种模式下多准都不该占太高权重。4.2 移动端和折叠屏上尺寸法基本等于不可用移动端的浏览器根本就不存在开发者工具停靠这个概念。你在手机上按不了 F12真要调试得连电脑走远程调试。所以尺寸差值在移动端要么恒为零要么是地址栏收起展开造成的几十像素抖动。我试过在几台安卓机上跑误报率超过三成全是地址栏高度变化导致的。折叠屏更夸张。展开和折叠时可视区域会发生巨大的跳变差值轻松超过 160直接触发误报。所以我现在的策略是用 UA 做一次能力判断移动端直接不注册尺寸探针。检测模块里加一句判断就够了const isMobile /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);4.3 用户缩放页面导致的误报这个坑我是被投诉之后才想明白的。用户按住 Ctrl 加号把页面放大到 150%或者用触控板双指缩放会导致devicePixelRatio变化、可视区域变化从而影响尺寸差值。更麻烦的是某些浏览器在缩放状态下outerWidth和innerWidth的单位基准不一致差值会凭空多出上百像素。解决办法有两个。一个是在缩放比例不为 1 时直接跳过尺寸探针另一个是连续观察多轮只有差值稳定保持在大值才计分单次抖动不计。我用了后者实现上就是给尺寸探针加一个计数器连续三轮超阈值才返回分数。4.4 性能开销轮询频率到底该选多少我用 Performance 面板做过对比在一个中等复杂度的页面上检测模块常驻运行时的开销大致是这样轮询间隔每秒主线程占用低端机首屏到可交互时间增量实际感受300ms约 6-9ms120ms滚动有明显掉帧800ms约 3-4ms60ms低端机偶有卡顿1200ms约 2ms30ms基本无感2000ms约 1ms18ms无感但响应偏慢数据是估的不同页面差异很大但趋势是可信的开销主要来自 console 探针和 debugger。所以我的建议是如果只能选一个参数来调优先拉长debugger的执行周期而不是拉长整个轮询间隔。5. 对手会怎么绕决定了你该防什么5.1 几种常见的绕过手法复盘站在防御方的角度你必须知道对方手里有什么牌否则你防的东西可能是完全无用的。最常见的几种绕过方式把开发者工具切成独立窗口。这一招直接干掉所有基于尺寸的检测成本为零。在页面加载前先下断点。对方在 Sources 里对某个早期加载的脚本下断点让你整个检测模块所在的文件根本没机会执行或者执行到一半就被拦下改掉返回值。覆盖探针方法。知道你用console.log钓鱼就把console.log整个替换成空函数你的探针永远不会被触发。禁用断点。Chrome 的 Sources 面板有个 Deactivate breakpoints 按钮开启之后所有debugger语句形同不存在。改分数线。如果对方能读到你的检测代码大多数情况下他都能他只要把阈值改大、把探针返回值改成 0检测就废了。看清楚这五条你会发现一件事当对方能读取并修改你的前端代码时你在前端做的任何检测都是可绕过的。这不是检测方法不够好这是架构决定的。前端代码运行在对方完全控制的沙箱里没有例外。5.2 混淆和反调试该怎么配合而不是互相拖累既然单靠检测不行那就得和代码保护手段配合起来用。我现在的做法是三层第一层是构建期的混淆。不是简单地把变量名改成 a、b、c那玩意两分钟就能还原。要做的是字符串加密、控制流平坦化、无效代码注入。做过混淆的代码即使被完整读到理解成本也会从几十分钟变成几天。这一层的目的就是让把检测代码找出来改掉这件事变得不划算。第二层才是运行期的检测。检测代码本身也参与混淆并且不要集中在一个文件里而是分散到多个业务模块中让不熟悉的人一眼看不出哪块是检测逻辑。同时检测结果不要太直白比如不要用devtoolsOpen这种一看就懂的变量名。第三层是服务端的兜底。这一步才是真正有价值的。前端检测只负责收集信号把本次会话的可疑分数上报给服务端由服务端决定这次请求要不要返回完整数据。这样一来即使对方绕过了所有前端检测服务端那边的策略还在。而且分数是累积的单次可疑不足以触发拦截多次可疑才会收紧误伤正常用户的概率就低了。5.3 把关键逻辑挪到服务端比任何检测都划算这条是我踩了足够多坑之后的结论。前端能拿到的东西最终都能被看到。所以判断一条逻辑要不要放前端问自己一个问题这条逻辑被完整公开我能不能接受不能接受的就别放前端。具体的拆法是这样的券码生成、签名计算的盐值和算法全部放服务端前端只传参数服务端算完返回结果。前端不参与任何密钥相关的运算。数值型的权重、阈值、价格区间不要在前端硬编码改成接口下发。这样即使别人拿到了前端代码也拿不到真实的业务参数。付费内容的实际内容做成按需分片下发且每次请求都要校验权限。不要一次性把整个内容塞进前端等着被切章节。真要保护算法考虑放到 WebAssembly 里或者用服务端冷启动的模型接口做推理。WASM 不是不可破但它把逆向成本又抬高了一个数量级。这么拆完之后你会发现前端检测从唯一的防线变成了整个体系里的一个探针。它的定位轻了你对它的期望也就合理了也就不会为了追求高准确率而牺牲用户体验。6. 别把正常用户当成对手6.1 上线前一定要统计误报率我给检测模块加了一个很轻的埋点每轮检测只上报lastScore的分桶值0-0.2、0.2-0.4 这样不上报任何用户标识。跑了一周之后正常用户群体的分数分布集中在 0 到 0.2 之间偶尔有 0.2 到 0.4 的离群点主要是缩放用户和双屏用户。这个数据很有用。它告诉我阈值定在 0.65 基本不会误伤同时也告诉我 0.2 到 0.4 这个区间是灰区可以在产品层做更轻的处理比如这些用户看到的内容做个轻微的随机化不阻断但也不给完整数据。如果你连这个数据都不收集就等于闭着眼睛定阈值早晚出事。6.2 检测失败时页面必须还能用这是我给自己定的硬规矩检测模块的任何一个探针抛异常都不能影响主流程。具体做法是把每个探针包在 try/catch 里任何异常直接返回 0并且用setTimeout而不是直接调用来执行检测避免它阻塞渲染。function safeProbe(fn) { return function () { try { const r fn.call(this); return typeof r number ? r : 0; } catch (e) { return 0; } }; }另外检测模块本身要能被远程关闭。做法是加一个开关配置通过接口下发或者读 URL 参数。万一上线后发现某个浏览器版本上误报严重你能在几分钟内全量关掉而不是等下一次发版。6.3 几条我自己踩出来的经验最后分享几个比较零散但很实用的心得。第一不要清空控制台。console.clear()看着很酷但它会把用户自己的调试输出也清掉而且这个行为本身就是一个巨大的信号等于你主动告诉对方我在这儿检测你呢。第二不要用debugger做高频轮询。我见过有人把它放在 200 毫秒的setInterval里结果就是打开工具的人页面直接卡死右键都点不动只能强杀标签页。这种做法在正常用户误触 F12 的时候会造成灾难性的体验。第三提示文案要写人话。检测到开发者工具已打开请关闭后刷新页面继续访问这句话对不懂技术的用户来说等于天书。如果一定要提示写成页面当前处于特殊状态关闭浏览器附加面板后会自动恢复都比前者好一点。当然最好的做法是根本不提示静默降级。第四定期回归测试。浏览器版本更新很勤检测方法的可用性会变。我一般每个大版本更新之后手动验证一遍每个探针在当前版本上的表现把失效的降权或者移除。第五也是最重要的想清楚你在保护什么。我见过一些项目页面上唯一值得保护的就是一段表单校验规则却硬要上全套检测结果把正常用户的转化率往下拉了两个点。这种投入产出比怎么算都是亏的。检测是个工具它服务于你的商业目标而不是反过来让产品为它服务。