image.png

在JavaScript中有两种发起HTTP请求的API - 现代的fetch()和传统的XMLHttpRequest。它们功能完全相同,只是语法不同。XMLHttpRequest使用回调处理响应,而fetch()返回更方便使用的Promise。

XMLHttpRequest是发起HTTP请求的主流API。在新项目中使用传统的XMLHttpRequest是没有意义的。

另一方面,将现有可运行的基于XMLHttpRequest的代码升级到fetch()并不会带来显著好处。那些经过多年开发、拥有大量代码库的成功网站,没有理由在代码中用fetch()替换XMLHttpRequest。将他们可运行的代码升级到fetch()只会带来bug和风险。

我检查了我所知道的一些流行网站的网络活动。google、youtube、gmail、bing、linkedin、tiktok、instagram、facebook主要依赖XMLHttpRequest,也使用一些fetch()。reddit、quora则不使用XMLHttpRequest。

为什么要重写XMLHttpRequest中的response

首先,在前端开发和调试过程中,在网页接收到HTTP响应之前修改响应是一个有用的技术。通过重写XMLHttpRequest,可以在不改变后端的情况下记录、伪造或调整响应体。

这种技术也可用于网页爬虫,并且使一些浏览器扩展的功能得以实现。

但是如果网页需要多次重新加载,比如在开发或调试期间,最好不要在控制台执行修改响应数据的脚本,而是作为浏览器扩展的content script自动执行。当脚本作为content script注入时,可以方便地由小型可重用模块组成。

拦截HTTP响应数据的示例

如上所述,许多流行网站都使用XMLHttpRequest发起HTTP请求。在这个实验中我使用知名且信誉良好的Facebook。。Facebook的初始HTML是在服务器端渲染的,所以不能通过修改XMLHttpRequest响应来修改它。但是之后逐渐加载的内容可以在被网页访问之前进行修改:

  • words mushroom、fungus或fungi被替换为字符串 🍄🍄🍄REPLACED🍄🍄🍄
  • jpg图片的URL被替换为一个蘑菇图片的URL

HTTP响应中的文本修改是通过以下函数完成的:

var RE = /"[^"]+\.jpg?[^"]+"/gi;
var REPLACEMENT = '"https://scontent-zrh1-1.xx.fbcdn.net/v/t39.30808-6/272917062_10157959892971991_7437132751388296237_n.jpg?_nc_cat=100&ccb=1-7&_nc_sid=127cfc&_nc_ohc=g7Qun1RfEvgQ7kNvgFVEOv6&_nc_ht=scontent-zrh1-1.xx&_nc_gid=AmiHBSQhbkAppb0buDWHP2N&oh=00_AYAYpDPV90lNRXvX2-bftFkUPHcqQJYVBmsE8BZnyNvqmg&oe=66EB3BAE"'
    .replaceAll('/', '\\/');

var RE2 = /mushroom|fungus|fungi/gi;
var REPLACEMENT2 = '🍄🍄🍄REPLACED🍄🍄🍄';

function modifyTextResponse(val) {
    if (typeof val === 'string')
        return val.replaceAll(RE, REPLACEMENT).replaceAll(RE2, REPLACEMENT2);
    return val;
}

下面的示例脚本使用了这个modifyTextResponse(val)函数。

这个蘑菇图片很好看但URL很长且难看。Facebook页面的内容安全策略(CSP)阻止从其他来源加载图片。我本可以使用反CSP浏览器扩展来放宽CSP,但为了简单起见,我遵守了CSP并使用了Facebook托管的图片。

在视频中,脚本在页面加载时自动作为content script注入。

{
  "name": "XMLHttpRequest",
  "version": "1.0",
  "manifest_version": 3,
  "description": "XMLHttpRequest",
  "permissions": [
    "scripting"
  ],
  "action": {},
  "icons": {
    "128": "icon.png"
  },
  "content_scripts": [
    {
      "matches": [
        "https://*.facebook.com/*"
      ],
      "run_at": "document_start",
      "js": [
        "main.js"
      ],
      "world":"MAIN"
    }
  ]
}

脚本必须注入到页面上下文中,即MAIN world。重写原生JavaScript方法是MAIN world的主要用例,否则这个world没有扩展API,用处不大。

访问XMLHttpRequest响应数据的唯一方式

XMLHttpRequest中有几个提供访问响应数据的属性:

  • response
  • responseText
  • responseXML

这些属性都是getter函数。要重写任何类型的响应,只需要重写response getter就够了。responseText和responseXML似乎只是通过转换response的值来工作。

但是需要了解什么时候进行重写。有两个合理的选择:

  • readystatechange事件监听器
  • open()方法

XMLHttpRequest的所有可能事件

我们看看HTTP请求期间发生的所有事件。

<script src="api.js" type="module"></script>

<button type="button" id="btn">Send</button>

脚本为所有以on开头的XMLHttpRequest属性添加监听器:

// api.js

const url = "https://data.cdc.gov/api/views/95ax-ymtc/rows.json";

function onEvent(e) {
    console.log(e.type.padEnd(16, ' '), this.readyState, this.response.length, e.loaded);
}

function request() {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.send();

    for (let k in xhr)
        if (k.startsWith('on'))
            xhr[k] = onEvent;
}

btn.addEventListener("click", request);

这个脚本请求一些公开可用的数据。

你可以看到,readystatechange监听器可能被多次调用,甚至可以访问未完全加载的数据。某些网站可能不会等待readyState===4就立即使用不完整的数据。

下面的代码通过在XMLHttpRequest对象中创建新的response属性来重写prototype中的response getter:

if (!oldXHROpen)
    var oldXHROpen = XMLHttpRequest.prototype.open;

XMLHttpRequest.prototype.open = function () {
    let oldOnreadystatechange = this.onreadystatechange;

    this.onreadystatechange = function () {
        if (this.readyState === XMLHttpRequest.DONE) {
             const txt = this.responseText;

            if (txt) {
                Object.defineProperty(this, 'response', { writable: true });
                this.response = modifyTextResponse(txt);
            }
        }

        if (oldOnreadystatechange)
            return oldOnreadystatechange.apply(this, arguments);
    };

    return oldXHROpen.apply(this, arguments);
} 

这种方法适用于许多网站,但在Facebook上会产生异常效果 - 关键词没有被替换,而且页面很快就会崩溃。这可能是因为Facebook页面在数据完全加载之前就使用了数据。因此,创建一个从原生getter读取数据的getter而不是属性是至关重要的。新的getter应该在所有可能的事件回调中都能正常工作。定义getter的唯一可能位置是open()方法。

代理getter转换继承getter返回的值

这段不会出错的代码定义了一个response getter函数,它首先通过在this对象上调用prototype中的response getter获取真实的响应值,然后返回用当前响应值调用modifyTextResponse()产生的值:

function defineProxyGetter(obj, property, func) {
    Object.defineProperty(obj, property, {
        get() {
            const val = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, property).get.call(obj);
            return func(val);
        }
    });
}

if (!oldXHROpen)
    var oldXHROpen = XMLHttpRequest.prototype.open;

XMLHttpRequest.prototype.open = function () {
    defineProxyGetter(this, 'response', modifyTextResponse);

    return oldXHROpen.apply(this, arguments);
}

在这篇文章中我修改了文本数据,因为这种修改更常见且结果容易可视化,但同样的方法应该也适用于blob或任何类型的响应数据。当然modifyTextResponse()应该替换为合适的函数。

首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。


王大冶
68.1k 声望105k 粉丝