项目中一直都有用到 Axios 作为网络请求工具,用它更要懂它,因此为了更好地发挥 Axios 在项目的价值,以及日后能够得心应手地使用它,笔者决定从源码层面好好欣赏一下它的美貌!
Axios是一款基于 Promise
并可用于浏览器和 Node.js
的网络请求库。
最近,Axios 官方文档终于变好看了,支持多语言切换,阅读更清晰,使用起来也更加舒适!作为一款受全球欢迎的网络请求库,有必要偷学一下其中的架构设计、编码方式。
本篇文章从源码层面主要分析 Axios 的功能实现、设计模式、以及分享 Axios 中一些笔者认为比较“精彩”的地方!
本文主要内容结构如下,大家按需食用:
本次分析的 Axios 版本是:v0.24.0
通过简单的浏览 package.json、文件及目录,可以得知 axios 工程采用了如下三方依赖:
名称 | 说明 |
---|---|
Grunt[1] | JavaScript 任务运行器 |
dtslint[2] | TypeScript 类型声明&样式校验工具 |
TypeScript[3] | 支持TS环境下开发 |
Webpack[4] | JavaScript 模块打包工具 |
karma[5] | 测试用例检查器 |
mocha[6] | 多功能的 JavaScript 测试框架 |
sinojs[7] | 提供spies, stub, mock,推荐文章《Sinon 入门,看这篇文章就够了[8]》 |
follow-redirects[9] | http(s)重定向,NodeJS模块 |
这里省略了对一些工具介绍,但可以发现,Axios 开发项目的主功能依赖并不多,换句话说是只有 follow-redirects
作为了“使用依赖”,其他都是编译、测试、框架层面的东西,可以看出官方团队在对于 Axios 有多么注质量和稳定性,毕竟是全球都在用的工具。
Axios 中相关代码都在 lib/
目录下(建议逐行阅读):
.
├── adapters // 网络请求,NodeJS 环境使用 NodeJS 的 http 模块,浏览器使用 XHR
│ ├── README.md
│ ├── http.js // Node.js 环境使用
│ └── xhr.js // 浏览器环境使用
├── helpers // 一些功能辅助工具函数,看文件名可基本知道干啥的
│ ├── README.md
│ ├── bind.js
│ ├── buildURL.js
│ ├── combineURLs.js
│ ├── cookies.js
│ ├── deprecatedMethod.js
│ ├── isAbsoluteURL.js
│ ├── isAxiosError.js
│ ├── isURLSameOrigin.js
│ ├── normalizeHeaderName.js
│ ├── parseHeaders.js
│ ├── spread.js
│ └── validator.js
├── cancel // 取消网络请求的处理
│ ├── Cancel.js // 取消请求
│ ├── CancelToken.js // 取消 Token
│ └── isCancel.js // 判断是否取消请求的函数方法
├── core // 核心功能
│ ├── Axios.js // Axios 对象
│ ├── InterceptorManager.js // 拦截器管理
│ ├── README.md
│ ├── buildFullPath.js // 构造完成的请求 URL
│ ├── createError.js // 创建错误,抛出异常
│ ├── dispatchRequest.js // 请求分发,用于区分调用 http 还是 xhr
│ ├── enhanceError.js // 增强错误???????????????
│ ├── mergeConfig.js // 合并配置参数
│ ├── settle.js // 根据请求响应状态,改变 Promise 状态
│ └── transformData.js // 数据格式转换
├── env // 无关紧要,没啥用,与发包版本有关
│ ├── README.md
│ └── data.js
├── defaults.js // 默认参数/初始化参数配置
├── utils.js // 提供简单的通用的工具函数
└── axios.js // 入口文件,初始化并导出 axios 对象
有了一个简单的代码功能组织架构熟悉后,对于串联 Axios 的功能很有好处,另外,从上述文件和文件夹的命名,很容易让人意识到这是一个什么功能的文件。
“高内聚、低耦合”的真言,在 Axios 中应该算是一个运用得很好的例子。
梳理了一张 Axios 发起请求、响应请求的执行流程图,希望可以给大家一个完整流程的概念,便于理解后续的源码分析。
Axios 网络请求流程图
我们在使用 Axios 的时候,会觉得 Axios 的使用特别方便,其原因就是 Axios 中针对同一功能实现了不同的 API,便于大家在各种场景下的变通扩展使用。
例如,发起一个 GET 请求的写法有:
// 第一种
axios('https://xxx.com/api/userInfo?uid=1')
// 第二种
axios.get('https://xxx.com/api/userInfo?uid=1')
// 第三种
axios({
method: 'GET',
url: 'https://xxx.com/api/userInfo?uid=1'
})
Axios 请求的核心方法仅两种:
axios(config)
// or
axios(url[, config])
我们知道一个网络请求的方式会有 GET、POST、PUT、DELETE 等,为了使用更加语义化,Axios 对外暴露了别名 API:
axios.request(config)
axios.get(url[, config])
axios.delete(url[, config])
axios.head(url[, config])
axios.options(url[, config])
axios.post(url[, data[, config]])
axios.put(url[, data[, config]])
axios.patch(url[, data[, config]])
通过遍历扩展axios对象原型链上的方法:
// Provide aliases for supported request methods
utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
/*eslint func-names:0*/
Axios.prototype[method] = function(url, config) {
return this.request(mergeConfig(config || {}, {
method: method,
url: url,
data: (config || {}).data
}));
};
});
utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
/*eslint func-names:0*/
Axios.prototype[method] = function(url, data, config) {
return this.request(mergeConfig(config || {}, {
method: method,
url: url,
data: data
}));
};
});
能够如上的直接循环列表赋值,得益于 Axios 将核心的请求功能单独放到了 Axios.prototype.request
方法中,该方法的 TS 定义为:
Axios.request(config: any, ...args: any[]): any
在其方法(Axios.request()
)内会对外部传参数类型做判断,并选择组装正确的请求参数:
// 生成规范的 config,抹平 API(函数入参)差异
if (typeof config === 'string') {
// 处理了第一个参数是 url 字符串的情况 request(url[, config])
config = arguments[1] || {};
config.url = arguments[0];
} else {
config = config || {};
}
// 合并默认配置
config = mergeConfig(this.defaults, config);
// 将请求方法转小写字母,默认为 get 方法
if (config.method) {
config.method = config.method.toLowerCase();
} else if (this.defaults.method) {
config.method = this.defaults.method.toLowerCase();
} else {
config.method = 'get';
}
以此来抹平了各种类型请求以及所需传入参数之间的差异性!
默认 Axios 导出了一个单例,导出了一个实例化后的单例,所以我们可以直接引入后就可以调用 Axios 的方法。
在某些场景下,我们的项目中可能对接了多个业务方,那么请求中的 base URL
就不一样,因此有没有办法创建多个 Axios 实例?
那就是使用 axios.create([config])
方法创建多个实例。
考虑到多实例这样的实际需求,Axios 对外暴露了 create()
方法,在 Axios 内部中,往导出的 axios 实例上绑定了用于创建本身实例的工厂方法:
/**
* Create an instance of Axios
*
* @param {Object} defaultConfig The default config for the instance
* @return {Axios} A new instance of Axios
*/
function createInstance(defaultConfig) {
var context = new Axios(defaultConfig);
var instance = bind(Axios.prototype.request, context);
// Copy axios.prototype to instance
utils.extend(instance, Axios.prototype, context);
// Copy context to instance
utils.extend(instance, context);
// Factory for creating new instances
instance.create = function create(instanceConfig) {
return createInstance(mergeConfig(defaultConfig, instanceConfig));
};
return instance;
}
这里的实现值得一说的地方在于:
instance.create = function create(instanceConfig) {
return createInstance(mergeConfig(defaultConfig, instanceConfig));
};
在创建 axios 实例的工厂方法内,绑定工厂方法到实例的 create
属性上。为什么不是在工厂方法外绑定呐?这是我们可能的习惯做法,Axios 之前确实也是这么做的。
为什么挪到了内部?可以看看这条 PR: Allow axios.create(options) to be used recursively[10]
原因简单来说就是,用户自己创建的实例依然可以调用 create
方法创建新的实例,例如:
const axios = require('axios');
const jsonClient = axios.create({
responseType: 'json' // 该项配置可以在后续创建的实例中复用,而不必重复编码
});
const serviceOne = jsonClient.create({
baseURL: 'https://service.one/'
});
const serviceTwo = jsonClient.create({
baseURL: 'https://service.two/'
});
这样有助于复用实例的公共参数复用,减少重复编码。
在文件 ./defaults.js
中生成了默认完整的 Request Config
参数。
其中 config.adapter
字段表明当前应该使用 ./adapters/
目录下的 http.js
还是 xhr.js
模块
// 根据当前使用环境,选择使用的网络请求适配器
function getDefaultAdapter() {
var adapter;
if (typeof XMLHttpRequest !== 'undefined') {
// For browsers use XHR adapter
adapter = require('./adapters/xhr');
} else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
// For node use HTTP adapter
adapter = require('./adapters/http');
}
return adapter;
}
这里使用了设计模式中的适配器模式,通过判断不同环境下是否支持方法的方式,选择正确的网络请求模块,便可以实现官网所说的支持 NodeJS
和浏览器环境。
这是 Axios 贴在官网的核心功能之一,且提到了可以自动转换响应体内容为 JSON 数据
默认请求配置中初始化的请求/响应转换器数组
自动尝试转换响应数据为 JSON 格式
transformRequest
和 transformResponse
字段是一个数组类型,因此我们还可以向其中增加自定义的转换器。
一般来讲我们只会通过复写 transitional
字段来控制响应数据的转换与否,但可以作为扩展 Axios 的一个点,留了口子,这一点考虑得也很到位。
可以通过拦截器来提前处理请求前和收到响应前的一些处理方法。
拦截器用于在 .then()
和 .catch()
前注入并执行的一些方法。
// 通过 use 方法,添加一个请求拦截器
axios.interceptors.request.use(function (config) {
// 在发送请求前干点啥,.then() 处理之前,比如修改 request config
return config;
}, function (error) {
// 在发起请求发生错误后,.catch() 处理之前干点啥
return Promise.reject(error);
});
// 通过 use 方法,添加一个响应拦截器
axios.interceptors.response.use(function (response) {
// 只要响应网络状态码是 2xx 的都会触发
// 干点啥
return response;
}, function (error) {
// 状态码不是 2xx 的会触发
// 发生错误了,干点啥
return Promise.reject(error);
});
Axios 将请求和响应的过程包装成了 Promise,那么 Axios 是如何实现拦截器在 .then()
和 .catch()
执行前执行呐?
可以很容易猜到通过组装一条 Promise 执行链即可!
来看看 Axios 在请求函数中如何实现:
首先是 Axios 对象中初始化了 拦截管理器:
function Axios(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
来到 ./lib/core/InterceptorManager.js
文件下,对于拦截管理器
// 拦截管理器对象
function InterceptorManager() {
this.handlers = [];
}
/**
* 添加新的管理器,定义了 use 方法
*
* @param {Function} fulfilled 处理 `Promise` 执行 `then` 的函数方法
* @param {Function} rejected 处理 `Promise` 执行 `reject` 的函数方法
*
* @return {Number} 返回一个 ID 值用于移除拦截器
*/
InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected,
// 默认不同步
synchronous: options ? options.synchronous : false,
// 定义是否执行当前拦截器的函数或布尔值
runWhen: options ? options.runWhen : null
});
return this.handlers.length - 1; // ID 值实际就是当前拦截器的数组索引
};
/**
* 从栈中移除指定 id 的拦截器
*
* @param {Number} id use 方法返回的 id 值
*/
InterceptorManager.prototype.eject = function eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 删除拦截器,但索引会保留
}
};
/**
* 迭代所有注册的拦截器
* 该方法会跳过因拦截器被删除而值为 null 的索引
*
* @param {Function} 调用每个有效拦截器的函数
*/
InterceptorManager.prototype.forEach = function forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) {
fn(h);
}
});
};
迭代所有注册的拦截器是一个 FIFS(first come first served
,先到先服务)队列执行顺序的方法。
在 ./lib/core/Axios.js
文件中,Axios 对象定义了 request
方法,其中将网络请求、请求拦截器和响应拦截器组装。
默认返回一个还未执行网络请求的 Promise 执行链,如果设置了同步,则会立即执行请求过程,并返回请求结果的 Promise 对象,也就是官方文档中提到的 Axios 还支持 Promise API。
函数详细的分析,都已经注释在如下代码中:
/**
* Dispatch a request
*
* @param {Object} config 传入的用户自定义配置,并和默认配置 merge
*/
Axios.prototype.request = function request(config) {
// 省略 ...
// 请求拦截器执行链
var requestInterceptorChain = [];
// 同步请求拦截器
var synchronousRequestInterceptors = true;
// 遍历请求拦截器
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
// 判断 runWhen 如果是函数,则执行函数,结果若为 false,则不执行当前拦截器
if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) {
return;
}
// 判断当前拦截器是否同步
synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous;
// 插入 requestInterceptorChain 数组首位
// 效果:[interceptor.fulfilled, interceptor.rejected, ...]
requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});
// 响应拦截器执行链
var responseInterceptorChain = [];
// 遍历所有的响应拦截器
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
// 插入 responseInterceptorChain 尾部
// 效果:[ ..., interceptor.fulfilled, interceptor.rejected]
responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
});
var promise;
// 如果非同步
// 一般大家在使用 axios.interceptors.request.use 都没有传递第三个配置参数
// 所以一般情况下会走这个逻辑
if (!synchronousRequestInterceptors) {
var chain = [dispatchRequest, undefined];
// 将请求拦截器执行链放到 chain 数组头部
Array.prototype.unshift.apply(chain, requestInterceptorChain);
// 将响应拦截器执行链放到 chain 数组末尾
chain = chain.concat(responseInterceptorChain);
// 给 promise 赋值 Promise 对象,并注入 request config
promise = Promise.resolve(config);
// 循环 chain 数组,组合成 Promise 执行链
while (chain.length) {
// 正好 resolve 和 reject 对应方法,两两一组
promise = promise.then(chain.shift(), chain.shift());
}
// 返回 Promise 执行链
return promise;
}
// 同步方式
var newConfig = config;
// 循环并执行所有请求拦截器
while (requestInterceptorChain.length) {
var onFulfilled = requestInterceptorChain.shift();
var onRejected = requestInterceptorChain.shift();
try {
// 执行定义请求前的“请求拦截器” then 处理方法
newConfig = onFulfilled(newConfig);
} catch (error) {
// 执行定义请求前的“请求拦截器” catch 处理方法
onRejected(error);
break;
}
}
try {
// 执行网络请求
promise = dispatchRequest(newConfig);
} catch (error) {
return Promise.reject(error);
}
// 循环并执行所有响应拦截器
while (responseInterceptorChain.length) {
promise = promise.then(responseInterceptorChain.shift(), responseInterceptorChain.shift());
}
// 返回 Promise 对象
return promise;
};
可以看到由于请求拦截器和响应拦截器使用了 unshift
和 push
,那么 use
拦截器的先后顺序就有变动。
通过如上代码的分析,可以得知若有多个拦截器的执行顺序规则是:
关于拦截器执行这部分,涉及到一个 PR改动: Requests unexpectedly delayed due to an axios internal promise[11],推荐大家阅读一下,有助于熟悉微任务和宏任务。
改动的原因:如果请求拦截器中存在一些长时间的任务,会使得使用 axios 的网络请相较于不使用 axios 的网络请求会延后,为此,通过为拦截管理器增加 synchronous
和 runWhen
字段,来实现同步执行请求方法。
在网络请求中,会遇到许多非预期的请求取消,当然也有主动取消请求的时候,例如,用户获取 id=1
的新闻数据,需要耗时 30s,用户等不及了,就返回查看 id=2
的新闻详情,此时我们可以在代码中主动取消 id=1
的网络请求,节省网络资源。
通过 CancleToken.source()
工厂方法创建取消请求的实例 source
在发起请求的 request Config 中设置 cancelToken
值为 source.token
在需要主动取消请求的地方调用:source.cancle()
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get('/user/12345', {
cancelToken: source.token
}).catch(function (thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// handle error
}
});
axios.post('/user/12345', {
name: 'new name'
}, {
cancelToken: source.token
})
// 主动取消请求 (提示信息是可选的参数)
source.cancel('Operation canceled by the user.');
同一个 source 实例调用取消 cancle()
方法时,会取消所有含有当前实例 source.token
的请求
想必大家也很好奇是怎么实现取消网络请求功能的,实际上有了上述的基础,把 Axios 的请求想象成为一条事件执行链,执行链中任意一处发生了异常,都会中断整个请求。
整个请求执行链中的设计了,首先来看:axios.CancelToken.source()
:
/**
* Returns an object that contains a new `CancelToken` and a function that, when called,
* cancels the `CancelToken`.
*/
CancelToken.source = function source() {
var cancel;
var token = new CancelToken(function executor(c) {
cancel = c;
});
return {
token: token,
cancel: cancel
};
};
该工厂方法返回了一个对象,该对象包含了一个 token
(取消令牌,CancleToken
对象的实例),以及一个取消与 token
映射绑定的取消请求方法 cancle()
其中 new CancelToken()
会创建 CancleToken
的单例,通过传入函数方式,拿到了取消请求的回调函数,该函数内会构造 token 取消的原因,并通过执行 resolvePromise()
,主动 reslove。
同样是一个微任务,当主动调用 cancle()
方法后,会调用 resolvePromise(reason)
,此时就会给当前 cancleToken
实例的 reason
字段赋值“请求取消的原因”:
function CancelToken(executor) {
if (typeof executor !== 'function') {
throw new TypeError('executor must be a function.');
}
// 初始化一个 promise 属性,resolvePromise 变量指向 resolve
var resolvePromise;
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});
// 赋值 token 为当前对象的实例
var token = this;
// 省略...
// 执行外部传入的初始化方法,将取消请求的方法,赋值给返回对象的 cancel 属性
executor(function cancel(message) {
if (token.reason) {
// Cancellation has already been requested
return;
}
token.reason = new Cancel(message);
resolvePromise(token.reason);
});
}
在 ./lib/core/dispatchRequest.js
文件中:
function throwIfCancellationRequested(config) {
// 当 request config 中有实例化 cancelToken 时
// 执行 throwIfRequested() 方法
// throwIfRequested() 方法在 cancleToken 实例的 reason 字段有值时
// 抛出异常
if (config.cancelToken) {
config.cancelToken.throwIfRequested();
}
// 判断 config.signal.aborted 值为真的时候抛出异常
// 该值时通过 new AbortController().signal,不过目前暂时未用到
// 官方文档上暂也暂未更新相关内容
if (config.signal && config.signal.aborted) {
throw new Cancel('canceled');
}
}
module.exports = function dispatchRequest(config) {
// 准备发起请求前检查
throwIfCancellationRequested(config);
// 省略...
var adapter = config.adapter || defaults.adapter;
return adapter(config).then(function onAdapterResolution(response) {
// 请求成功后检查
throwIfCancellationRequested(config);
// 省略...
return response;
}, function onAdapterRejection(reason) {
if (!isCancel(reason)) {
// 请求发生错误时候检查
throwIfCancellationRequested(config);
// 省略...
}
// 省略...
return Promise.reject(reason);
});
}
在文章前边分析拦截器的时候讲到了 dispatchRequest()
在请求拦截器之后执行。
在请求前,请求成功、失败后三个时机点,都会通过 throwIfCancellationRequested()
函数检查是否取消了请求,throwIfCancellationRequested()
函数判断了 cancleToken.reason 是否有值,如果有则抛出异常并中断请求 Promise 执行链。
Axios 支持防御 CSRF(Cross-site request forgery
,跨站请求伪造)攻击,而防御 CSRF 攻击的最简单方式就是加 Token。
CSRF 的攻击可以简述为:服务器错把攻击者的请求当成了正常用户的请求。
加一个 Token 为什么就能解决呐?首先 Token 是服务端随用户每次请求动态生成下发的,用户在提交表单、查询数据等行为的时候,需要在网络请求体加上这个临时性的 Token 值,攻击者无法在三方网站中获取当前 Token,因此服务端就可以通过验证 Token 来区分是否是正常用户的请求。
Axios 在请求配置中提供了两个字段:
// cookie 中携带的 Token 名称,通过该名称可以从 cookie 中拿到 Token 值
xsrfCookieName: 'XSRF-TOKEN',
// 请求 Header 中携带的 Token 名称,通过该成名可从 Header 中拿到 Token 值
xsrfHeaderName: 'X-XSRF-TOKEN',
用于附加验证防御 CSRF 攻击的 Token。
在 Axios 内,没有引入其他例如 lodash 的工具函数依赖,都在自己内部按需实现了工具函数,提供给整个项目使用。
个人非常喜欢这种做法,尤其是在一个 ES5 的工具库下,这样做不仅代码易读,与此同时还显得非常得纯粹、干净、清晰!
如果团队内有这种诉求,建议可以写一个 ESM 模块的工具库,这样做以后,在打包 Tree Shaking 时,打包的结果应该能更加干净。
总体来说,Axios 涉及到的设计模式就有:单例模式、工厂模式、职责链模式、适配器模式,因此绝对是值得学习的一个工具库,梳理之后不仅利于我们灵活使用其 API,更有助于根据业务去自定义扩展封装网络请求,将网络请求统一收口。
与此同时,Axios 绝对是一个可以作为软件工程编码的学习范本,其中的文件夹结构,功能设计,功能解耦,按需封装工具类,以及灵活运用设计模式都是值得揣度回味。
[1] Grunt: https://gruntjs.com/
[2] dtslint: https://www.npmjs.com/package/dtslint
[3] TypeScript: https://github.com/Microsoft/TypeScript
[4] Webpack: https://www.webpackjs.com/
[5] karma: https://github.com/crazygit/karma-intro
[6] Mocha: https://mochajs.org/
[7] SINON.JS: https://sinonjs.org/
[8] Sinon 入门,看这篇文章就够了: https://segmentfault.com/a/1190000010372634
[9] follow-redirects: https://github.com/follow-redirects/follow-redirects
[10] Allow axios.create(options) to be used recursively: https://github.com/axios/axios/pull/2795
[11] Requests unexpectedly delayed due to an axios internal promise: https://github.com/axios/axios/issues/2609