整个实现流程分为 5 个大部分:
准备测试环境 axios 核心请求构建 多宿主环境(浏览器 || node)适配思想 拦截器的实现原理 如何取消请求
1、准备基础的测试环境
1.1 基于 Koa 准备一个最简单的服务程序:
import Koa from 'koa';
const app = new Koa();
// 一个简单的路由处理函数
app.use(async ctx => {
ctx.body = 'Hello, World!';
});
// 启动服务器
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});
因为我们需要在浏览器中测试请求,所以服务端还需要支持浏览器跨域,所以我们添加一个支持跨域的中间件:
app.use(async (ctx, next) => {
ctx.set('Access-Control-Allow-Origin', '*'); // 允许所有来源
ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (ctx.request.method === 'OPTIONS') {
ctx.status = 200;
return;
}
await next();
});
1.2 准备浏览器和node端测试环境:
我们初始化基础的测试 html文件以及 node 文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src="./index.js"></script>
</body>
</html>
console.log('基础的浏览器测试环境')
基础的 node 测试环境比较简单,就是一个普通的 js 文件,只要我们上述的 js 文件不包含浏览器端的宿主 api 那么也可以直接在 node 端进行测试。整个结构搭建完成之后应该就是下面的文件格式:
2、axios 核心请求构建
2.1 开发 axios 的入口模块:
我们在测试文件夹下面新建一个 axios.js 的文件,入口内容开发比较简单,我们就不再过多赘述了,主要就是开发一个 Axios 类,初始化 axios 工厂函数以及导出 axios 实例:
// util.js
/**
*
* @param {Object} config1
* @param {Object} config2
*/
export const mergeConfig = (config1, config2) => {
return Object.assign(config1, config2)
}
import { mergeConfig } from './utils.js'
class Axios {
constructor(defaultConfig) {
this.defaultConfig = defaultConfig
}
/**
*
* @param {string} url
* @param {Object} options
*/
requiest(url, options) {
try {
this._requiest(url, options)
} catch (error) {
}
}
/**
*
* @param {string} url
* @param {Object} options
*/
_requiest(url, options) {
console.log('开始发送请求', url, options)
}
}
/**
*
* @param {Object} defaultConfig axios 的基础配置
*/
function createInstance(defaultConfig) {
// 初始化 axios 实例
const context = new Axios(defaultConfig)
const instance = Axios.prototype.requiest.bind(context)
// 实例上挂手动挂载一个 create 方法
instance.create = function create(instanceConfig) {
// 将用户传入的配置和默认配置进行合并
return createInstance(mergeConfig(defaultConfig, instanceConfig))
};
return instance
}
// 基于默认配置,利用工厂函数创建出一个默认的 axios 实例
const axios = createInstance({
// 默认的网络延迟时间
timeout: 0,
// adapter: 默认的适配器配置
adapter: ["xhr", "http", "fetch"],
// 基础路径
beseURL: "",
headers: {}
});
// 给 axios 添加一系列其他配置
axios.Axios = Axios;
axios.default = axios;
export default axios
axios 入口核心代码其实比较简单,最核心的就是利用工厂函数创建出一个最基础的 request 请求方法。
如果我们不需要进行额外的自定义配置,那么 axios 本身就已经可以开箱即用了。如果我们调用 create,本质上就是合并用户自定义的 axios 配置然后重新产生一个 requiest 方法。
开发完毕之后,我们就可以在之前已经准备好的测试文件中导入 axios 实例来进行测试了:
import axios from './axios.js'
axios('http://localhost:3000/')
浏览器中最基础的测试已经可以了。
我们看一下node环境:
至此基础的开发和测试就完毕了。
2.2 利用参数归一化的技巧处理 _requiest 的参数问题:
参数归一化是 js 这种弱类型语言中一种常见的统一函数入参的方法,好处就是 减少主干函数中对于参数校验的判断逻辑,统一函数参数的类型,让主干函数的代码更加清爽
/**
* 参数归一化的辅助函数
* @param {string} url
* @param {Object} options
*/
requestHelper(url, options) {
let config = {}
if (typeof url === 'string') {
config = options || {};
config.url = url;
} else if (typeof url === 'object') {
config = url;
}
return config
}
/**
*
* @param {string} url
* @param {Object} options
*/
_requiest(url, options) {
// 首先进行参数归一化,将所有的参数全部统一为一个配置对象
const config = this.requestHelper(url, options)
console.log('config', config)
}
我们来测试一下输出:
我们可以看到参数就已经统一成为一个对象了。在统一完毕 _requiest
这个函数的参数之后,因为现在 Axios 这个类中存在一个 defaultConfig 的默认配置,而 _requiest
本身又可以接收一个配置对象,所以我们可以将将这两个配置进行简单的合并:
/**
*
* @param {string} url
* @param {Object} options
*/
_requiest(url, options) {
// 首先进行参数归一化,将所有的参数全部统一为一个配置对象
const config = mergeConfig(this.defaultConfig, this.requestHelper(url, options))
console.log('最终的配置', config)
}
3、多环境请求发送的问题处理:
前端工程师接触的更多的环境一般是浏览器环境,浏览器环境中两个发送请求的方案:
xhr fetch
但是如果是比较旧的node环境的话这两种请求方案都不支持,node环境中原生支持的请求库是 http 以及 https 模块。
axios作为一个通用的http请求库就必须要解决这个问题。也就是它必须能够适应不同环境的请求方案。针对这个问题,axios 提出了 适配器 的概念,axios中所有的请求的发送都是基于这个适配器来进行发送的,源码中专门有一个模块来处理请求适配的问题:
适配器的思想其实极其简单,就是根据判断哪一套请求 api 存在,那么就使用那一套请求 api。这个和 vue 内部 nextTick 异步模块的处理方案是一致的。大家如果感兴趣可以去查阅一下。
我们来简单实现一下适配器的核心逻辑:
我们新建一个 Adapte.js 的文件:
export default {
/**
* 获取请求适配器的方法
* @param {Function | Function[]} adapters
*/
getAdapter(adapters) {
}
}
我们同样可以进行参数归一化:
// getAdapte 参数归一化
/**
* 获取请求适配器的方法
* @param {Function | Function[]} adapters
*/
const getAdapteHandlers = (adapters) => {
return Array.isArray(adapters) ? adapters : [adapters]
}
export default {
/**
* 获取请求适配器的方法
* @param {Function | Function[] | string[]} adapters
*/
getAdapter(adapters) {
// 参数归一化
adapters = getAdapteHandlers(adapters)
},
}
我们再新建一个 dispatchRequest 模块,并且在这个模块中统一发送 axios 请求:
/**
*
* axios 统一进行 http 请求发送的模块
*
* @param {Object} config
*/
export default function dispatchRequest(config) {
console.log('开始请求发送', config)
}
我们将这个模块导入到 axios 主模块中,并且在 _request
函数中进行调用:
import dispatchRequest from './dispatchRequest.js'
/**
*
* @param {string} url
* @param {Object} options
*/
_requiest(url, options) {
// 首先进行参数归一化,将所有的参数全部统一为一个配置对象
const config = mergeConfig(this.defaultConfig, this.requestHelper(url, options))
// 调用 dispatchRequest 方法进行请求的发送和处理,并且将合并之后的配置传入
dispatchRequest(config)
}
看一下控制台我们就可以发现请求方法被成功调用了:
接着我们就需要在 dispatchRequest 方法中处理多环境请求适配的问题了
首先我们要明白一个点,和vue3中的渲染器同样的设计理念,axios中的请求器也是允许用户自定义的,用户只需要在配置中指定 adapter 配置,传入一个 () => Promise 类型的函数就可以了 ,因此 mini axios
也需要支持这个设计:
用户只需要将这个配置替换掉,比如我们可以传入如下的配置:
那么原本默认的适配器就会被替换:
然后我们将 adapter 配置传入到 adapate.getAdapter 函数中:
export default function dispatchRequest(config) {
// 利用适配器获取当前环境的请求 api
adapate.getAdapter(config.adapter)
}
此时我们就可以在 adapate.getAdapter 函数中获取到最终的适配器的配置了。
因为参数归一的缘故,已经被统一为一个数组结构了:
我们目前先不测试自定义适配器的情况,所以我们先将基础配置复原:
那么我们拿到的适配器配置就是基础的配置:
我们先针对基础配置进行处理,我们先梳理一下适配器中需要处理的一些基础问题:
首先需要提供出 xhr, http, fetch 这三种请求方案对应的请求方法。 查找当前环境支持的第一个请求类型,使用基于该请求类型封装的请求方法来作为请求方案。
我们在这里就不把所有的请求方案都写出来了,我们先以 xhr 为例子来封装一个请求模块:我们新建一个 xhr 模块,先添加上如下代码:
// 首先判断当前环境是否存在 xhr 模块
const isXHRAdapterSupported = typeof XMLHttpRequest !== 'undefined'
这个判定就是为了判断当前宿主环境是否支持 XMLHttpRequest 这个构造函数
紧接着如果支持的话,那么我们就可以返回一个Promise函数,在函数中封装 xhr 的逻辑:
export default isXHRAdapterSupported && function (config) {
return Promise((resplved, rejected) => {
})
}
至于里面细枝末节的内容我就不过多赘述了,大家感兴趣可以去查阅代码,总体上比较简单。
封装好了 xhr 模块之后,我们就在 adapate 适配器中导入该模块,并且进行简单配置:
import xhr from './xhr.js'
// 假如目前的浏览器比较古老,只支持 xhr
const adapteConfig = {
xhr,
fetch: undefined,
http: undefined
}
紧接着,我们需要来编写查找当前环境支持的第一个请求方法的逻辑:
/**
* 获取请求适配器的方法
* @param {Function | Function[] | string[]} adapters
*/
getAdapter(adapters) {
// 参数归一化
adapters = getAdapteHandlers(adapters)
let handler = null
for (const adapter of adapters) {
handler = adapteConfig[adapter]
if (handler) {
// 当前已经找到合适的适配器,那么不需要继续查找了
break
}
}
return handler
},
至此我们就处理完毕默认处理器的情况了,但如果是用户自定义了处理器呢,我们还需要进一步适配这种情况,其实总体很简单:
/**
* 获取请求适配器的方法
* @param {Function | Function[] | string[]} adapters
*/
getAdapter(adapters) {
// 参数归一化
adapters = getAdapteHandlers(adapters)
let handler = null
for (const adapter of adapters) {
if (typeof adapter === 'function') {
// 支持用户自定义处理器的情况
handler = adapter
} else {
handler = adapteConfig[adapter]
}
if (handler) {
// 当前已经找到合适的适配器,那么不需要继续查找了
break
}
}
return handler
},
至此我们就把 axios 中的适配器的实现方案的核心逻辑探讨完毕了。
4、axios 拦截器实现方案:
axios 拦截器实现方案其实并没有太多特别的地方,和大部分开源库中实现异步任务调度是类似的方案,总体上是以下的思路:
实现拦截器的注册逻辑:
class InterceptorManager {
constructor() {
this.handlers = [];
}
use(fulfilled, rejected, options) {
this.handlers.push({
fulfilled,
rejected,
synchronous: options ? options.synchronous : false,
runWhen: options ? options.runWhen : null
})
return this.handlers.length - 1
}
}
export default InterceptorManager
在 Axios 类中导入该模块,初始化拦截器存储容器
class Axios {
/**
*
* @param {Object} defaultConfig
*/
constructor(defaultConfig) {
this.defaultConfig = defaultConfig;
// 初始化拦截器容器
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
}
function createInstance(defaultConfig) {
// 初始化 axios 实例
const context = new Axios(defaultConfig);
const instance = Axios.prototype.requiest.bind(context);
// 实例上挂手动挂载一个 create 方法
instance.create = function create(instanceConfig) {
// 将用户传入的配置和默认配置进行合并
return createInstance(mergeConfig(defaultConfig, instanceConfig));
};
// 获取到当前 axios 对象上的拦截器
instance.interceptors = context.interceptors;
return instance;
}
核心就是 在 Axios 实例上挂载了拦截器对象 。
然后在使用的过程中注册请求和响应拦截器
const instance = axios.create({
uri: 'http://localhost:3000/',
// adapter: () => new Promise((resolve, reject) => {
// resolve('自定义内容')
// })
})
// console.log('instance', instance.interceptors)
// 添加请求拦截器
instance.interceptors.request.use(
function (config) {
// 在发送请求之前做些什么,例如添加 token 到请求头
config.headers['Authorization'] = `Bearer ${localStorage.getItem('token')}`
return config;
},
function (error) {
// 对请求错误做些什么
return Promise.reject(error)
}
)
// 添加响应拦截器
instance.interceptors.response.use(
function (response) {
// 对响应数据做点什么
return response
},
function (error) {
// 对响应错误做点什么,例如处理 401 错误
if (error.response && error.response.status === 401) {
// 清除本地存储的 token,并跳转到登录页面
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
紧接着我们开始在 _request
中处理请求和响应拦截器的内容:我们先将请求和响应拦截器打印出来:
/**
*
* @param {string} url
* @param {Object} options
*/
_requiest(url, options) {
// 首先进行参数归一化,将所有的参数全部统一为一个配置对象
const config = mergeConfig(this.defaultConfig, this.requestHelper(url, options))
// 开始处理请求和响应拦截器的内容
console.log('获取到的请求和响应拦截器', this.interceptors)
// 调用 dispatchRequest 方法进行请求的发送和处理,并且将合并之后的配置传入
dispatchRequest(config)
}
我们可以看到请求和响应拦截器注册进来了。
开始进行拦截器和请求方法的任务编排:
所谓任务编排其实很简单,就是在底层维护一个任务队列来处理一系列任务,队列类似于下面这样:
[
请求拦截器1成功方法,
请求拦截器1失败方法,
请求方法,
undefined,
响应拦截器1成功方法,
响应拦截器1失败方法
]
然后从头到尾循环这个队列,每一次循环都取出当前队列的头两位,并且使用 Promise.then 将其注册为当前任务阶段成功和失败的处理函数。
此时浏览器主线程任务执行完毕之后会依次执行 Promise.then 注册的微任务。
理解了思路,代码实现就非常简单了:
/**
*
* @param {string} url
* @param {Object} options
*/
_requiest(url, options) {
// 首先进行参数归一化,将所有的参数全部统一为一个配置对象
const config = mergeConfig(this.defaultConfig, this.requestHelper(url, options))
// 开始处理请求和响应拦截器的内容
console.log('获取到的请求和响应拦截器', this.interceptors)
// 调用 dispatchRequest 方法进行请求的发送和处理,并且将合并之后的配置传入
const chain = [dispatchRequest, undefined]
// 开始进行任务编排
this.interceptors.request.handlers.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
})
this.interceptors.response.handlers.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected)
})
// 开始注册异步任务, 注意这里很重要,将 config 配置对象按照 Promise 链式调用的参数一直传递给后续的任务去处理
let promise = Promise.resolve(config)
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift())
}
}
调整 dispatchRequest 函数,调用适配器发送请求:
import adapate from './Adapte.js'
/**
*
* axios 统一进行 http 请求发送的模块
*
* @param {Object} config
*/
export default function dispatchRequest(config) {
// 利用适配器获取当前环境的请求 api
const adapter = adapate.getAdapter(config.adapter)
return adapter(config)
}
调整 xhr 适配器,简单返回一个结果:
// 首先判断当前环境是否存在 xhr 模块
const isXHRAdapterSupported = typeof XMLHttpRequest !== 'undefined'
export default isXHRAdapterSupported && function (config) {
return new Promise((resolved, rejected) => {
resolved('请求结果')
})
}
我们在测试代码中加入一些测试log:
instance.interceptors.request.use(
function (config) {
// 在发送请求之前做些什么,例如添加 token 到请求头
config.headers['Authorization'] = `Bearer ${localStorage.getItem('token')}`
console.log('请求拦截器', config)
return config;
},
function (error) {
// 对请求错误做些什么
return Promise.reject(error)
}
)
// 添加响应拦截器
instance.interceptors.response.use(
function (response) {
console.log('响应拦截器', response)
// 对响应数据做点什么
return response
},
function (error) {
// 对响应错误做点什么,例如处理 401 错误
if (error.response && error.response.status === 401) {
// 清除本地存储的 token,并跳转到登录页面
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
可以看到整个流程按照预期的执行了
至此我们就搞清楚了 axios 拦截器的设计哲学了。
5、axios 取消请求的实现方案
axios 取消请求的实现我们在这里就不一行一行的写代码了,我们直接去源码中一探究竟:
首先我们简单梳理一下 axios 的源码结构:
axios 的核心代码就分模块存在放 lib 目录下面:
lib目录下面几个核心文件夹的作用分别是:
adapters:适配器相关逻辑
cancel: 请求取消相关功能
core: axios 核心代码
Axios.js: Axios 核心类
dispatchRequest: 请求发送的核心模块,和我们手写的核心逻辑类似
helpers: 工具方法
回顾 axios 请求取消的使用方式(以 ### CancelToken
为例,其他方式大家自行查阅):
import axios from 'axios';
const source = axios.CancelToken.source();
axios.get('/api/data', {
cancelToken: source.token
})
.catch(thrown => { if (axios.isCancel(thrown)) { console.log('Request canceled', thrown.message); } else { // handle error } }); // 取消请求
source.cancel('取消请求的原因');
核心其实就是三步:
创建 CancelToken.source 对象 将该对象中的 token 配置到 cancelToken 上 在指定的时候调用 source.cancel 方法
我们先来看一下 CancelToken 这个的核心逻辑:
class CancelToken {
construcoer(sxsc) {
// 缓存改变 promise 状态的方法
let resolvePromise;
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});
// 注册异步微任务
this.promise.then(cancel => {
// 没有订阅任务,那么直接退出
if (!token._listeners) return;
let i = token._listeners.length;
// 依次执行订阅任务
while (i-- > 0) {
token._listeners[i](cancel);
}
token._listeners = null;
})
// 注册任务
subscribe(listener) {
if (this.reason) {
listener(this.reason);
return;
}
if (this._listeners) {
this._listeners.push(listener);
} else {
this._listeners = [listener];
}
}
// 执行回调方法
executor(function cancel(message, config, request) {
if (token.reason) {
// Cancellation has already been requested
return;
}
token.reason = new CanceledError(message, config, request);
resolvePromise(token.reason);
});
}
static source() {
let cancel;
const token = new CancelToken(function executor(c) {
cancel = c;
});
return {
token,
cancel
};
}
从以上的核心代码我们可以看出
cancelToken 本质上就是一个 CancelToken 的实例,因此我们可以调用 CancelToken 原型上的 subscribe 方法来注册一个异步任务。 CancelToke 内部通过 Promise 微任务的方式来管理了一个任务队列,任何通过 subscribe 注册的任务都会在该 Promise 对象的状态完成之后得到执行。 cancel 方法做的最核心的一件事情就是 resolvePromise(token.reason) 将上面聊到的 Promise 对象的状态变成已完成,从而将 subscribe 注册的任务全部推入微任务队列去进行执行。 CancelToken 底层实际上就是实现了一个典型的发布订阅模式,外部的模块可以通过 subscribe 方法来注册一系列时间,然后通过调用 cancel 执行这些事件。
梳理了 CancelToken 模块的实现逻辑,我们再来看请求取消的实现方案就很好理解了, 我们在创建 axios 请求任务的时候传入了 cancelToken: source.token 实际上就是把 CancelToken 对象传入到了配置中,然后 axios 内部会在某一个模块中通过 cancelToken.subscribe 的方法注册一个请求取消的事件,最后我们在需要的地方调用cannal方法触发事件的调用从而取消请求。
实际上我们查看 http 模块就可以看到在 http 模块中 axios 通过 cancelToken.subscrib 注册了请求取消事件:
另外如果我们查看 dispatchReques 源码,我们可以看到,如果我们进行了请求取消相关配置,在请求发布的每一个阶段,axios都会调用 throwIfCancellationRequested 方法来检查请求的取消状态,如果发现某一个阶段请求和已经取消了,那么这个阶段以及以后的额任务都不会继续执行了:
另外我们在扩展一个点,就是在浏览环境,我们可以使用 controller.signal; 方式来配置请求取消,这样的话,那么 axios 就真的可以利用这个 api 来真正的取消指定的 http 请求传输并且回收相关资源了。
至此我们就从提上了解完毕了 axios 中最核心的设计原理。
扩展:ts 中模板模式实现通用请求处理的方案:
在企业中我们目前一般使用 ts 来编写底层的库,而 ts 为我们提供了接口和类型更加强大的抽象类,基于这些高级语法,我们可以使用模板模式更好的实现跨平台的通用请求逻辑。总体实现方案如下:
比如:
我们现在需要封装一个通用的业务请求库,这个库底层的依赖的基础请求库可能是 axios,也可能是传统的 fetch,也可能是针对 Vue 框架的 VueRequest,也可能是针对react的 useSWR 等等,我们可能需要根据不同的业务场景进行灵活的切换。
但是在切换的过程中,我们可能希望做到在使用层面无感,使用层面的 api 统一。
比如在基于 axios 来进行请求的时候我们这样用:
import { request } from 'my-request'
request('xxx', {
...xxx
})
在基于 vueRequest 时候我们也希望可以直接这样使用:
import { request } from 'my-request'
request('xxx', {
...xxx
})
也就是这个库底层需要对用户完全屏蔽各种底层请求库存的差异,不管是基于什么样的底层请求库,api还是照样的调用,代码还是照样的写。为了实现这一点,我们就可以这样去进行设计:
暴露一个标准的 request 方法,不论在怎样的情况下,发送请求都是基于这一个统一的请求方法,并且存在统一侧参数类型。 暴露一个标准的 interface RequestHandler 接口,任何需要注册到请求库中的模块都需要按照标准统一的接口进行设计。 按照 RequestHandler 接口的约束来封装对应的请求模块。 request上暴露一个对外的 use 方法,用户调用这个方法可以将指定的请求模块进行注册。 用户需要将默认的请求库 AxiosFetch替换为指定的请求库使用指定的 请求库 VueRequestFetch的时候,只需要导入基于统一的接口封装的 VueRequestFetch 模块,然后调用 use 函数将该模块进行注册就可以了,其他的业务代码完全不需要替换。
request 库最核心的代码类似于下面这样:
import AxiosFetch from '@/xxx/AxiosFetch'
// 定义接口
export interface RequestHandler {
get(xxx) {
},
post(xxx) {
}
}
let useHandler: RequestHandler = AxiosFetch
export interface RequestOptions {
....
}
export const request(options: RequestOptions) {
// 利用 useHandler 处理请求
useHandler.get()
}
request.use = (handler: RequestHandler) {
// 注册
useHandler = handler
}
在使用的时候极其简单
import { request } from '@xxx/core'
import VueRequestFetch from '@xxx/VueRequestFetch'
// 注册自定义请求库
const { use } = request
use(VueRequestFetch)
request('xxx', {})
request.get('xxx', {})