fetch劫持的可行性方案

背景

最近公司要搞前端监控,要对请求进行拦截,接口错误或者非200-400之间都上报错误数据,方便排查。ajax和axios都好说,关键在fetch的劫持中还是遇到了不少的问题。

前言

相比较于XMLHttpRequest来说,fetch的写法简单又直观,只要在发起请求时将整个配置项传入就可以了。而且相较于 XHR 还提供了更多的控制参数,例如是否携带 Cookie、是否需要手动跳转等。此外 Fetch API 是基于 Promise 链式调用的,一定程度上可以避免一些回调地狱。举个例子,下面就是一个简单的 fetch 请求:

fetch('https://example.org/foo', {
    method: 'POST',
    mode: 'cors',
    headers: {
        'content-type': 'application/json'
    },
    credentials: 'include',
    redirect: 'follow',
    body: JSON.stringify({ foo: 'bar' })
}).then(res => res.json()).then(...)
复制代码

再来看看XMLHttpRequest是如何工作的,简单来说,你需要调用 open() 方法开启一个请求,然后调用其他的方法或者设置参数来定义请求,最后调用 send() 方法发起请求,再在onload或者 onreadystatechange 事件里处理数据:

var xhr = new XMLHttpRequest();
//设置xhr请求的超时时间
xhr.timeout = 3000;
//设置响应返回的数据格式
xhr.responseType = "text";
//创建一个 post 请求,采用异步
xhr.open('POST', '/server', true);
//注册相关事件回调处理函数
xhr.onload = function(e) { 
  if(this.status == 200||this.status == 304){
      alert(this.responseText);
  }
};
xhr.ontimeout = function(e) { ... };
xhr.onerror = function(e) { ... };
xhr.upload.onprogress = function(e) { ... };

//发送数据
xhr.send(...);
复制代码

Fetch API 不足之处

看起来 Fetch API 相比较于传统的 XHR 优势不少,不过在「真香」之前,我们先来看三个在 XHR 上很容易实现的功能:

  1. 如何中断一个请求?

XMLHttpRequest 对象上有一个 abort() 方法,调用这个方法即可中断一个请求。此外 XHR 还有 onabort 事件,可以监听请求的中断并做出响应。
2. 如何超时中断一个请求?
XMLHttpRequest 对象上有一个 timeout 属性,为其赋值后若在指定时间请求还未完成,请求就会自动中断。此外 XHR 还有 ontimeout 事件,可以监听请求的超时中断并做出响应。
3. 如何拦截请求,对响应内容/请求内容进行包装?
XMLHttpRequestresponseText可以解析响应数据,但是对于fetch需要调用res.json()/res.text()来获取数据,但是如果我们在用户之前调用json方法,就会抛出错误,打开了 MDN,仔细地看了 fetch()Response的介绍:

Response.json()
读取 Response 对象并且将它设置为已读(因为 Responses 对象被设置为了 stream 的方式,所以它们只能被读取一次),并返回一个被解析为 JSON 格式的 Promise 对象。

WechatIMG645.png

呃….,这个只能被读取一次确实有点坑。但是真的就没有办法了吗?

对于第一个问题其实已经有比较好的解决方案了,只是在浏览器上的实现距离Fetch API晚了近三年。随着 AbortControllerAbortSignal 在各大浏览器上完整实现,Fetch API 也能像 XHR 那样中断一个请求了,只是稍微绕了一点。通过创建一个 AbortController 实例,我们得到了一个 Fetch API 原生支持的控制中断的控制器。这个实例的 signal 参数是一个 AbortSignal 实例,还提供了一个 abort() 方法发送中断信号

对于第二个问题,既然已经稍微绕路实现中断请求了,为何不再绕一下远路呢?只需要 AbortController 配合 setTimeout()就能实现类似的效果了。

对于第三个问题,我在Fetch API MDN上找到了这句话,给了我解题的思路:

流操作API 中的ReadableStream 接口呈现了一个可读取的二进制流操作。Fetch API 通过 Response 的属性 body 提供了一个具体的 ReadableStream 对象。

ReadableStream???!!!哦吼!发现的了不得东西,既然是流那是否会有clone,tee,pipe相关的方法可以用呢?

