1. 先把“拦截 response”这件事拆开来看
做 Electron 桌面端这几年,被问得最多的一类问题就是“怎么把页面里发出的请求的 response 拦下来看看”。问的人往往把它当成一个单一动作,实际上在 Electron 里,“拦截”这个词至少覆盖了三件完全不同的事:看到请求元信息、拿到响应体内容、改写响应再交还给页面。这三件事对应的 API 层级、实现成本、稳定性天差地别,很多人在第一步就选错了工具,然后在webRequest的回调里翻文档翻到怀疑人生。
我先把结论摆在前面:Electron 官方提供的session.webRequest系列钩子,天生就不提供 response body。你在onHeadersReceived里能拿到responseHeaders,在onCompleted里能拿到状态码和头信息,但 body 这一层 Chromium 从设计上就没往下抛。所以只要你的诉求里有“读响应内容”这半句话,就必须换路线——要么接管协议自己发请求再回填,要么挂调试协议从网络域里把 body 掏出来,要么在页面运行时劫持fetch和XMLHttpRequest。
这篇东西写给两类人:一类是刚上手 Electron、想给自己的客户端加一个“请求监控面板”或者“接口调试视图”的开发者;另一类是被某个具体需求卡住了,比如要做离线缓存、要做响应内容审计、要在不改后端的前提下给第三方接口打补丁。不管你是哪一种,下面这套拆解都能让你少走两天弯路。文章里所有方案都会给到能直接跑的关键代码和参数说明,同时把我踩过的那几个坑摊开讲清楚——有些坑不踩一遍根本想不到,比如contextIsolation开启后你在 preload 里改window.fetch为什么完全没反应。
1.1 你要的到底是响应头、响应体,还是改写后的响应
先做个需求分类,这决定了后面所有的技术选型。我把常见的诉求归成三类,你可以直接对号入座。
第一类:只要元信息。比如统计页面发出了多少个请求、哪些请求失败了、某个接口的耗时是多少、某个域的 Cookie 有没有带上。这类需求全部落在webRequest的能力范围内,成本最低,代码量最小,而且对性能几乎无影响。很多号称“要拦截响应”的需求,聊到最后发现其实只要一个状态码和耗时,根本不需要 body。
第二类:要读响应体内容。比如把 JSON 响应存下来做本地缓存、做接口数据的二次分析、做内容合规扫描、做自动化测试里的断言。这类需求是真正的主战场,也是本文的核心。三条路线(协议接管、调试协议、运行时劫持)都能干,但各自有明确的适用边界,选错了会很难受。
第三类:要改写响应再交给页面。比如把后端返回的字段做一层兼容映射、把图片请求重定向到本地缓存、给某个接口注入 mock 数据。这类需求只有“协议接管”和“页面劫持”两条路能走,调试协议那条路只能看不能改——这一点很多人没意识到,Network.getResponseBody是只读的,它拿不到改写能力。
分清这三类之后,你会发现所谓“拦截 response”其实是一个能力梯度:从只读元信息,到只读 body,再到可改写,每往上一级,实现复杂度就翻一倍。我个人的建议是永远从最低一级开始尝试,确认真的不够用再往上加,因为每一级增加的不只是代码量,还有稳定性和维护成本。
1.2 为什么 webRequest 天生给不了 body
这个问题值得单独说清楚,因为理解了原因,你就不会在错误的 API 上浪费时间。
Chromium 的网络栈是流式的。一个响应从建立连接到第一个字节到达,再到完整传输结束,整个过程是分块推进的,body 可能永远不被完整缓冲(比如一个持续推送的流式接口,或者一个几百兆的视频文件)。Electron 的webRequest模块本质上是 ChromiumURLLoaderFactory这一层的事件代理,它只负责在关键节点上通知你,不负责保存数据。让它在每个响应上都缓冲一份完整 body,等于把整个网络栈变成一个巨大的内存池,这在浏览器架构里是不可接受的。
官方文档里对onCompleted的描述也很直白:它只提供requestId、url、statusCode、responseHeaders、fromCache这类信息。你会看到很多人在论坛里问“怎么在 onCompleted 里拿到 body”,答案基本都是“拿不到,换方案”。
这里有一个常见误解:有人以为
onHeadersReceived的callback里可以塞点什么进去。实际上这个回调只能改responseHeaders,也就是响应头,body 的传输通道根本不经过你的回调。
理解了这一层,后面的方案选型就有逻辑基础了:想拿 body,就必须在数据流经的路径上自己插一脚。而“插一脚”的位置不同,就形成了三条完全不同的技术路线。
2. 四条技术路线的取舍:先选路,再写码
在正式写代码之前,我习惯先把候选方案的边界画清楚。下面这张表是我自己项目里长期用的对比,参数是按常见配置估的,实际数值会随版本和场景浮动,但相对关系是稳定的。
| 维度 | protocol.handle 接管 | debugger + CDP | preload 运行时劫持 | webRequest 组合 |
|---|---|---|---|---|
| 能否拿到 body | 能,且是完整原始字节 | 能,但受缓冲上限限制 | 能,取决于页面 API 用法 | 不能 |
| 能否改写响应 | 能,完全可控 | 不能,只读 | 能,但只能改页面可见的那一层 | 只能改响应头 |
| 是否影响请求链路 | 影响,请求会绕一圈 | 不影响,纯旁路 | 不影响,纯旁路 | 部分影响 |
| 对页面代码的侵扰 | 需要改请求地址或 scheme | 无 | 需要注入脚本 | 无 |
| 大文件友好度 | 高(可用流式) | 低(全量进内存) | 中(受页面自身读取方式限制) | 高 |
| 与 DevTools 冲突 | 无 | 有,同一 webContents 不能同时挂 | 无 | 无 |
| 实现复杂度 | 中高 | 中 | 中 | 低 |
| 版本兼容风险 | 中(API 迭代较快) | 低(CDP 相对稳定) | 低 | 低 |
2.1 按场景选型的三条判断线
光看表还不够,落到具体项目里,我一般用三条判断线来快速定位。
第一条线:这个请求是不是你自己发起的?如果页面里所有目标请求都来自你自己的前端代码,你能控制请求的发起方式,那么最省事的其实是运行时劫持——在页面里包一层fetch,把响应读一份再返回,改动范围最小,也不涉及主进程的任何协议层操作。反过来,如果请求来自第三方 SDK、iframe、或者是页面加载过程中自动发起的资源请求,运行时劫持就很难覆盖全,这时候必须走协议接管或者调试协议。
第二条线:你需要改写吗?只要答案是“需要”,调试协议这条线直接划掉。剩下两条路里,协议接管更彻底(能改到字节级),运行时劫持更轻量(只能改到页面可见的那一层)。
第三条线:响应的体积有多大?如果目标响应动辄几十兆,比如视频分片、大文件下载,那调试协议这条线基本不能用,因为Network.getResponseBody是把整个 body 序列化后传回来的,缓冲上限一到就直接报错。协议接管配合流式转发才是正解。
2.2 一个容易被忽略的前提:session 的隔离
选型之前还有件事必须先确认——你的窗口用的是哪个 session。Electron 里BrowserWindow默认走session.defaultSession,但如果创建时指定了partition,比如partition: 'persist:work',那它就是一个独立的 session,所有网络相关的钩子都必须挂在这个 session 上,挂到默认 session 上完全无效。
我在一个多窗口项目里被这个坑掉过半天:主窗口拦截得好好的,弹出的小窗口一条都抓不到,最后发现小窗口创建时带了partition。排查方法很简单,在窗口创建后打印一下win.webContents.session === session.defaultSession,如果返回false,那你就得从win.webContents.session上取 session 对象再挂钩子。
提醒一句:
protocol.handle也分全局版本(protocol.handle)和 session 版本(session.protocol.handle)。前者等价于对默认 session 操作,后者可以精确指定。多 session 项目里建议统一用 session 版本,避免隐式行为。
3. 路线一:protocol.handle 接管请求并回填响应体
这是我目前在需要“读 + 改”双能力时首选的路子。核心思路一句话概括:把目标请求的协议接管过来,自己用net.fetch去真正发起请求,拿到 Response 之后,一边读流做分析,一边把流原样(或改造后)返回给页面。
相比老版本的registerBufferProtocol/interceptBufferProtocol那一套,protocol.handle的接口设计干净太多——它直接接收标准的Request对象,返回标准的Response对象,跟 Web 平台的 Fetch API 完全对齐,心智负担小。这套 API 从 Electron 25 开始提供,如果你的项目还停在更早的版本,那要么升级,要么看本文第 4 节的调试协议方案。
3.1 先把自定义 scheme 注册好,顺序错了全白搭
protocol.handle有一个硬性前置条件:如果目标是自定义 scheme(比如你要把https://api.target.tld/*映射到app-api://...),必须在app.ready之前调用protocol.registerSchemesAsPrivileged完成权限声明。这个顺序错了,后面所有操作都会静默失效,而且不报错,非常坑。
const { app, protocol } = require('electron') protocol.registerSchemesAsPrivileged([ { scheme: 'appnet', privileges: { standard: true, // 按标准 URL 解析,有 host/path 之分 secure: true, // 视为安全上下文,避免混合内容拦截 supportFetchAPI: true, corsEnabled: true, stream: true, // 允许返回流式响应,大文件必备 }, }, ]) app.whenReady().then(() => { // 这里才开始注册 handle })几个参数为什么这么配,值得说清楚。standard: true决定了你的 URL 会被按scheme://host/path的结构解析,如果不开,new URL()拿到的 host 会是空字符串,路径处理会很别扭。secure: true让页面把这个 scheme 当成 HTTPS 对待,否则在 HTTPS 页面里请求它会被当成混合内容拦掉。stream: true是很多人漏掉的——不开这个标志,返回流式 Response 时会退化成全量缓冲,大文件场景直接内存爆掉。
关于直接对http/https调handle:官方示例里有对标准协议接管的写法,技术上可行。但我的建议是生产环境慎用,原因是它会绕过一部分 Chromium 内置的行为(比如 Service Worker 的介入时机、部分缓存策略),而且不同大版本之间行为有差异。更稳的做法是走自定义 scheme 转发,页面里把目标地址替换成你的 scheme,可控性强得多。
3.2 用 net.fetch 拿到原始响应,再把流原样吐回去
接管之后的核心逻辑分三步:解析出真实目标地址、用net.fetch发出去、把结果构造成 Response 返回。这里有个细节必须注意——net.fetch在主进程里走的是 Chromium 的网络栈,跟渲染进程共享同一套 Cookie 和代理配置,这是它比 Node 原生http模块更合适的原因。你用 Node 的http去发,Cookie 得自己拼,代理得自己配,证书校验还得单独处理,麻烦一大堆。
const { session, net } = require('electron') function setupIntercept(ses) { ses.protocol.handle('appnet', async (request) => { const url = new URL(request.url) const realUrl = 'https://' + url.host + url.pathname + url.search // 把原请求的 method / headers / body 原样带过去 const forwardHeaders = new Headers(request.headers) forwardHeaders.delete('host') const original = await net.fetch(realUrl, { method: request.method, headers: forwardHeaders, body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body, redirect: 'manual', }) // 这里做你的分析逻辑,注意不能直接读 body,会消耗掉流 // 正确做法是 tee 一份出来 const [forPage, forAnalysis] = original.body.tee() analyzeStream(forAnalysis).catch((e) => { console.error('[intercept] 分析流失败', e) }) return new Response(forPage, { status: original.status, statusText: original.statusText, headers: sanitizeHeaders(original.headers), }) }) }这段代码里有两个关键点,是我踩坑之后才加上的。
第一个是redirect: 'manual'。默认情况下net.fetch会自动跟随重定向,拿到的是最终响应。但页面那边期望的可能是重定向本身——如果你把最终响应直接返回,页面的fetch就看不到 302 了,某些依赖重定向做鉴权跳转的流程会直接断掉。设成manual之后,重定向响应会原样返回,页面自己会按正常逻辑处理。当然如果你确实想跟随,那就用默认值,但要记得把Location头剔掉,不然页面会基于错误的地址做跳转。
第二个是body.tee()。Response 的 body 是只能读一次的ReadableStream,你读了页面就读不到了。tee()会把流分成两支,一支给页面,一支给自己分析,两边互不干扰。很多人第一次写的时候直接await response.clone().text(),小响应没问题,大响应就是双倍内存,而且clone()在某些流式场景下会直接抛错。
3.3 大文件与流式响应的正确姿势
上面analyzeStream的实现决定了方案在大文件场景下能不能活下来。我见过最常见的错误写法是await stream.arrayBuffer()一把梭,请求一个 200MB 的视频分片,主进程内存直接飙到 400MB 以上,多来几次就 OOM 了。
正确做法是边读边处理,只保留你真正需要的那部分数据。比如你只想校验响应是不是合法的 JSON、或者提取其中某个字段,那就没必要把整个 body 存下来:
async function analyzeStream(stream, maxKeepBytes = 2 * 1024 * 1024) { const reader = stream.getReader() const chunks = [] let total = 0 while (true) { const { done, value } = await reader.read() if (done) break total += value.byteLength if (total <= maxKeepBytes) { chunks.push(value) } // 超出阈值后继续读取但不再累积,保证流被完整消费完 } const buf = Buffer.concat(chunks.map((c) => Buffer.from(c))) return { total, sample: buf } }这里有个不太直观的点:即使你不需要后面的数据,也必须把流读完。原因是tee()出来的两支流,如果其中一支被取消或者提前停止消费,另一支的读取速度会被反压拖慢,严重的时候页面的请求会卡住甚至超时。所以循环要么读到底,要么显式调用reader.cancel()并且确认不会影响另一支。我给的做法是读到底但不累积,用内存换稳定性,这个取舍在小文件占多数的场景里完全划算。
还有一类特殊的响应要注意:流式接口(Server-Sent Events、长连接分块推送)。这种响应永远不会done,你的分析循环会一直挂着。处理办法是加一个空闲超时,比如 30 秒没收到新数据就主动cancel(),同时把已经收到的部分落盘。这个超时值别设太小,正常的长连接接口中间隔几十秒才推一条消息是很常见的。
3.4 一份可以跑起来的最小骨架
把上面几块拼起来,配合窗口创建和页面侧地址替换,就是一套完整的可运行结构。页面侧如果你不想改业务代码,可以在 preload 里做一层fetch地址重写,把匹配到的目标域改写成appnet://开头,这样业务代码一行不动。
// preload.js 片段:把目标域请求改道到自定义 scheme const TARGET_HOSTS = new Set(['api.target-a.tld', 'api.target-b.tld']) const originalFetch = window.fetch window.fetch = function (input, init) { try { const url = typeof input === 'string' ? input : input.url const parsed = new URL(url, location.href) if (TARGET_HOSTS.has(parsed.host)) { const rewritten = `appnet://${parsed.host}${parsed.pathname}${parsed.search}` return originalFetch.call(window, rewritten, init) } } catch (e) { // 解析失败就走原路径,不要吞掉业务请求 } return originalFetch.call(window, input, init) }注意这里的边界:这个 preload 片段必须跑在页面主世界里才有效。contextIsolation打开时(默认就是打开的),preload 运行在隔离世界,改window.fetch只改了隔离世界的,页面自己的fetch不受影响。这是第 5 节要展开讲的坑,这里先记住结论。
另外,sanitizeHeaders这一步不能省。net.fetch返回的 headers 里可能带着content-length、content-encoding、transfer-encoding这类跟具体传输过程绑定的字段。如果你对 body 做了任何改动(哪怕是解压后再传),这些头就会和实际内容不匹配,页面的解析器会直接报错或者卡死。我的做法是只保留白名单里的头:content-type、cache-control、etag、last-modified、以及业务自定义的x-系列,其余全部丢掉。
4. 路线二:debugger 挂 CDP,从 Network 域里掏 body
如果目标请求你完全控制不了——来自第三方页面、来自 iframe、来自别人写的 SDK——那协议接管就不好使了,因为你没法让页面把地址改成你的 scheme。这时候最干净的办法是挂调试协议旁路抓取:不影响请求链路,纯观察者视角。
Electron 的webContents.debugger提供了对 Chrome DevTools Protocol 的直通能力,我们可以订阅Network域的事件,在响应加载完成后主动索取 body。这条路子的好处是覆盖面极广,任何走网络栈的请求都能看到;坏处是有几个硬限制,后面会一一说明。
4.1 attach 的时机比命令本身更重要
很多人写的第一版是这样的:窗口加载完之后在某个按钮的回调里debugger.attach(),然后发现前面几十个请求全都抓不到。原因很直接——attach之前发生的网络事件,CDP 是不会补发给你的。
正确的时机是在webContents创建的那一刻就挂上。Electron 提供了app.on('web-contents-created')这个全局钩子,任何 webContents 创建时都会触发,在这里挂最保险:
const { app } = require('electron') app.on('web-contents-created', (event, contents) => { if (contents.getType() !== 'window') return // 只处理普通窗口 try { contents.debugger.attach('1.3') } catch (err) { console.warn('[cdp] attach 失败', err.message) return } contents.debugger.sendCommand('Network.enable', { maxTotalBufferSize: 200 * 1024 * 1024, maxResourceBufferSize: 20 * 1024 * 1024, }).catch((e) => console.error('[cdp] Network.enable 失败', e)) })Network.enable的那两个参数很关键,必须显式传。默认的缓冲上限很小(几兆量级),一旦某个响应超过上限,getResponseBody就会直接报错,而且错误信息很不友好。maxTotalBufferSize是整条连接累计的缓冲池,maxResourceBufferSize是单个资源的。我一般按项目里最大响应体的一到两倍来设,设太大会有内存风险,因为这部分缓冲是在渲染进程侧分配的。
版本号'1.3'这个参数容易被忽略。它指的是 CDP 协议版本,写过'1.3'是目前最通用的写法,对应 Chromium 的稳定协议子集。如果你的 Electron 版本很新,理论上可以传更高版本,但没必要——'1.3'覆盖了 Network 域的全部常用命令。
4.2 配对 requestId,在 loadingFinished 后再取 body
CDP 的事件是分散的,一个请求从requestWillBeSent到responseReceived再到loadingFinished,中间可能隔着好几秒,你必须用一个 Map 按requestId把它们串起来。而且getResponseBody有一个硬性时序要求:只能在loadingFinished之后、资源被释放之前调用。在responseReceived里调会直接报 "No resource with given identifier"。
const pending = new Map() contents.debugger.on('message', async (event, method, params) => { if (method === 'Network.responseReceived') { pending.set(params.requestId, { url: params.response.url, status: params.response.status, mimeType: params.response.mimeType, at: Date.now(), }) return } if (method === 'Network.loadingFailed') { pending.delete(params.requestId) return } if (method === 'Network.loadingFinished') { const meta = pending.get(params.requestId) pending.delete(params.requestId) if (!meta) return // 只对我们关心的类型取 body,避免无意义的开销 if (!/json|text|javascript|xml/.test(meta.mimeType)) return try { const { body, base64Encoded } = await contents.debugger.sendCommand( 'Network.getResponseBody', { requestId: params.requestId } ) const text = base64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body handleResponse({ ...meta, text }) } catch (e) { // 缓存命中、重定向、preflight 等情况会走到这里,属正常现象 } } })这段代码里有三处是必须加的防御性判断,不加就会出问题。
第一处是mimeType 过滤。不加过滤意味着图片、字体、wasm 二进制全都会尝试取 body,白白吃掉缓冲和序列化开销。更重要的是,对二进制资源调用getResponseBody容易触发缓冲上限,把整个连接的缓冲池挤爆,导致后面真正需要的请求反而取不到。
第二处是base64Encoded分支。CDP 对非文本内容会自动用 base64 编码返回,并且把base64Encoded置为true。如果你不管这个标志直接当字符串用,拿到的会是一堆乱码。
第三处是catch 里的静默处理。有几种情况是必然取不到 body 的:304 缓存命中(根本没有 body 传输)、重定向中间响应、CORS 预检请求。这些不是错误,是正常现象,你要是把它们当异常打日志,日志会淹没真正的错误。
4.3 base64、超时与取不到 body 的几种情况
把getResponseBody失败的原因归一下类,能省掉大量排查时间。
| 现象 | 典型原因 | 处理办法 |
|---|---|---|
| No resource with given identifier | 调用时机早于 loadingFinished,或资源已被回收 | 严格在 loadingFinished 回调里取 |
| Response body is too large | 单个响应超过 maxResourceBufferSize | 调大缓冲上限,或改走协议接管 |
| 拿到的是乱码 | base64Encoded 为 true 但没解码 | 按标志位分支处理 |
| 偶尔丢失个别请求 | attach 时机太晚 | 在 web-contents-created 阶段挂载 |
| 整体 attach 失败 | DevTools 已占用同一个 webContents | 关闭 DevTools 或改用其他方案 |
| 空字符串 | 304 缓存命中、204 无内容、预检请求 | 属正常,直接跳过 |
还有一个隐蔽的坑:pending这个 Map 会泄漏。如果某个请求触发了responseReceived但后续既没有loadingFinished也没有loadingFailed(比如页面在这个过程中被销毁了),条目就会永远留在 Map 里。长跑的应用里这个泄漏会慢慢累积。我的做法是加一个定时清理,超过 60 秒还没完成的条目直接删掉,顺便打个警告日志。
5. 路线三:preload 注入,在页面主世界里劫 fetch 和 XHR
第三条路是站在页面自己的角度做手脚:既然页面的请求最终都要经过window.fetch和XMLHttpRequest,那我在它们身上裹一层,请求前记一笔、响应后读一笔,不就完了。这个思路最直观,代码量也最小,但有一个前提必须搞清楚,否则你会写出一段“看起来完全正确但一点效果都没有”的代码。
5.1 contextIsolation 下 preload 改 window 为什么没用
Electron 从 12 开始默认开启contextIsolation,preload 脚本运行在一个跟页面主世界隔离的 JavaScript 上下文里。两个世界共享同一个 DOM,但window对象是两份。你在 preload 里执行window.fetch = myHook,改的是隔离世界的那份fetch,页面主世界里的fetch纹丝不动,页面代码调用的还是原生那个。
这个机制很多人第一次遇到会懵,因为控制台里打印window.fetch确实显示是被替换过的——那是因为你在 preload 的上下文里打印的。切到页面的 console context 再看,就是原生的了。
解决办法是用webFrame.executeJavaScript把 hook 代码注入到主世界执行:
// preload.js const { webFrame, ipcRenderer } = require('electron') const HOOK_SOURCE = ` (() => { if (window.__netHookInstalled) return window.__netHookInstalled = true const report = (payload) => { window.postMessage({ __channel: 'net-hook', payload }, '*') } const origFetch = window.fetch window.fetch = async function (...args) { const res = await origFetch.apply(this, args) try { const clone = res.clone() const ct = clone.headers.get('content-type') || '' if (/json|text/.test(ct)) { const text = await clone.text() report({ kind: 'fetch', url: res.url, status: res.status, text }) } } catch (e) {} return res } const origOpen = XMLHttpRequest.prototype.open XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__meta = { method, url } return origOpen.call(this, method, url, ...rest) } const origSend = XMLHttpRequest.prototype.send XMLHttpRequest.prototype.send = function (...args) { this.addEventListener('loadend', () => { try { const type = this.responseType let text = null if (type === '' || type === 'text') text = this.responseText else if (type === 'json') text = JSON.stringify(this.response) if (text != null) { report({ kind: 'xhr', url: this.responseURL, status: this.status, text }) } } catch (e) {} }) return origSend.apply(this, args) } })() ` webFrame.executeJavaScript(HOOK_SOURCE).catch((e) => { console.error('[hook] 注入失败', e) }) window.addEventListener('message', (event) => { if (event.data && event.data.__channel === 'net-hook') { ipcRenderer.send('net-hook:report', event.data.payload) } })这段代码里有几个设计选择值得解释。
为什么用postMessage中转而不是直接在注入脚本里调ipcRenderer?因为注入脚本在主世界执行,那里没有require,也拿不到ipcRenderer。即便你通过contextBridge暴露了一个函数,主世界能拿到的也只是一个受限的代理,传复杂对象还会有序列化限制。postMessage是两个世界都能用的原生通道,最稳。
XHR 里为什么区分responseType?responseText在responseType为arraybuffer或blob时访问会直接抛InvalidStateError。不加判断的话,页面里所有下载文件的请求都会触发异常,虽然被你 catch 住了不会崩,但每次都要走一遍异常路径,性能上不划算。
fetch里为什么用res.clone()?同一个原因,body 流只能读一次。clone()会创建一份独立的流,你读克隆的,页面读原生的,互不影响。但要注意clone()对大响应意味着双份内存,所以我在前面加了content-type判断,只克隆文本类响应。
5.2 这套方案的覆盖盲区
运行时劫持看起来很美,但它有几个天然的盲区,必须提前知道。
盲区一:Worker 和 Service Worker。页面里的 Web Worker 有自己独立的全局环境,你在主窗口注入的 hook 完全管不到。Service Worker 更麻烦,它在独立线程里运行,拦截的是fetch事件而不是window.fetch。如果你的目标请求被 Service Worker 接管了,主世界的 hook 根本看不到。
盲区二:iframe。同源 iframe 的话,通过webFrame的getAllWebFrames可以拿到子 frame 并注入,但跨域 iframe 就没办法了,只能靠主进程侧的方案兜底。
盲区三:原生发起的请求。比如<img>、<script>、<link>、表单提交、navigator.sendBeacon,这些根本不走fetch和XHR,hook 完全覆盖不到。要抓这类请求,还是得回到主进程侧的webRequest。
盲区四:注入时机。如果页面的某些脚本在 preload 之前就执行了(runAt设成document_start之前,或者页面用了非常激进的内联脚本),会漏掉最前面的几个请求。这个一般靠runAt: 'document_start'缓解,但也没法做到 100%。
综合下来,我的判断是:运行时劫持适合“抓自己代码发的接口请求”,做接口调试面板、做数据采集都很舒服;但要做全量审计,必须配合主进程侧方案使用。我在实际项目里的做法是两个都用——主进程侧用webRequest拿全量元信息,preload 侧只负责需要读 body 的那部分核心接口。
5.3 上报通道的背压控制
还有个工程上的细节,做不好会把主进程拖垮。页面可能在一秒钟里发几十个请求,每个请求的响应体都往主进程发一份,IPC 通道会瞬间被灌满。轻则上报延迟,重则主进程事件循环被通信开销占满,界面卡顿。
我的处理办法是三层限流:单条消息体积上限(比如超过 512KB 的响应直接截断只留头部)、时间窗口内的条数上限(比如每秒最多 20 条,超出的丢弃并计数)、发送侧的批量合并(攒 100ms 或者攒够 50 条发一次)。
// 主进程侧 let buffer = [] let timer = null ipcMain.on('net-hook:report', (event, payload) => { if (payload.text && payload.text.length > 512 * 1024) { payload.text = payload.text.slice(0, 512 * 1024) + '...[truncated]' } buffer.push(payload) if (!timer) { timer = setTimeout(flush, 100) } }) function flush() { const batch = buffer buffer = [] timer = null if (batch.length) { // 批量落盘或推给 UI } }6. 路线四:webRequest 组合拳,只做元信息与改写
前面说了webRequest拿不到 body,但这不代表它没用。如果你的需求停留在“看请求、改请求头、改响应头”这个层面,它反而是最轻、最稳的方案,而且对性能的影响最小。我在好几个项目里只用这一套就解决了问题,没必要上更重的方案。
6.1 onHeadersReceived 才是改响应的主战场
webRequest一共七个钩子,真正跟响应相关的是onHeadersReceived、onResponseStarted、onCompleted三个。其中只有onHeadersReceived允许你修改东西——通过调用callback({ responseHeaders })替换响应头。
const { session } = require('electron') const ses = session.defaultSession ses.webRequest.onHeadersReceived( { urls: ['*://*/api/*'] }, (details, callback) => { const headers = details.responseHeaders || {} // 给所有 API 响应补一个允许本地调试的 CORS 头 headers['access-control-allow-origin'] = ['*'] // 去掉可能引起解析歧义的头 delete headers['content-security-policy-report-only'] callback({ responseHeaders: headers }) } )这里有个格式上的硬要求:响应头是一个Record<string, string[]>,值必须是数组。很多人写成字符串,Electron 不会报错,但头会被静默丢弃。我第一次踩这个坑的时候排查了很久,因为代码上看不出任何问题。
还有一个行为差异要注意:如果你在回调里传了responseHeaders,那你提供的就是完整替换,不是合并。漏掉的头会消失。稳妥的做法是先在原有对象上增删,再整体传回去,就像上面代码那样。
6.2 onBeforeRequest 与 onCompleted 的配对
想做“请求耗时统计”这类功能,典型玩法是在onBeforeRequest里按id记下时间戳,在onCompleted里算差值。details.id是这个配对的关键,同一个请求在各个钩子里 id 是一致的。
const startAt = new Map() ses.webRequest.onBeforeRequest((details, callback) => { if (details.resourceType === 'mainFrame') return callback({}) startAt.set(details.id, { url: details.url, t: Date.now() }) callback({}) }) ses.webRequest.onCompleted((details) => { const meta = startAt.get(details.id) if (!meta) return startAt.delete(details.id) const cost = Date.now() - meta.t if (cost > 2000) { console.warn(`[slow] ${meta.url} ${details.statusCode} ${cost}ms`) } })details.id会复用,你必须在onCompleted里及时删掉,否则 Map 会持续膨胀。onErrorOccurred也要监听并清理,请求失败的时候是不会触发onCompleted的,这个很容易漏。
resourceType过滤也很重要,mainFrame、subFrame、image、xhr、fetch、script、stylesheet、font等等,不同版本会有细微差异。我一般先不做过滤,在回调里打一行日志把实际值观察一遍,再决定保留哪些。
6.3 什么时候这条路就够了
总结一下,以下场景用webRequest完全够用,不用折腾更重的方案:
- 统计请求数量、成功率、耗时分布
- 监控失败请求并做重试兜底(
onErrorOccurred里判断) - 给特定域名的请求统一加鉴权头(
onBeforeSendHeaders) - 把某些请求直接拦截掉(
onBeforeRequest里callback({ cancel: true })) - 把请求重定向到本地地址(
onBeforeRequest里返回redirectURL) - 修改响应头解决 CORS 或 CSP 问题
反过来,只要需求里出现“我要看看返回了什么”,这条路就地终止,老老实实回到第 3 或第 4 节。
7. 踩坑实录与排查速查表
前面几节讲的是“怎么做”,这一节讲“为什么做不成”。我把这几年遇到的典型问题按类型归了一下,都是真金白银换来的,希望你看完能少调几个夜。
7.1 时序类:请求比监听跑得快
这是最高频的一类问题。表现是:代码明明写了,但某些请求就是抓不到,而且抓不到的都是页面加载初期的那几个。
根因基本都一样——监听的注册时机晚于请求的发生时机。BrowserWindow创建之后,loadURL一调用,页面立刻开始发请求。如果你在did-finish-load之后才去挂钩子,前面几十个请求早就飞完了。
正确的挂载时机分场景:
| 方案 | 推荐挂载位置 | 说明 |
|---|---|---|
| webRequest | app.whenReady()之后、创建窗口之前 | 挂到 session 上,越早越好 |
| protocol.handle | app.whenReady()之后、创建窗口之前 | 同上,且要在 loadURL 前 |
| debugger | app.on('web-contents-created') | 拿到 webContents 立刻 attach |
| preload 注入 | preload 脚本顶层,runAt: 'document_start' | 确保早于页面脚本 |
还有一个更隐蔽的时序问题:protocol.registerSchemesAsPrivileged必须在app.ready之前调用。如果你把它写在了whenReady().then()里面,它会被静默忽略,不报任何错,后面handle注册上去也不生效。这个坑我见过至少三个人踩。
7.2 数据类:拿到的 body 是空的、是乱码、是被截断的
按现象查原因,这张表基本能覆盖九成情况:
| 现象 | 排查方向 | 定位方法 |
|---|---|---|
| body 为空字符串 | 304 / 204 / 预检请求 | 看状态码,304 本来就没 body |
| body 是乱码 | base64 未解码 / 字符集不是 UTF-8 | 检查 base64Encoded 标志和 content-type 里的 charset |
| body 被截断 | 缓冲上限被触发 / 主动截断逻辑 | 看日志里的 total 和实际长度对比 |
| 只在某个窗口拿不到 | session 隔离 | 对比 webContents.session 是否等于 defaultSession |
| 只有部分请求拿不到 | 过滤条件写错 | 把过滤条件临时去掉,看是否恢复 |
| 拿到的内容是压缩的 | 手动请求未解压 | 检查 content-encoding 头,手动做 gzip 解压 |
关于压缩那条,补充一个细节:走net.fetch的时候,Chromium 会自动带Accept-Encoding并自动解压,你拿到的 body 已经是解压后的。但如果你为了某些原因绕开net.fetch用 Node 的http或https模块发请求,就要自己处理gzip/br解压,否则拿到的是一堆二进制乱码。我个人的建议是能用net.fetch就别用 Node 原生模块,省掉的不只是解压,还有 Cookie 管理、代理配置、证书链校验这一整套。
7.3 冲突类:debugger 与 DevTools、多协议注册打架
webContents.debugger和 DevTools 共用同一个调试通道,同一个 webContents 上两者只能存在一个。如果你在开发时习惯开着 DevTools,那debugger.attach()会直接抛Another debugger is already attached。这个不是 bug,是设计如此。
应对办法有两个:一是生产构建里关掉 DevTools,只在开发时用协议接管方案调试;二是用contents.debugger.isAttached()先判断,attach 失败时降级到其他方案,不要让整个功能挂掉。
另一个冲突是同一个 scheme 被重复注册。protocol.handle对同一个 scheme 再注册一次会抛错。在多窗口、多 session 的项目里,很容易出现某个 session 已经注册过、你又去注册一次的情况。稳妥的写法是先protocol.isProtocolHandled(scheme)判断一下,或者用一个 Set 自己记录已经处理过的 session。
还有个小提醒:
protocol.unhandle是可以的,但卸载之后进行中的请求可能会挂起。切换方案的时候要注意把在途请求处理干净,别留下悬空的 Promise。
8. 几个我实际用下来觉得值得说的细节
写到这里,方案和坑都摊得差不多了,最后补充几个偏工程实践的点,都是文档里不会写、但真正会影响上线质量的东西。
8.1 内存与性能:别把整条链路做成全量缓冲
不管选哪条路,最容易被忽视的就是内存控制。这三个方案的“默认写法”全都是全量缓冲的,小流量下看不出问题,一旦遇上大文件或者高并发,内存曲线会非常难看。
我的经验值是:单个响应体超过 2MB 就要开始警惕,超过 10MB 就应该走流式处理。protocol.handle这条路天然支持流式,只要别手贱去await arrayBuffer()就行。CDP 那条路没法流式,所以要设一个明确的大小阈值,超过的直接跳过不取 body,只记录元信息。preload 那条路受页面自身读取方式限制,但至少可以只对文本类响应做克隆。
还有一个容易被忽略的点是事件监听的清理。窗口关闭的时候,debugger要detach,webRequest的监听要看情况移除,protocol的 handler 在 session 销毁时会自动清,但你自己维护的那些 Map、Set 得手动清。长跑的应用里,这些不清理的残留会一点点累积,最后表现为内存缓慢上涨,很难定位。
8.2 安全边界:只拦自己的域名
最后必须说一句安全上的事。请求拦截是个权力很大的能力,它能看到所有经过的响应内容,包括用户的身份凭证、隐私数据。所以过滤条件一定要写死,只拦你自己业务需要的域名,绝不要图省事写成*://*/*就上生产。
const ALLOW = [ '*://api.your-service.tld/*', '*://cdn.your-service.tld/*', ]webRequest的urls过滤器、protocol.handle里的 host 判断、CDP 里的 url 匹配、preload 里的 TARGET_HOSTS 集合,这四处都要对齐。少写一处,就等于给你的拦截器开了一个口子。
另外,抓下来的响应内容落地时要注意脱敏——authorization、cookie、set-cookie这些头,以及响应体里的 token、手机号、身份证号这类字段,能不打日志就别打。做审计功能的时候这点尤其重要,很多时候问题不是出在拦截本身,而是出在拦截下来的数据没管好。
实际用下来,我的搭建顺序是:先用webRequest把能做的事做掉,剩下的必须读 body 的部分再按“自己的请求用 preload 劫持、第三方请求用协议接管或调试协议”的原则分流。四条路混着用不丢人,能稳定把需求交付才是正经事。真正需要全量读 body 的场景其实很少,大部分“我要拦截 response”的需求,聊到第三句就会发现只要一个状态码和耗时就够了。先想清楚这一步,比啃十篇 API 文档都管用。