做个Chrome小扩展mock请求从此测试如飞

做网页应用时,我们离不开用浏览器测试,特别是Chrome和Firefox这样调试工具强大的浏览器。并且很多时候测试内容与后端接口相关。

聪明的前端儿为了进度不被后端小伙伴们不稳定的接口拖累,发明了mock。事到如今,我们有用本地应用mock的,如Mockon;有用自己Node服务器mock的;有用接口协作工具的,如YAPI;还有用Service Worker的,如msw……其实,还有一种方式——浏览器扩展。浏览器扩展可以让我们减少窗口切换浪费的时间,大大提升调试效率。

今天,让我们谈谈怎么用Chrome浏览器拦截网页中发出的请求,如果符合预设的条件,返回mock的数据。

听起来很简单,不过自Chrome发现这类API有一些安全隐患之后,便不再提供官方API支持(原先在webRequest中可更改相应的内容)。所以我们只能绕一些弯路:在页面中插入一段代码代理请求的JS API。

为什么要在页面中插入代码,扩展不能吗

答案是不能啊亲!让我们看看Chrome扩展的架构设计:

开发人员需要考虑5个部分:打开的页面(可以是任意标签页)、每个页面上运行的扩展的content_script、扩展的弹出窗口、以及一个后台脚本和扩展的设置界面。每个部分都独立运行在自己的黑箱里,互相之间通信只能依靠Chrome提供的一些接口。比如sendMessagestorage

如果我们想获取或操作用户页面上的内容,就得依靠content_script。它是扩展中唯一可以获取页面文档document的地方。然而这仅限于document,为了保护用户,content_script里的window对象与页面中的window对象并非同一个。也就是说他们运行的JS API是隔离开来的。因此,我们不得不“偷偷”在用户页面中插入一段代码协助操作页面中的接口。

拦截fetch

拦截fetch其实很简单,只需要将原API调个包:

// 存一下原来的fetch
const f = window.fetch
// 改写成自己的
window.fetch = (req, config?) => {
  // hjack方法检查是否有mock,有的话返回一个Response Object
  return hijack(req, config)
    // 没有的话抛出,扔回给原fetch处理
    .catch(() => f(req, config))
}
复制代码

拦截XHR

当应用有转成es5标准代码时,除了拦截fetch还需要考虑拦截XHR。与fetch不同,拦截XHR就比较麻烦了。

让我们回顾一下XHR的用法:

var get = new XMLHttpRequest();
get.open('GET', '/api', true);

get.onload = function () {
  // 请求结束
};
get.send(null);


var post = new XMLHttpRequest();
post.open("POST", '/api', true);
post.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
post.onreadystatechange = function() {
    if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
        // 请求结束
    }
}
post.send(JSON.stringify({}));
复制代码

XHR的请求设定、发送与返回结果处理都分布在不同的方法中,同fetch一样“偷天换日”就得每个都照顾到。

幸好找到有位小伙伴开源了一个包叫ajax-hook,把这些代码都给写好啦!使用ajax-hook,我们只需要这样就可以了:

import { proxy } from 'ajax-hook'
proxy({
  onRequest: (config, handler) =>
    hijack(config)
      .then(({ response }) => {
        // 找到mock,让handler帮忙把mock数据交到下一步处理
        return handler.resolve({
          config,
          status: 200,
          headers: [],
          response,
        })
      })
  		// 没找到mock就可以用handler.next让原XHR处理
      .catch(() => handler.next(config)),
  onResponse: (response, handler) => {
    // handler帮忙响应数据(无论是线上返回还是mock)
    handler.resolve(response)
  },
})
复制代码

配置Chrome扩展,插入这段拦截代码

插入这段代码我们需要至少三个文件:

intercept.js
content_script.js
manifest.json
复制代码

我们把以上代码整理一下放入intercept.js:

import { proxy } from 'ajax-hook'

function hijack(url, { method }) {
  return new Promise((resolve, reject) => {
    // 稍后再替换这段代码
    console.log(`拦截请求 ${method} ${url}`)
    reject();
  })
}

proxy({
  onRequest: (config, handler) =>
    hijack(config.url, config)
      .then(({ response }) => {
        return handler.resolve({
          config,
          status: 200,
          headers: [],
          response,
        })
      })
      .catch(() => handler.next(config)),
  onResponse: (response, handler) => {
    handler.resolve(response)
  },
})

if (window.fetch) {
  const f = window.fetch
  window.fetch = (req, config?) => {
    return hijack(req, config)
      .then(({ response }) => {
        return new Response(response, {
          headers: new Headers([]),
          status: 200,
        })
      })
      .catch(() => f(req, config))
  }
}
复制代码

注意这里我们引用了一个第三方包,需要考虑用打包工具打包。

然后在content_script.js中将这个文件插入文档中:

const interceptScript = document.createElement('script')
interceptScript.src = chrome.runtime.getURL('intercept.js')
document.head.prepend(interceptScript)
复制代码

然后一定要记得manifest.json中声明对应的权限:

{
  "name": "My Chrome Extension",
  "description": "",
  "manifest_version": 2,
  "version": "1.0.0",
  "permissions": [],
  "content_scripts": [
    {
      "js": ["content_script.js"],
      "matches": ["*://*/*"],
      "all_frames": true
    }
  ],
  "content_security_policy": "script-src 'self' *; object-src 'self'",
  "web_accessible_resources": ["intercept.js"]
}
复制代码

其中,content_scriptscontent_security_policy是为我们的content_script.js能够在浏览器中运行准备。需要声明准备访问的域名。这里你看到我在content_scripts中写的是*://*/*,而content_security_policy中只用了*,理论上来说两者皆可对应所有页面,不过实验中发现content_scripts不写那么详细好像没法正常工作?‍♀️。