Streams 是什么

流将你希望通过网络接收的资源拆分成小块,然后按位处理它。这正是浏览器在接收用于显示web页面的资源时做的事情——视频缓冲区和更多的内容可以逐渐播放,有时候随着内容的加载,你可以看到图像逐渐地显示。

但曾经这些对于JavaScript是不可用的。以前,如果我们想要处理某种资源(如视频、文本文件等),我们必须下载完整的文件,等待它反序列化成适当的格式,然后在完整地接收到所有的内容后再进行处理。

随着流在JavaScript中的使用,一切发生了改变——只要原始数据在客户端可用,你就可以使用JavaScript 按位处理它,而不再需要缓冲区、字符串或blob

Concept.png

还有更多的优点——你可以检测流何时开始或结束,将流链接在一起,根据需要处理错误和取消流,并对流的读取速度做出反应。

流的基础应用围绕着使响应可以被流处理展开。例如,一个成功的 fetch request 响应 Body 会暴露为 ReadableStream,之后你就可以使用 ReadableStream.getReader() 建立的 reader 读取它,使用 ReadableStream.cancel() 取消它等等。

ReadableStream

ReadableStream.tee()
tee 方法(tee本意是将高尔夫球放置在球座上)tees 了可读流,返回包含两个ReadableStream 实例分支的数组,每个元素接收了相同的传输数据。

WechatIMG2632.jpeg
所以我们可以利用这个特性将一个流分成两个流,将其中一个流用于输出我们拦截的数据,而另一个流直接返回给用户:

const getFetchRes = (res) => {
    const [hijackStream, returnStream] = res.body.tee();
   
	const getBody = function(response: Response) {
	  return new Promise((reslve, reject) => {
	    if (response.headers.get('content-type') === 'application/json') {
	      response
	        .json()
	        .then(function(json) {
	          reslve(json)
	        })
	        .catch(error => {
	          reject(error)
	        })
	    } else {
	      response
	        .text()
	        .then(function(text) {
	          reslve(text)
	        })
	        .catch(error => {
	          reject(error)
	        })
	    }
	  })
	}

	getBody.then((res)=>{
		report({...})
	})
	
    return new Response(returnStream, { headers: res.headers })
};
fetch('/foo').then(logProgress).then(res => res.json()).then((data) => { ... })
复制代码

很好,这样我们就完美的解决了fetch 返回数据不能劫持的问题了。

流的锁机制

一个流只能同时有一个处于活动状态的 reader,当一个流被一个 reader 使用时,这个流就被该 reader 锁定了,此时流的 locked 属性为 true。如果这个流需要被另一个reader读取,那么当前处于活动状态的 reader 可以调用 reader.releaseLock() 方法释放锁。此外 readerclosed 属性是一个 Promise,当 reader 被关闭或者释放锁时,这个 Promise 会被 resolve,可以在这里编写关闭 reader 的处理逻辑:

reader.closed.then(() => {
  console.log('reader closed');
});
reader.releaseLock();
复制代码

让我们翻一下 Fetch API 的规范文档,在 5.2. Body mixin 中有如下一段话:

Objects implementing the Body mixin also have an associated consume body algorithm, given a type, runs these steps:

1.If this object is disturbed or locked, return a new promise rejected with a TypeError.
2.Let stream be body’s stream if body is non-null, or an empty ReadableStream object otherwise.
3.Let reader be the result of getting a reader from stream. If that threw an exception, return a new promise rejected with that exception.
4.Let promise be the result of reading all bytes from stream with reader.
5.Return the result of transforming promise by a fulfillment handler that returns the result of the package data algorithm with its first argument, type and this object’s MIME type.

简单来说,当我们调用 Body 上的方法时,浏览器隐式地创建了一个 reader 读取了返回数据的流,并创建了一个 Promise 实例,待所有数据被读取完后再 resolve 并返回格式化后的数据。所以,当我们调用了 Body 上的方法时,其实就创建了一个我们无法接触到的 reader,此时这个流就被锁住了,自然也无法从外部取消。

参考资料:

MDN web docs – Streams API

从 Fetch 到 Streams —— 以流的角度处理网络请求 – 网易云音乐大前端团队

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