前言
在几个项目中用了 fetch,总体感觉还不错,优点就是灵活,可以根据不同的项目需求定制封装不同的请求。但是缺点也是灵活 🤣,正因为定制化,所以每个项目都得再复制一份然后魔改成需要的样子。
能不能像 Axios 一样,封装并提取一些公共部分?比如今天想和大家分享的 Interceptor 拦截器。
Axios 拦截器源码浅析
所谓拦截,有点类似于钩子的概念,在真正的请求发起之前和响应结束之后这两段时机内额外做一些其他事情,比如统一修改请求配置和处理响应结果。
1. 拦截器的模型设计
我们使用 axios.interceptors.request.use(resolved, rejected)
来注册拦截器。这里的 resolved
(或者叫 fulfilled
)和 rejected
最终都以回调函数的形式传递给了 Promise.prototype.then(onFulfilled, onRejected)
,用以处理成功和失败的情况。
除了注册功能,拦截器还能做很多其他事情,比如添加多个拦截器,删除拦截器,清空拦截器等。所以,我们可以将拦截器单独抽离出一个 InterceptorManager
类。
既然可以添加多个拦截器,那么这个类就可以维护一个数组,它的大概形状如下所示:
js
class InterceptorManager {
constructor() {
this.handlers = [];
}
// 注册拦截器
use(fulfilled, rejected) { /* ... */ }
// 删除拦截器
eject() { /* ... */ }
// 清空拦截器
clear() {
if (this.handlers) {
this.handlers = [];
}
}
// 遍历拦截器
forEach() { /* ... */ }
}
除简单的 clear
外,其他几个我们依次来看下源码是怎么实现的。
use 注册
use
其实就是增删改查中的增,将收集到的 fulfilled
和 rejected
存入数组。
js
use(fulfilled, rejected, options) {
this.handlers.push({
fulfilled,
rejected,
// ...
});
return this.handlers.length - 1;
}
注意: 它返回一个当前拦截器所在数组的 index
。这样做的目的是将其当成 ID
使用,我们可以在注册时,接受这个 ID
,在不需要的时候调用 eject
方法删除它(虽然不常用)。
eject 删除
eject
接收一个 ID
,即 use
返回的拦截器所在数组的下标,eject
根据 ID
将其所对应的拦截器置为 null
。
js
eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null;
}
}
之所以置为 null
,也很好理解,因为直接删除的话,数组中元素的下标就乱掉了。
forEach 遍历
forEach
接收一个回调,它会在遍历的同时跳过已被 eject
"删除"的拦截器,并执行回调,将每一个拦截器作为参数传入该回调。
js
forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) {
fn(h);
}
});
}
forEach
方法是为了多个拦截器链式调用时准备的,这个我们待会儿会讲到。
2. 拦截器的任务编排设计
什么数据结构能在某个成员的前面和后面添加其他成员,并依次遍历执行呢?没错,就是队列。
队列的先进先出(FIFO)原则,保证了拦截器的执行时机,即先添加的请求拦截会在请求本身之前执行,后添加的响应拦截会在请求本身之后执行。
拦截器队列
我们先在 Axios
类中实例化刚刚创建好的 InterceptorManager
,请求、拦截各一个:
js
class Axios {
constructor(instanceConfig) {
// other code ...
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
}
以请求拦截为例,遍历 InterceptorManager
收集到的拦截器,备份到 Axios
维护的请求拦截器队列中,这里就用到了上文提到的 forEach
方法。
js
request(configOrUrl, config) {
// other code ...
const requestInterceptorChain = [];
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
// other code ...
requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});
}
注意: 这里使用 unshift
进行添加,因为请求拦截是在请求本身之前。同理,响应拦截队列就应该使用 push
进行添加。
完整队列的链式调用
现在,Axios
中有了两个队列:请求拦截队列、响应拦截队列。我们得将它们组合到一起。此外,还差一样重要的东西:请求本身(dispatchRequest
)。
dispatchRequest 用于真正的数据请求,这里我们先不用管它具体是怎么实现的。
我们再创建一个 chain
数组,将 dispatchRequest
放入其中:
js
const chain = [dispatchRequest.bind(this), undefined];
注意: 这里还传入了 undefined
。这是因为作为 .then()
的两个回调参数,onFulfilled
和 onRejected
总是成对出现,你可以将 dispatchRequest
看成 onFulfilled
,将 undefined
看成 onRejected
。
再把之前两个拦截器队列统统融入进来:
js
chain.unshift.apply(chain, requestInterceptorChain);
chain.push.apply(chain, responseInterceptorChain);
至此,Axios
终于有了一个完整的队列。最后一步,我们需要一个引子,将整个队列执行起来。
既然请求拦截需要对 config
进行编辑,那我们就利用 config
创建一个给定 fulfilled
状态的 Promise
对象,有了这个引子,我们就能续上 chain
队列:遍历 chain
,并将其传递给 promise
,让它一直调用下去:
js
request() {
// other code ...
let promise;
let i = 0;
let len;
if (!synchronousRequestInterceptors) {
const chain = [dispatchRequest.bind(this), undefined];
chain.unshift.apply(chain, requestInterceptorChain);
chain.push.apply(chain, responseInterceptorChain);
len = chain.length;
// 把 config 初始化为一个 Promise 对象,开始链式调用
promise = Promise.resolve(config);
while (i < len) {
// 依次取出执行 onFulfilled 和 onRejected 方法
// 将执行后的结果传给下一个拦截器
promise = promise.then(chain[i++], chain[i++]);
}
return promise;
}
// other code ...
}
我们可以结合这张示意图来理解拦截器的设计。
实现 Fetch 拦截器
有了前置知识的了解,给 Fetch 安装拦截器就简单多了,InterceptorManager
类可以直接拿来用,我们简化其中的拦截器实现即可。
ts
import InterceptorManager from './interceptorManager';
import dispatchRequest from './dispatchRequest';
type ResolvedFn<T = any> = (val: T) => T | Promise<T>;
type RejectedFn = (error: any) => any;
interface Interceptor<T> {
resolved: ResolvedFn<T>;
rejected?: RejectedFn;
}
interface Interceptors {
request: InterceptorManager<RequestInit>;
response: InterceptorManager<any>;
}
class FetchHttp {
private readonly defaultConfig: RequestInit = {
credentials: 'include', // 默认支持发送跨域 cookie
};
interceptors: Interceptors;
constructor(config) {
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager(),
};
// todo: 处理合并 config ......
}
async request(url: string, config: RequestInit = {}) {
// other code ...
// 声明一个 Promise 队列,存放所有的拦截器(包括真正用于请求的 dispatchRequest)
const chain: Array<Interceptor<RequestInit>> = [{
resolved: dispatchRequest,
rejected: undefined,
}];
// 将请求拦截塞进 chain 前面
this.interceptors.request.forEach(interceptor => {
chain.unshift(interceptor);
});
// 将响应拦截塞进 chain 后面
this.interceptors.response.forEach(interceptor => {
chain.push(interceptor);
});
// 利用 config 初始化一个 promise
let promise = Promise.resolve({
url,
...this.defaultConfig,
});
// 执行任务
while (chain.length) {
const { resolved, rejected } = chain.shift()!;
promise = promise.then(resolved, rejected);
}
return promise;
}
}
这里,我们用对象 [{ resolved: dispatchRequest, rejected: undefined }]
替代了原先的 [dispatchRequest, undefined]
,这种写法看上去更好理解,对 TS 而言也会有更好的类型支持。
总结
- 探寻 Axios 源码如何实现拦截器;
- 如法炮制给 Fetch 添加了拦截器。
篇幅有限,获取 TypeScript 版完整 Fetch 代码,可直接翻阅 FetchHttp 🤞❤️。关于 Fetch 的基本知识,可以参阅往期文章 Fetch API 的使用和采坑记录。
现在,你可以将封装的 Fetch 上传到公司内部的 NPM 私有仓库。这样,就不用每次都 CP 再魔改了。
但是,如果将 Fetch 封装成了 Fexios,那我为什么不直接使用 Axios 呢?