做前端开发、做爬虫采集、做自动化测试的朋友几乎都绕不开 JavaScript Hook 脚本。说白了Hook 就是“钩子”在函数执行之前或之后插入我们自己的一段逻辑等这段逻辑跑完再放行让原函数继续执行。这技术听起来有点“黑”其实用起来特别朴素日常调试、性能分析、页面数据采集、篡改猴脚本全都靠它。这篇东西适合两类人一类是刚接触 JavaScript、想知道怎么监听和劫持页面行为的新手另一类是已经会写一点 JS但每次要 Hook 时总得现场查资料、踩坑的人。我把自己这几年积累的常用模板和避坑心得一次性整理出来。开头先说明白Hook 不是魔法它只是 JS 函数一等公民这个特性带来的“替换函数”技巧。1. 先把原理说透Hook脚本到底在Hook什么1.1 Hook的本质就是替换、拦截、再放行在 JavaScript 里函数也是对象函数名本质上就是个变量引用。你用window.setTimeout myFn就把全局的 setTimeout 换成了自己的函数。这种“替换”的能力是 Hook 的地基。但我必须强调一点真正合格的 Hook 不是把原函数彻底弄没而是把原函数先存到一个变量里在需要的时候调用它保证原有逻辑不丢。可以理解成一个保安正常情况你直接进门现在多了一道岗保安要拦住你登记一下然后放你进去。这就是 Hook 技术原理的核心——劫持、改写、再放行三步。这里有一个新手容易混淆的概念Hook 函数与普通的事件监听有什么不同监听是“旁边看着”Hook 是“中间拦截”。比如addEventListener可以让你知道用户点了按钮但你不能在点击事件里把按钮的默认行为修改成别的逻辑后再继续而 Hook 可以直接替换按钮点击处理器甚至替换掉整个框架内部的方法。理解了这一点后面所有模板你都能看懂了。1.2 JavaScript里三类最常碰的Hook点第一类是函数级 Hook。我可以把任何一个全局函数替换成自己的版本比如window.fetch、window.setInterval替换后做参数记录、结果篡改、异常上报。第二类是对象方法 Hook常见的有XMLHttpRequest.prototype.open、console.log、Array.prototype.push。第三类是属性读写 Hook也就是用Object.defineProperty给对象属性添加 getter/setter这样才能知道某个全局变量什么时候被修改了。这三类基本覆盖了日常 90% 的需求我在后面的章节会分别给出可直接用的模板。提示做 Hook 前先想清楚你要“看”还是“改”。只想观察就用原函数包装想动态改结果就在返回值上动手。别把“观察型”脚本写成“修改型”否则页面大概率被你玩坏。2. 在哪儿写Hook脚本三种注入方式怎么选2.1 浏览器控制台临时调试最快的路子只是临时看一眼某个请求的参数或者某个函数的调用栈不用任何工具F12 打开控制台粘贴一段立即执行函数就行。比如我想记录页面上所有 XMLHttpRequest 请求可以直接粘贴我下面的模板(function () { const originalOpen XMLHttpRequest.prototype.open; const originalSend XMLHttpRequest.prototype.send; window.__xhrLog []; XMLHttpRequest.prototype.open function (method, url, async, user, pass) { this.__xhrInfo { method: method, url: url }; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send function (body) { if (this.__xhrInfo) { window.__xhrLog.push({ info: this.__xhrInfo, body: body, time: Date.now() }); } return originalSend.apply(this, arguments); }; })();这里最关键的细节是originalOpen.apply(this, arguments)这两个字。为什么不能直接写originalOpen(...)因为open方法里的this必须是发请求的那个 XHR 实例如果直接调用originalOpen(...)this就会丢失浏览器会报错“Illegal invocation”。任何 Hook 脚本只要涉及到对象方法都必须保留this。这是一个非常经典的坑我在后面的常见问题里还会再讲。2.2 篡改猴脚本长期运行和自动注入更省心如果这个 Hook 逻辑需要每次打开页面都生效那就别再用控制台手贴了。我推荐用篡改猴Tampermonkey这类油猴插件直接在脚本管理页面新建脚本配置好元数据块的match和run-at就能自动注入。match 控制“这个脚本在哪些网址生效”run-at 控制“页面加载到什么阶段去执行”。比如我想让脚本在 document-start 阶段就执行就可以在run-at document-start下写代码这样比等待整个 DOM 加载完更早介入。举个实用例子之前有个朋友想快速看页面里所有视频的旋转效果网上经常看到javascript:v document.queryselector(video);v.style.rotate -90度这种片段。这里其实有两个问题第一querySelector的 S 是大写写成全小写是找不到元素的浏览器直接报TypeError: document.queryselector is not a function第二querySelector只匹配第一个视频元素页面有多个视频时得用querySelectorAll加循环。我用篡改猴脚本把这两个问题都修掉了// UserScript // name 旋转所有视频 // match https://* // run-at document-idle // /UserScript (function () { const videos document.querySelectorAll(video); videos.forEach((v) { v.style.rotate -90deg; }); })();这只是个最简单的示范。篡改猴脚本最大的价值是它可以跨页面长期运行我经常在里面写各种调试辅助脚本比如把接口返回的 JSON 直接在页面上展示成表格、把加密参数自动复制到剪贴板等等。脚本的grant字段也要留意它决定脚本能不能访问GM_setClipboard、GM_xmlhttpRequest这些增强 API。2.3 DevTools断点和Override不污染代码的方式有些场景我不想在控制台里粘贴任何东西也不想改页面源码。比如我在排查一个按钮点击后为什么没反应我可以在 Sources 面板里找到那个绑定事件的 JS 文件在可疑函数那行打上断点。断点停下来后控制台里可以手动执行表达式查看变量甚至可以修改局部变量的值再继续运行。这本质上也是一种 Hook只是它由调试器帮你完成。如果你要改的是静态文件还可以用 DevTools 的 Overrides 功能把某个 JS 文件保存到本地改完直接生效。不过这种方式我不太推荐长期用因为本地覆盖文件的维护成本和切换成本都很高。我个人更常用的组合是快速定位用断点临时记录用控制台注入稳定自动化用篡改猴脚本。3. 真正值得背下来的几个常用Hook模板3.1 Hook网络请求fetch、XHR与常见重定向技巧网络请求是前端里最常被 Hook 的对象因为很多问题都出在请求参数、响应结构上。先说 XHR上面我给了 open 和 send 的模板这里补一个返回响应的拦截。由于 XHR 的响应是异步到达的所以要在readystatechange事件里监听(function () { const originalOpen XMLHttpRequest.prototype.open; const originalSend XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open function (method, url, async, user, pass) { this.__url url; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send function (body) { this.addEventListener(load, function () { if (this.__url this.__url.indexOf(/api/) ! -1) { console.log(响应来了, this.__url, this.responseText); } }); return originalSend.apply(this, arguments); }; })();fetch 的 Hook 和 XHR 不太一样因为 fetch 返回的是 Promise你需要在then的前后做处理。如果想把响应内容打印出来注意不能只改Promise.resolve的返回还要重新包一层(function () { const originalFetch window.fetch; window.fetch function () { console.log(fetch 请求参数, arguments); const promise originalFetch.apply(this, arguments); return promise.then(function (response) { console.log(fetch 响应状态, response.status, response.url); return response; }); }; })();这里有个坑如果你在 then 里直接返回原来的 response后续代码还能正常使用但如果某些页面代码会调用response.json()而你已经调用过一次response.json()来读取内容那后面的代码再调用就会报“body used already”。所以只是记录状态就别提前消费 body想改 body 就必须自己构造新的 Response 对象。这种细节真是血的教训。3.2 Hook函数调用链参数、调用栈、异常一次性拿全我常用一个通用包装函数不管 Hook 什么方法都能记录参数和调用栈function hookFunction(obj, methodName, onCall) { const original obj[methodName]; obj[methodName] function () { let result; try { result original.apply(this, arguments); } catch (e) { onCall onCall(methodName, arguments, e); throw e; } if (onCall !result) { onCall(methodName, arguments, null); } return result; }; }这里的arguments是一个类数组对象最好先转成真正的数组再记录不然 console 里看到的是Arguments { ... }很多序列化工具不支持直接复制。转换方法很简单Array.from(arguments)或者[...arguments]。另外如果原函数返回值本身就是个对象或 Promise你要在 onCall 里做进一步处理不能只记录“null”。我还想特别说明一下调用栈的价值。有时候一个函数被很多地方调用你光知道参数不够还必须知道是哪里调用的。在 onCall 里可以用new Error().stack拿到当时的调用栈。比如hookFunction(window, alert, function (name, args) { console.log(alert被调用了, args[0], \n调用栈:\n, new Error().stack); });这样能快速定位某个弹窗到底是谁弹出来的。这个方法在排查第三方 SDK 的奇怪弹窗时非常管用我至少帮同事解决过三次类似的定位问题。3.3 Hook对象属性读写监听变量何时被赋值光 Hook 函数还不够有时候你得监听某个全局对象/变量的赋值时机。比如页面里有个window.loginState你想知道它是从哪些值变成某个值的。直接轮询太笨用Object.defineProperty加一个 setter 就好(function () { let value window.loginState; Object.defineProperty(window, loginState, { get: function () { return value; }, set: function (v) { console.log(loginState 被修改为, v, 调用栈:, new Error().stack); value v; }, configurable: true }); })();这里必须强调configurable: true否则后续脚本再想重新定义这个属性会直接抛错。另外这个方式只能 Hook 已经存在的“数据属性”如果目标属性本身是不可配置的你会看到TypeError: Cannot redefine property。遇到这种情况我一般会退而求其次在目标赋值的地方断点或者在对象外面包一层 ProxyProxy 也可以但代价更大。在面试场景里这个属性的 getter/setter 劫持也经常被拿来考“数据双向绑定原理”理解了它对 Vue 2 的响应式原理也会更容易上手。3.4 Hook字符串调用函数动态调用的正确姿势在写 Hook 脚本时经常遇到一个需求我要动态调用一个名字存在字符串里的函数。比如页面把回调函数名存在window.__callbackName handleResult里我要带着参数调用它。最直接的方式是window[window.__callbackName](data)这没问题。但要注意如果你不确定这个函数存不存在可以先判断const fnName handleResult; const fn window[fnName]; if (typeof fn function) { fn.call(null, data); } else { console.warn(函数不存在, fnName); }有朋友会想用eval(handleResult(data))我要劝一句在 Hook 脚本里尽量别用 eval因为你原本就是在操作不稳定的页面环境eval 的字符串很容易把页面自己的变量和你的变量串在一起出错了还特别难调试。Function构造器和eval更是能不用就不用它们除了带来安全隐患还会让代码的可读性和兼容性都下降。动态读取对象属性和用window[fnName]这种反射方式已经能满足 90% 以上的场景。4. 实操演示用Hook脚本解决一个真实的小问题4.1 场景合并配置对象时老是被覆盖我做油猴脚本时经常面对一个问题脚本默认配置和页面传过来的配置要合并。刚开始我用Object.assign({}, defaultConfig, userConfig)表面看着没问题用着用着发现嵌套对象被整体覆盖了。比如 defaultConfig 里有settings: { theme: dark, fontSize: 14 }用户配置只传了settings: { fontSize: 16 }合并完 theme 就没了。这就是典型的浅拷贝问题。JavaScript 合并两个对象看起来简单但深浅拷贝的差别会直接影响 Hook 脚本的健壮性。后来我写了一个极简的深合并函数放在脚本里反复用function deepMerge(target, source) { const result Array.isArray(target) ? [] : {}; for (const key in target) { result[key] target[key]; } for (const key in source) { const value source[key]; if (value typeof value object !Array.isArray(value)) { result[key] deepMerge(target[key] || {}, value); } else { result[key] value; } } return result; }这个函数纯手写不需要引 lodash逻辑也很直白遇到对象就递归遇到基本类型和数组就覆盖。数组为什么不递归因为配置项里的数组往往不固定递归合并会出现 index 错位反而更麻烦。真正需要深合并数组的情况非常少等你真的碰到再去引入成熟的合并库也不迟。这个合并问题虽然不是 Hook 本身但它几乎是每个 Hook 脚本里都会出现的配套问题我放在这一节就是想提醒大家脚本的健壮性和你的 Hook 逻辑一样重要。4.2 场景页面里出现一堆报错定位不到源头JavaScript 运行时报错是每个前端都头疼的。很多报错是第三方库内部抛出的页面又没有全局错误处理你光看控制台根本不知道是哪一个调用链触发的。这时候可以用一段立即执行的 Hook 脚本来做全局异常捕获(function () { window.addEventListener(error, function (event) { console.log(捕获运行时报错:); console.log(错误信息:, event.message); console.log(出错文件:, event.filename); console.log(行号:, event.lineno); console.log(列号:, event.colno); console.log(错误对象:, event.error); if (event.error event.error.stack) { console.log(错误堆栈:\n, event.error.stack); } }, true); window.addEventListener(unhandledrejection, function (event) { console.log(未处理的Promise异常:, event.reason); }); })();这个代码本身不复杂但它能帮你在第一时间把所有异常集中打印带时间线记录的信息也比浏览器默认输出更完整。这里要提醒一点事件监听的第三个参数是true也就是捕获阶段。因为很多错误是在目标阶段之前被其他代码处理的捕获阶段监听能更早获取信息。另外error事件里的event.error.stack不一定总是存在浏览器兼容性有差异所以打印的时候要做兜底判断。4.3 场景需要模拟点击/自动操作但框架事件绑定难搞有些页面按钮绑定了框架内部方法直接调用button.click()不一定触发框架的逻辑。这时候我会先找按钮对应的 Vue 或 React 内部实例再调用它内部方法。不过这个操作依赖框架内部数据结构容易随版本变化失效不是长期稳定的方案。更稳的做法是“从源头截断”用 Hook 脚本把框架暴露的核心方法劫持掉直接注入我们想要的行为。比如我之前想知道一个后台系统里“保存”按钮到底提交了什么数据就 Hook 了window.syncData这样的核心 API在提交前把 payload 打出来再原样放行。这种思路比模拟点击更可控因为你不依赖 DOM 元素层级只依赖函数调用点。唯一的代价是你要先费点功夫找到那个核心函数名这个可以用 DevTools 的搜索面板对 JS 文件做全局搜索。5. 常见问题与排查技巧实录5.1 为什么我的Hook脚本没生效过一会儿函数又被还原了这个问题在单页应用里特别常见。很多前端框架会在路由切换时重新加载模块把全局函数重新赋值一遍你的 Hook 就失效了。解决办法有几个一是尽量 Hook 底层原型方法比如XMLHttpRequest.prototype.open这种一般不会被页面重新赋值二是用轮询检测每隔一定毫秒检查目标函数是否还是你的包装函数如果不是就重新挂上去三是如果修改的是某个对象实例的方法优先在构造函数定型之后再 Hook而不是在模块加载时 Hook。我个人的经验是先判断目标函数是“全局环境”的还是“闭包内部”的。闭包内部的基本没法直接 Hook只能通过断点定位内部源码或者绕到上层 API 入口。如果连入口都找不到就别硬 Hook 了退一步从事件监听入手用捕获阶段监听加原生事件模拟通常也能达到目的。5.2 this指向丢了报“Illegal invocation”这个问题我在前面已经提过一次但值得单独拉出来再说一遍。直接写const fn obj.method; fn()时fn 内部的 this 已经不再指向 obj 了因为函数调用时的 this 取决于调用方式而不是定义位置。所以在 Hook 函数里调用原函数的时候必须用original.apply(this, arguments)或者original.call(this, ...args)。这也是面试里常问的“this 绑定规则”在真实项目里的体现。如果你对 this 的规则不熟我建议你直接用 apply arguments 这套万能写法至少在 Hook 场景里不会出错。还有一个小细节如果你在箭头函数里保存了this那么 apply 的第一个参数传什么都无所谓因为箭头函数内部没有自己的 this。所以 Hook 包装函数本身千万别用箭头函数否则你会把 this 弄丢。5.3 Hook了console.log结果递归爆栈我见过很多新手在 Hook console.log 的时候写const originalLog console.log; console.log function (...args) { console.log(打印了, args); // 这里又调用了 console.log而 console.log 已经被你改了于是无限递归 originalLog.apply(console, args); };这就是一个典型的死循环。正确做法是确保内部调用的是“原始函数”或者用一个标志位防止递归const originalLog console.log.bind(console); let logging false; console.log function (...args) { if (!logging) { logging true; originalLog([Hook], ...args); logging false; } originalLog.apply(console, args); };这里我先 bind 了一次保住了 console 作为 this避免后续在包装函数里丢失上下文。这种“递归防护”在很多 Hook 场景里都会用到尤其是你 Hook 的方法本身是你日志输出依赖的方法时。5.4 关于DOM API大小写和常见书写误区热搜里有条javascript:v document.queryselector(video)我猜是有人在浏览器地址栏里输入了 javascript: 协议。这里有两个要注意的点首先浏览器地址栏现在对 javascript: 协议支持很有限很多环境会忽略或者弹出安全提示不建议这样用。其次querySelector是驼峰命名queryselector全小写是无效方法。类似的还有querySelectorAllS 和 A 都要大写。这些属于 JavaScript 基础中的细节但实际写 Hook 脚本时一个大小写错误会浪费你好几分钟。所以我在调试脚本时优先在控制台里先输入一半方法名看自动补全提示有没有匹配再写完整代码。5.5 关于 javascript:void(0) 和链接相关逻辑顺带提一个相关的高频词javascript:void(0)经常被写在 a 标签的 href 里表示点击链接不做任何跳转只执行绑定的 onclick 事件。很多旧项目都用这个写法。如果你想统一拦截这种链接的点击事件与其在页面上绑一堆监听不如从事件入口做更全面的观察或者干脆在框架层面处理。不过说实话这种“拦截”很容易误伤我自己一般只在分析整页行为时临时用不会长期挂。6. 写了这么多年Hook脚本我给自己定的规矩6.1 三条底线不伤害页面、不留后患写 Hook 脚本这些年我最大的体会是两个字克制。Hook 能力越强越不要乱用。你在控制台里随手改一下全局函数当时看着没问题可能在某个边缘场景就造成数据错乱、页面白屏甚至把线上数据提交错。所以我给自己定了几条底线只在本地调试或测试环境使用不再需要时立刻刷新页面还原环境能不 Hook 就不 Hook能用事件委托或原生重写解决就优先用更温和的方式。如果一定要长期跑某个 Hook 脚本我建议你加上开关和版本号至少在脚本崩溃的时候能快速关掉。还要注意脚本在内存占用和性能上的影响尤其是拦截高频调用比如 requestAnimationFrame、滚动事件时包装函数里别做太重的序列化和 console 输出否则页面会明显变卡。6.2 把模板沉淀成自己的工具箱最后再分享一个小技巧如果你想复用自己常用的 Hook 模板建议把它们整理成一个通用工具函数库放在本地或者自己的篡改猴脚本仓库里而不是每次现场重写。把hookFunction、deepMerge、onErrorCapture、fetchHook这些函数沉淀下来你会发现自己后来越来越快而且出错率直线下降。我就是靠这套“模板经验”的组合几乎没在 Hook 问题上再翻过车。每次遇到新场景无非是“保存原函数、包装调用、适时放行”这三步换个壳而已。