axios是一个基于promise调用逻辑的http请求库,是一个优秀的开源项目。了解其实现逻辑有助于深化我们对接口往返的理解,提高Promise的应用能力等。本文会挑几个axios重点且常用的功能模块进行原理剖析并使用ts实现,完整代码在这里。本文对每个模块将按照功能描述,原理剖析,ts实现的顺序进行。
请求参数与url的处理
axios会对我们传入的请求参数做统一处理,当发送的是get请求时会根据不同数据类型做不同的拼接处理,总结有以下几点
-
基本类型 按基础规则拼接
axios({ method: 'get', url: '/test/get', params: { a: 1, b: 2, }, }); // http://localhost:8080/simple/get?a=1&b=2
-
数组 属性名拼接数组符号并依次拼接各元素
axios({ method: 'get', url: '/test/get', params: { arr: [1, 2], }, }); // http://localhost:3000/test/get?arr[]=1&arr[]=2
-
对象 encode后拼接
axios({ method: 'get', url: '/test/get', params: { obj: { name: 'foo', }, }, }); // http://localhost:3000/test/get?obj=%7B%22name%22:%22foo%22%7D
-
Date toString后拼接
axios({ method: 'get', url: '/test/get', params: { date: new Date(), }, }); // http://localhost:3000/test/get?date=2021-11-02T07:58:49.323Z
特殊字符不被encode
@
、:
、$
、,
、[
、]
,允许他们存在url中。忽略空值 类型为null
或者
undefined的值不会被添加到url中。忽略哈希标记#.
保存已存在的url参数,传入的参数会继续拼接在已存在的参数后。
接下来具体实现,命名为buildUrl
// 声明两个工具函数 用于判断Date类型和object类型
function isDate(val: any) {
return toString.call(val) === '[object Date]'
}
function isPlainObject(val: any) {
return toString.call(val) === '[object Object]'
}
function buildURL(url: string, params?: any): string {
if (!params) {
return url;
}
// 要拼接的参数数组
const parts: string[] = [];
Object.keys(params).forEach(key => {
let val = params[key];
if (val === null || typeof val === 'undefined') {
return;
}
let values = [];
if (Array.isArray(val)) {
values = val;
// 属性名加'[]'标记
key += '[]';
} else {
values = [val];
}
values.forEach(val => {
if (isDate(val)) {
val = val.toISOString();
} else if (isPlainObject(val)) {
val = JSON.stringify(val);
}
parts.push(`${encode(key)}=${encode(val)}`);
});
});
let serializedParams = parts.join('&');
if (serializedParams) {
// 忽略哈希标记
const markIndex = url.indexOf('#');
if (markIndex !== -1) {
url = url.slice(0, markIndex);
}
// 保留原有的参数
url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams;
}
return url;
}
默认配置与配置合并策略
axios有默认配置(axios.defaults),我们可以修改默认配置且其能与我们传入axios的配置合并,下面分析其合并策略。
由于axios的配置的是个复杂对象,因此默认配置和自定义配置的合并也不是简单的对象合并。合并的总体原则是,对于基本类型属性(method,timeout,withCredentials。。。)的合并优先使用自定义属性,没有自定义属性则用默认属性,无默认属性则为null(有些属性没有默认值如url、
params、
data,因为这些属性与当次请求强相关。设置默认值无意义),对于object类型(如headers)属性则需要深度合并,即要进行递归判断。
因此,我们要为每一个属性定制其合并策略,接下来按以下步骤具体实现:
- 声明一个对象strats用来存储各个属性的合并策略
const strats = Object.create(null);
-
分别定义默认策略和深度合并策略
默认策略
优先用自定义属性 没有则用默认属性 否则返会null
const defMerge = (target: any, source: any) => { return typeof source !== 'undefined' ? source : typeof target !== 'undefined' ? target : null; };
深度合并策略
基本类型直接合并,对象类型值则判断原属性是否是对象类型,如果是,则递归合并。不是,则用一个空对象与之合并。const deepMerge = (...objs: any[]): any => { const result = Object.create(null) objs.forEach(obj => { if (obj) { Object.keys(obj).forEach(key => { const val = obj[key] if (isPlainObject(val)) { if (isPlainObject(result[key])) { result[key] = deepMerge(result[key], val) } else { result[key] = deepMerge({}, val) } } else { result[key] = val } }) } }) return result }
为每个属性指定合并策略
由于只有少数属性('headers', 'auth')需要深度合并,因此我们只需将需要深度合并的属性及其合并策略注册到strats中,在执行合并时判断当前属性是否在strats中存在即可,存在则执行其专属的合并策略,不存在则执行默认合并策略。
// 需要深度合并的属性
const stratKeysDeepMerge = ['headers', 'auth'];
// 注册合并策略
stratKeysDeepMerge.forEach(key => {
strats[key] = deepMergeStrat;
});
- 执行合并
我们约定config1代表默认配置 config2代表自定义配置。
首先声明一个空对象存储合并结果,遍历自定义配置并执行对应合并策略,会优先使用strats中的合并策略,没有则用默认合并策略。之后遍历默认配置,只有合并结果中不存在该属性时再执行其合并策略。
function mergeConfig(config1: AxiosRequestConfig, config2?: AxiosRequestConfig): AxiosRequestConfig {
if (!config2) {
config2 = {};
}
const config = Object.create(null);
// 优先合并自定义配置
for (let key in config2) {
mergeField(key);
}
// 合并默认配置 只有在没有自定义配置时才使用默认配置
for (let key in config1) {
if (!config2[key]) {
mergeField(key);
}
}
function mergeField(key: string): void {
// 优先自定义配置合并策略 没有则用默认策略
const strat = strats[key] || defMerge;
config[key] = strat(config1[key], config2![key]);
}
return config;
}
拦截器
axios的拦截器几乎是项目中必用的一项配置,它可以在请求前/响应后对请求体/响应体做一些处理,先来回顾一下基本用法。
使用use方法注册拦截器,使用类似promise.then,接收两个参数,第一个用来添加我们期望拦截器处理的逻辑,第二个参数用来处理错误。
使用eject方法删除某个拦截器。
拦截器可以添加多个,执行顺序是:请求拦截器先添加的后执行,响应拦截器先添加的先执行。
我们知道axios是基于promise实现的,结合拦截器的执行过程其实不难想到可以用promise的链式调用实现,先来回顾一下链式调用。简单来讲Promise.then方法会返回一个Promise,可以继续调用then方法, 前一个then的回调返回的数据会作为参数传入下一个then的回调。因此我们可以将请求/响应拦截器与请求发送的调用使用promise.then方法串联起来。
基于上述,首先实现一个拦截器管理类
interface ResolvedFn<T = any> {
(val: T): T | Promise<T>
}
interface RejectedFn {
(error: any): any
}
interface Interceptor<T> {
resolved: ResolvedFn<T>
rejected?: RejectedFn
}
class InterceptorManager<T> {
private interceptors: Array<Interceptor<T> | null>
constructor() {
// 用于存放拦截器
this.interceptors = []
}
// 注册拦截器,返回其索引可用于删除
use(resolved: ResolvedFn<T>, rejected?: RejectedFn): number {
this.interceptors.push({
resolved,
rejected
})
return this.interceptors.length - 1
}
// 遍历所有拦截器,将每个拦截器作为传入函数的参数并执行
// 将来用于将拦截器推入promise的调用链中
forEach(fn: (interceptor: Interceptor<T>) => void): void {
this.interceptors.forEach(interceptor => {
if (interceptor !== null) {
fn(interceptor)
}
})
}
// 删除拦截器
eject(id: number): void {
if (this.interceptors[id]) {
this.interceptors[id] = null
}
}
}
接下来为Axios类添加interceptors属性,他有两个值分别是请求和响应拦截器
export default class Axios {
constructor() {
this.interceptors = {
request: new InterceptorManager<AxiosRequestConfig>(),
response: new InterceptorManager<AxiosResponse>()
}
}
......
}
拦截器拦截的是请求,因此最后需要对发送请求的方法进行处理。具体如下:
声明一个chain数组用于存放promise调用链,并首先将请求发送方法放入。
分别调用请求/响应拦截器的foreach方法,将请求拦截器倒序插入数组前部,将响应拦截器顺序插入数组尾部。
声明一个resolved状态的Promise用于启动链式调用,最后循环chain数组,取出每个拦截器,使用then方法调用即可。
request(url: any, config?: any): AxiosPromise {
// 其他逻辑
const chain: PromiseChain[] = [{
resolved: dispatchRequest,
rejected: undefined
}]
// 将请求拦截器倒序插入数组前部
this.interceptors.request.forEach(interceptor => {
chain.unshift(interceptor)
})
// 将响应拦截器插入数组尾部
this.interceptors.response.forEach(interceptor => {
chain.push(interceptor)
})
// 初始化一个reslove状态的promise
let promise = Promise.resolve(config)
while (chain.length) {
// 链式调用
const { resolved, rejected } = chain.shift()!
promise = promise.then(resolved, rejected)
}
return promise
}
请求取消
请求取消也是axios在项目中的一个常用功能,一个典型场景就是当接口响应慢且会多次触发时(如点击按钮提交,搜索输入框等),由于每次响应时间不定,因此可能出现后发出的请求比先发出的请求的响应速度快的情况,此时就可以使用请求取消,即判断前一次请求结果未返回时,取消当次请求。
回顾下如何使用请求取消,有两种使用方式:
- 方式一 使用axios.CancelToken的source方法,调用后返回token和cancel两个属性,token用于请求时传给配置对象中的cancelToken属性,在请求发出后,可以使用cancel方法取消请求。
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get('/test/get', {
cancelToken: source.token
})
source.cancel('Operation canceled by the user.');
- 方式二 直接new一个CancelToken的实例赋给配置对象的cancelToken属性并传入一个函数,该函数接收一个处理取消逻辑的函数参数,在函数体内将其赋给在外手动声明的cancel变量,通过执行cancel函数取消请求。
const CancelToken = axios.CancelToken;
let cancel;
axios.get('/test/get', {
cancelToken: new CancelToken(function executor(c) {
cancel = c;
})
});
cancel();
接下来分析其实现思路。
我们知道http请求取消是通过调用xhr对象的abort方法实现的。现在的问题是当想要取消请求时我们往往无法直接访问xhr对象。要想操作xhr对象我们只有在请求发送的过程中,也即在new XMLHttpRequest()后才能访问xhr对象。而要实现请求取消,我们只能事先将请求取消的逻辑写好但不执行,等将来需要取消时再执行这段逻辑。说到这里就又想到了promise,我们知道promise.then方法中指定的回调是会等promise的状态改变后再执行的,因此实现思路也就有啦。我们可以声明一个pending状态的promise,在then方法的成功回调中添加请求取消的逻辑,即xhr.abort。等需要取消时,将该promise的状态改变即可。换句话说我们把请求取消的逻辑"寄托"在了一个promise上。那么这个promise从何而来?观察拦截器的两种使用方式,其实配置对象中的cancelToken属性就是那个promise。
既然有两种使用方式,相应的也就有两种方式可以得到这个promise,分别是直接实例化axios.CancelToken类以及调用CancelToken类的source方法得到。axios得到这个promise后就可以将取消逻辑‘寄托’在其then方法上,接下来看一下实现:
export default (config: AxiosRequestConfig): AxiosPromise => {
return new Promise((resolve,reject)=>{
const {
......
cancelToken,// 传入的promise
} = config
const request = new XMLHttpRequest()
// ...其他逻辑
if (cancelToken) {
cancelToken.promise.then(reason => {
// 调用取消方法
request.abort()
// 改变Axiospromise状态为失败
reject(reason)
})
}
})
}
接下里分析如何改变该promise的状态。观察第二种使用方式,executor函数接收的参数就是用来处理取消逻辑的函数,即改变promise的状态。因此在实现CancelToken类时首先需要声明一个pending状态的promise,即暂不执行resolve函数,而是将其暂存起来(赋给外部变量)。之后执行executor函数并传入一个函数,函数内部执行暂存的resolve函数即可改变上述promise的状态,接下来具体实现
interface ResolvePromise {
(reason?: string): void
}
class CancelToken {
promise: Promise<string>
reason?: string
constructor(executor) {
let resolvePromise: ResolvePromise
// 声明一个pending状态的promise
this.promise = new Promise<string>(resolve => {
resolvePromise = resolve
})
// executor函数传入的参数会被赋值给外部变量cancel用于取消请求
executor(message => {
if (this.reason) {
return
}
this.reason = message
resolvePromise(this.reason)
})
}
}
至此第二种使用方式已经实现,再来看第一种。容易看出source函数返回的token就是一个pending状态的promise,返回的cancel函数可直接调用,无需我们手动声明,对比一下不难发现其实这就是相对于第二种方式做了一层封装,将CancelToken的实例化和取消函数的处理逻辑放在source方法内部实现,接下来实现source方法。
class CancelToken {
...
//静态方法source 实例化CancelToken 声明cancel变量 接收取消函数并返回
static source(): CancelTokenSource {
let cancel
const token = new CancelToken(c => {
cancel = c
})
return {
cancel,
token
}
}
constructor(executor) {...}
}
至此请求取消的主体逻辑已经实现。有一点需要特别区分,即axios.CancelToken和config的cancelToken这两个属性,事实上他们是类与实例的关系。
源码地址,如有错误恳请指正。