🔥 如何“为所欲为”地渲染页面:优雅拦截 Fetch 和 XMLHttpRequest!

大家好,我是 前端架构师 - 大卫

更多优质内容请关注微信公众号 @程序员大卫

初心为助前端人🚀,进阶路上共星辰✨,

您的点赞👍与关注❤️,是我笔耕不辍的灯💡。

背景

当你想写一个油猴脚本(Tampermonkey)来注入网页,并拦截页面原有的 XMLHttpRequest 和 fetch 请求时,并返回自定义的 response 数据,实现对页面内容的"随心所欲"地改造和渲染。

这篇文章将一步步介绍如何实现这个过程。

实现步骤

1. 准备工作

1.首先,在 Chrome 商店安装 Tampermonkey 插件(谷歌商店需要科学上网):

chromewebstore.google.com/detail/tamp...

2.安装完成后,点击浏览器插件图标,选择"添加新脚本",就可以开始编写你的第一个脚本:

2. 编写代码

1.油猴脚本的前置

⚠️注意:@run-at 字段必须设置为 document-start,这样可以尽可能早地拦截 XMLHttpRequestfetch 请求,也能避免页面里的 console.log 被其他脚本覆盖。

perl 复制代码
// ==UserScript==
// @name         API - 拦截
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  根据不同请求 URL 自定义不同的响应数据
// @match        https://www.xxx.com/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

不过需要注意,document-start 实际的执行时机并不是页面加载最开始的时候,而是 DOM Ready 之后。这时因为油猴本质上是通过 Chrome 的内容脚本机制注入代码的,因此仍然受到一些限制。

可以通过以下示例来验证脚本的实际执行时机。

油猴脚本代码:

js 复制代码
// ==UserScript==
// @name         测试油猴脚本执行
// @namespace    http://tampermonkey.net/
// @version      2025-04-10
// @description  try to take over the world!
// @author       You
// @match        http://*/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function() {
    'use strict';
    console.log('油猴脚本执行');
})();

HTML 页面代码:

html 复制代码
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<link rel="icon" type="image/svg+xml" href="/vite.svg" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>测试油猴脚本执行时机</title>
	</head>
	<body>
		<script>
			console.log("Script excution started");
			document.addEventListener("DOMContentLoaded", function () {
				console.log("Dom ready");
			});
			window.addEventListener("load", () => {
				console.log("Page loaded");
			});
		</script>
	</body>
</html>

控制台输出结果:

arduino 复制代码
Script execution started
Dom ready
油猴脚本执行
Page loaded

可以看到,油猴脚本在 DOM 完成之后才运行的。

2.拦截 Fetch 请求

几点关键说明:

  • 原始请求建议仍然发出:这样可以在开发工具中看到真实的返回值,便于调试。
  • 返回的 mock 数据必须是 window.Response 的实例。
  • Response 的 body 是一次性读取的流,如果需要打印内容,请先使用 .clone(),否则会报错:TypeError: body stream already read.

拦截代码如下:

js 复制代码
  const tryJSONParse = (str) => {
    try {
      return JSON.parse(str);
    } catch (e) {
      return str;
    }
  };

  const log = console.log;

  function setupFetchInterceptor(interceptRules = []) {
    const originalFetch = window.fetch;

    window.fetch = function (input, init) {
      const url = typeof input === "string" ? input : input.url;
      const matchedRule = interceptRules.find((rule) => rule.match(url));
      const orgResponse = originalFetch(input, init);
      if (matchedRule) {
        const mockResponse = createMockResponse(matchedRule);
        // 需要 clone 一下然后再打印日志,因为 Response 的 body 是一个一次性可读流(ReadableStream),
        // 第二次尝试读取时会抛出错误 `TypeError: body stream already read。`
        mockResponse
          .clone()
          .text()
          .then((data) => {
            log(
              `🔥 拦截 Fetch 响应:
    🌐 InterceptedUrl: ${url}
    📦 FakeResponse:`,
              tryJSONParse(data),
            );
          });
        return mockResponse;
      } else {
        return orgResponse;
      }
    };

    function createMockResponse(rule) {
      const { body, ...init } = {
        status: 200,
        headers: { "content-type": "application/json" },
        ...rule.response,
      };

      let responseBody = body;
      const contentType = init.headers?.["content-type"];

      if (
        typeof responseBody === "object" &&
        contentType?.includes("application/json")
      ) {
        responseBody = JSON.stringify(responseBody);
      }

      return new window.Response(responseBody, init);
    }
  }

  setupFetchInterceptor(interceptRules);