另外在web_accessible_resources必须声明我们即将插入的代码文件,否则页面是无法访问到这个文件的。

编辑存储mock数据

现在我们还缺一个简单的界面来设置mock数据。这个页面可以在Chrome右上角的弹出框里,或是在调试工具框里,或是一个单独的页面,看你喜欢放哪。这里我就跳过UI的部分,假设最终在页面上调用如下接口把数据存入了Chrome Storage中:

chrome.storage.local.set({ key: value }, callback)
复制代码

注意Chrome的storage接口有几种,其中我们可以用来存储的主要是local和sync。它们除了容量大小区别之外,还有一点很重要的是如名字所示,一个是在本地,另一个是让Google账号同步的存储。那一般情况下我们还是尽量选择本地存储。

假设我们把mock都存储在键值为mock的数据中,包含匹配的完整URL、方法以及mock的响应数据,如下:

chrome.storage.local.get(['mock'], function(result) {
  console.log(result.mock);
  /* 打印:
     {
       'GET http://www.example.com/api/test': {
          response: '{"msg":"This is a mock"}'
       }
     }
  */
});
复制代码

那么如何让插入在页面中的代码获取到这些信息呢?它隶属于页面环境,是无法访问扩展的存储的哦!

这里我们可以借助于Chrome的页面与扩展的通信消息机制。

页面中通过如下接口向指定扩展发送消息:

chrome.runtime.sendMessage(
  EXTENSION_ID,
  message,
  options,
  (response) => {
    // 如果有响应的话可以在回调中处理
  }
)
复制代码

而扩展中通过如下接口处理收到的消息:

chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  // 处理收到的信息,可以通过sendResponse给发消息方响应
})
复制代码

让我们在之前的intercept.js中向扩展发送消息。而扩展中则可以处理消息,查询我们是否有对应的mock设置,然后回应页面的请求。

在此之前,我们首先发现有一个问题:EXTENSION_ID是神马?

所有Chrome扩展都有个ID。已经发布的扩展ID在url里就可以找到。比如这个Google Translate的URL的最后一部分:

https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb
复制代码

本地安装的扩展在浏览器里输入chrome://extensions回车就可以看到图标下面的扩展ID。

然而细心的小伙伴很快会发现本地调试的话每次重新加载ID都会更新。那怎么办呢?

我们可以通过chrome.runtime.id在扩展中获取目前的ID。在页面中插入一个全局变量让intercept.js读取。

修改content_script.js如下:

const extensionGlobals = document.createElement('script')
extensionGlobals.innerText = `window.__EXTENTION_ID__ = "${chrome.runtime.id}";`
document.head.prepend(extensionGlobals)
const interceptScript = document.createElement('script')
interceptScript.src = chrome.runtime.getURL('intercept.js')
document.head.prepend(interceptScript)
复制代码

然后就可以让intercept.js给扩展发消息了:

import { proxy } from 'ajax-hook'

function hijack(url, { method }) {
  return new Promise((resolve, reject) => {
    console.log(`拦截请求 ${method} ${url}`)
    
    chrome.runtime.sendMessage(
      window.__EXTENSION_ID__,
      {
        type: 'request_mock',
        url,
        method
      },
      {},
      (response) => {
        if (response) resolve(response);
        else reject();
      }
    )
  })
}

proxy({
  onRequest: (config, handler) =>
    hijack(config.url, config)
      .then(({ response }) => {
        return handler.resolve({
          config,
          status: 200,
          headers: [],
          response,
        })
      })
      .catch(() => handler.next(config)),
  onResponse: (response, handler) => {
    handler.resolve(response)
  },
})

if (window.fetch) {
  const f = window.fetch
  window.fetch = (req, config?) => {
    return hijack(req, config)
      .then(({ response }) => {
        return new Response(response, {
          headers: new Headers([]),
          status: 200,
        })
      })
      .catch(() => f(req, config))
  }
}
复制代码

扩展中最好在background_script中处理消息,因为它不同于弹出框、调试框,它不需要用户打开就会初始化并一直在后台运行。打开background_script需要在manifest.json中声明一下:

{
  "name": "My Chrome Extension",
  "description": "",
  "manifest_version": 2,
  "version": "1.0.0",
  "permissions": [
    "background",
    "*://*/*"
  ],
  "background": {
    "scripts": ["background_script.js"],
    "persistent": true
  },
  "externally_connectable": {
    "matches": [
      "*://*/*"
    ]
  },
  "content_scripts": [
    {
      "js": ["content_script.js"],
      "matches": ["*://*/*"],
      "all_frames": true
    }
  ],
  "content_security_policy": "script-src 'self' *; object-src 'self'",
  "web_accessible_resources": ["intercept.js"]
}

复制代码

创建background_script.js,处理我们收到的request_mock

chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
   if (message && message.type === 'request_mock') {
    const { method, url } = message;
    chrome.storage.local.get(['mock'], function({ mock }) {
      const matchedMock= mock[`${method.toUpperCase()} ${url}`];
			sendResponse(matchedMock);
    });	
   }
});
复制代码

好了,这样一个mock插件就初步完成了。

插入代码拦截的缺陷

像这样插入代码拦截请求的方式基本上可以获得所有原请求的信息,然而它有一个致命的缺陷——无法拦截在文档加载期间发出的请求。

这是因为我们的插入行为是一种DOM操作,需要等待文档加载完成才可以开始插入的操作。目前我没有找到比较好可以绕过的方法。

如果想要覆盖完整一些,可能需要借助于Chrome的代理接口加一个外部的mock服务器处理了。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享