3. 拦截 XMLHttpRequest 请求

需要注意以下几点:

  1. Axios 内部默认也是通过 XMLHttpRequest 发送请求。

2.不能单纯使用 Reflect.set 来代理 Web API 提供的原生宿主对象(如 XMLHttpRequest

因为这些对象的属性 setter 往往依赖内部上下文绑定(this)。使用 Reflect.set 时,如果上下文绑定不正确,会抛出如 TypeError: Illegal invocation 这样的错误。

以下是一个常见的错误示例:

js 复制代码
const xhr = new window.XMLHttpRequest();
const proxy = new Proxy(xhr, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      return typeof value === "function" ? value.bind(target) : value;
    },
    set(target, prop, value, receiver) {
      return Reflect.set(target, prop, value, receiver);
    },
});
proxy.onreadystatechange = ()=> {};

3.另外为了和原型保持一致,需要加 CustomXHR.prototype = OriginalXHR.prototype

最终 XMLHttpRequest 拦截代码如下:

js 复制代码
  function setupXHRInterceptor(interceptRules = []) {
    const OriginalXHR = window.XMLHttpRequest;

    function CustomXHR() {
      const xhr = new OriginalXHR();

      let interceptedUrl = null;
      let matchedRule = null;

      // 自定义数据注入(供 Proxy 读取)
      let mockData = {
        readyState: undefined,
        status: undefined,
        responseText: undefined,
      };

      // open 拦截,记录请求 URL 和匹配规则
      const originalOpen = xhr.open;
      xhr.open = function (method, url, ...args) {
        interceptedUrl = url;
        matchedRule = interceptRules.find((rule) => rule.match(url));
        return originalOpen.call(this, method, url, ...args);
      };

      // send 拦截:匹配则生成伪造响应
      const originalSend = xhr.send;
      xhr.send = function (...args) {
        if (matchedRule) {
          const { body, status = 200 } = matchedRule.response;

          const fakeResponseText =
            typeof body === "object"
              ? JSON.stringify(body)
              : String(body ?? "");

          // 设置 mock 数据
          mockData = {
            readyState: 4,
            status,
            responseText: fakeResponseText,
          };

          // 打印日志(保留原有)
          log(
            `🔥 拦截 XHR 响应:
🌐 InterceptedUrl: ${interceptedUrl}
📦 FakeResponse:`,
            tryJSONParse(fakeResponseText),
          );
        }

        return originalSend.apply(this, args);
      };

      // Proxy 包装,拦截只读属性的读取
      return new Proxy(xhr, {
        get(target, prop) {
          if (prop === "readyState" && mockData.readyState) {
            return mockData.readyState;
          }
          if (prop === "status" && mockData.status) {
            return mockData.status;
          }
          if (
            (prop === "responseText" || prop === "response") &&
            mockData.responseText
          ) {
            return mockData.responseText;
          }

          const value = target[prop];
          return typeof value === "function" ? value.bind(target) : value;
        },
        set(target, prop, value) {
          target[prop] = value;
          return true;
        },
      });
    }

    // 原型保持一致
    CustomXHR.prototype = OriginalXHR.prototype;

    // 替换全局 XMLHttpRequest
    window.XMLHttpRequest = CustomXHR;
  }

  setupXHRInterceptor(interceptRules);

4. 编写 interceptRules

interceptRules 用于定义需要拦截的 API 以及其返回的数据格式:

js 复制代码
const interceptRules = [{
  match: url => url.includes('/api/xxx'),
  response: {
    body: {
      userId: 1111
    }
  }
}]

实战

假设我们要修改掘金首页的"作者版"版块,如下图所示:

首先打开 Chrome 开发者工具,可以看到该版块的请求地址为:

bash 复制代码
https://api.juejin.cn/user_api/v1/quality_user/rank

接口返回数据如下图:

并且这个请求是通过 fetch 发起的:

接下来我们编写完整的 fetch 拦截代码:

js 复制代码
// ==UserScript==
// @name         掘金首页拦截接口
// @namespace    http://tampermonkey.net/
// @version      2025-04-10
// @description  try to take over the world!
// @author       You
// @match        https://juejin.cn/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=juejin.cn
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
	"use strict";

	const interceptRules = [
		{
			match: (url) =>
				url.includes("https://api.juejin.cn/user_api/v1/quality_user/rank"),
			response: {
				body: {
					err_no: 0,
					err_msg: "success",
					data: {
						user_rank_list: [
							{
								user_info: {
									user_id: "4416102247436824",
									user_name: "🔥马云🔥",
								},
							},
						],
					},
				},
			},
		},
	];

	const tryJSONParse = (str) => {
		try {
			return JSON.parse(str);
		} catch (e) {
			return str;
		}
	};

	const log = console.log;

	function setupFetchInterceptor(interceptRules = []) {
		const originalFetch = window.fetch;

		window.fetch = function (input, init) {
			const url = typeof input === "string" ? input : input.url;
			const matchedRule = interceptRules.find((rule) => rule.match(url));
			const orgResponse = originalFetch(input, init);
			if (matchedRule) {
				const mockResponse = createMockResponse(matchedRule);
				// 需要 clone 一下然后再打印日志,因为 Response 的 body 是一个一次性可读流(ReadableStream),
				// 第二次尝试读取时会抛出错误 `TypeError: body stream already read。`
				mockResponse
					.clone()
					.text()
					.then((data) => {
						log(
							`🔥 拦截 Fetch 响应:
        🌐 InterceptedUrl: ${url}
        📦 FakeResponse:`,
							tryJSONParse(data)
						);
					});
				return mockResponse;
			} else {
				return orgResponse;
			}
		};

		function createMockResponse(rule) {
			const { body, ...init } = {
				status: 200,
				headers: { "content-type": "application/json" },
				...rule.response,
			};

			let responseBody = body;
			const contentType = init.headers?.["content-type"];

			if (
				typeof responseBody === "object" &&
				contentType?.includes("application/json")
			) {
				responseBody = JSON.stringify(responseBody);
			}

			return new window.Response(responseBody, init);
		}
	}

	setupFetchInterceptor(interceptRules);
})();

思考

有人可能会问:为什么不直接使用 Charles 或 Proxyman 这类抓包工具?

首先,这些工具需要你每次都手动启动,而且如果你电脑中已经在使用如 ClashX 这类网络代理工具,当你同时打开抓包工具时可能会发生冲突,导致代理不可用。

相比之下,油猴脚本更轻便。虽然它在执行时机上有一定限制(不能在最开始执行),并且脚本的启用/禁用并不是实时生效,需要刷新页面才能应用,但整体来说仍然是一个非常灵活的解决方案。

总结

本文介绍了如何用油猴脚本拦截网页中的 fetchXMLHttpRequest 请求,并返回自定义数据。相比抓包工具,这种方式更轻量、灵活,适合快速调试和页面定制。不过也有局限,比如脚本执行时机受限,启用需刷新页面等。整体来说,是前端开发中非常实用的一种技巧。

相关推荐
好_快8 分钟前
Lodash源码阅读-take
前端·javascript·源码阅读
好_快9 分钟前
Lodash源码阅读-takeRight
前端·javascript·源码阅读
好_快10 分钟前
Lodash源码阅读-takeRightWhile
前端·javascript·源码阅读
烂蜻蜓11 分钟前
在 HTML5 中使用 MathML 展示数学公式
前端·html·html5
好_快14 分钟前
Lodash源码阅读-takeWhile
前端·javascript·源码阅读
风中飘爻1 小时前
JavaScript:BOM编程
开发语言·javascript·ecmascript
恋猫de小郭1 小时前
Android Studio Cloud 正式上线,不只是 Android,随时随地改 bug
android·前端·flutter
清岚_lxn6 小时前
原生SSE实现AI智能问答+Vue3前端打字机流效果
前端·javascript·人工智能·vue·ai问答
ZoeLandia6 小时前
Element UI 设置 el-table-column 宽度 width 为百分比无效
前端·ui·element-ui
橘子味的冰淇淋~7 小时前
解决 vite.config.ts 引入scss 预处理报错
前端·vue·scss