【axios源码】封装ajax请求
文本主要涉及到axios封装ajax请求的方法,首先从自己封装一个ajax请求会遇到的困难出发,然后看axios源码里面是怎么解决这些问题的。第一节简单介绍了ajax,第二节描述了一下我自己在封装ajax请求遇到的问题,尽管问题考虑的也没有axios完整。第三节开始分析axios源码,由于源码不能按顺序分解,因此从它解决了什么问题出发去分析,所以感觉更乱了,建议看这一节时对着源码加上注释。最后,还有很多地方与这个位置相关的处理不在这个函数里面,所有漏了一些数据处理部分没讲。希望对读者能有一点启发去实现自己的封装。本文并不适合观看,因为表达能力有限然而东西太多,所以需要配合源码慢慢的看。
一、预备知识
1.1 什么是ajax
1.2 一次最简单的原生ajax请求代码例子
function request(url,data,headers,method){
var xhr = new XMLHttpRequest();
xhr.onreadystatechange=function(){
if (xhr.readyState==4 && xhr.status == 200){
//TODO::
}
}
xhr.open(method,url,true);
xhr.send(data);
}
1.3 简单的流程
readyState和status参考以下文章https://www.cnblogs.com/liu-fei-fei/p/5618782.html
一次请求二、封装ajax请求会遇到哪些难题
2.1 HTTP请求参数
首先从发送http请求出发,我们发送http请求都有哪些参数?请求地址url、请求方法method、请求头headers、请求成功的回调函数、请求失败的回调函数、请求取消的回调函数
、请求超时的回调函数
、上传进度回调函数
、下载进度回调函数
。红字表示简单的请求可能不会用到,但是考虑完善的话这些都是需要处理的。比如像下面代码这样将其作为参数都传递出去,这样也可以,但是现在应该很少有人会这么做了,对于这种异步函数,我们应该用promise代码回调,会让代码看起来更舒适,这也是axios的选择
function request(url,method,headers,success,error,cancel,timeout,upload,download){
}
2.2 问题(不完全,真实情况比以下复杂的多)
- 如果请求需要Authorization验证怎么办?
- url输入不规范怎么处理?主要体现在绝对地址和相对地址上面。比如http://localhost/test 和 /test
- get请求的参数怎么拼接?
- 请求完毕了该返回什么?正常的请求可以直接返回,异常的请求(500警告)该返回什么错误?
- 请求出错、请求取消、请求超时该怎么返回?
- Cookies怎么处理?
- 跨域请求怎么办?
三、源码分析(axios如何封装ajax请求)
这个也是我们可以学习到的地方。以下分析并没有按照代码的顺序
3.1 用promise的形式代替回调
如果用过JQuery,应该会了解JQuery的Ajax。下面给出代码对比一下两种调用方式:
//jq
$.ajax({
url:'',
success:function(data){},
error:function(err){}
})
//axios
axios.post('/user')
.then(function (response) {})
.catch(function (error) {});
那么孰优孰劣呢?其实就是在问回调函数和promise哪个好?具体还是看场景,jquery好像ajax也改成promise的形式了。(用async await这个主意怎么样)
我们有5个地方需要返回,4个地方是异常返回,一个地方是成功的返回。只需要在对应的位置调用resolve或者reject函数就可以了,这样做的好处在于这个函数可以在其他函数里面调用。因此这个部分的函数会是下面这样一个结构,config代表请求需要的所有参数。
function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
}
};
如果再完善一点就是下面这个,最后会给出源码,下面这个只是随便写的。
function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
var {url,data,headers,method} = config
var xhr = new XMLHttpRequest();
xhr.onreadystatechange=function(){
if (xhr.readyState!=4){
return
}
if(xhr.status >= 200 && xhr.status < 300){
//TODO::
resolve(xhr.responseText)
}else{
reject("请求异常")
}
}
xhr.onabort = function(){reject("请求取消")}
xhr.onerror = function(){reject("请求出错")}
xhr.ontimeout = function(){reject("请求超时")}
xhr.open(method,url,true);
xhr.send(data);
}
};
3.2 Authorization 验证
什么是Authorization
如果参数包含了验证信息,那么取出username和password拼成一个字符串。btoa函数是创建一个base64编码的字符串。
if (config.auth) {
var username = config.auth.username || '';
var password = config.auth.password || '';
requestHeaders.Authorization = 'Basic ' + btoa(username + ':' + password);
}
3.3 使用不同的变量区分不同请求发送的数据
axios用了params和data区别get请求和post请求时传递的数据。下面贴出相关代码
var requestData = config.data;
//中间省略很多代码
var fullPath = buildFullPath(config.baseURL, config.url);
request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);
//中间省略很多代码
if (requestData === undefined) {
requestData = null;
}
request.send(requestData);
- params在open函数调用时会和基础的url拼接成一个完整的url
- data如果不为undefined就会被发送出去,即使是GET请求
3.4 拼接fullpath考虑各个地址的边界问题
baseURL为基础地址,一般设置是域名,这样就可以通过相对地址访问接口,不用每次都把域名加上去。如果baseURL和url分为取如下值,那么很容易出现边界问题,也就是斜杠问题。
baseURL = "httpl://localhost:8080/"
url = "/test"
在代码中需要包容这样的值,这个功能主要在buildFullPath函数中实现
function isAbsoluteURL(url) {
// A URL is considered absolute if it begins with "<scheme>://" or "//" (protocol-relative URL).
// RFC 3986 defines scheme name as a sequence of characters beginning with a letter and followed
// by any combination of letters, digits, plus, period, or hyphen.
return /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url);
};
function combineURLs(baseURL, relativeURL) {
return relativeURL
? baseURL.replace(/\/+$/, '') + '/' + relativeURL.replace(/^\/+/, '')
: baseURL;
};
function buildFullPath(baseURL, requestedURL) {
if (baseURL && !isAbsoluteURL(requestedURL)) {
return combineURLs(baseURL, requestedURL);
}
return requestedURL;
};
它的处理逻辑如下:
1.如果没有设置baseURL或者requestedURL是绝对地址,那么直接使用requestedURL。
2.否则将baseURL和requestedURL作为最终的地址
问题1:什么是absoluteURL?
从注释可以看出如果URL以“ <scheme>://”或“ //”(协议相对URL)开头,则被认为是absoluteURL。但是通过isAbsoluteURL函数可以发现它的判断是/^([a-z][a-z\d\+\-\.]*:)?\/\//i
也就是说以下3种情况都是绝对地址
- http://localhost
- //localhost
- scheme://xxx
问题2:如何拼接?
如果relativeURL为空则取baseURL的值。否则取baseURL.replace(//+$/, '') + '/' + relativeURL.replace(/^/+/, '')的值。主要的处理在replace的处理,对baseURL的处理是删除字符串末尾的一个或者多个斜杠,对relativeURL的处理是删除字符串开头的一个或者多个斜杠。
3.5 params拼接考虑很多可能出现的情况
这个部分完整代码在函数buildURL中
function buildURL(url, params, paramsSerializer) {
/*eslint no-param-reassign:0*/
if (!params) {
return url;
}
var serializedParams;
if (paramsSerializer) {
serializedParams = paramsSerializer(params);
} else if (utils.isURLSearchParams(params)) {
serializedParams = params.toString();
} else {
var parts = [];
utils.forEach(params, function serialize(val, key) {
if (val === null || typeof val === 'undefined') {
return;
}
if (utils.isArray(val)) {
key = key + '[]';
} else {
val = [val];
}
utils.forEach(val, function parseValue(v) {
if (utils.isDate(v)) {
v = v.toISOString();
} else if (utils.isObject(v)) {
v = JSON.stringify(v);
}
parts.push(encode(key) + '=' + encode(v));
});
});
serializedParams = parts.join('&');
}
if (serializedParams) {
var hashmarkIndex = url.indexOf('#');
if (hashmarkIndex !== -1) {
url = url.slice(0, hashmarkIndex);
}
url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams;
}
return url;
};
首先看函数参数,url是fullpath,它是可以直接放入open函数中的结果。params一般是object类型,具体还得初始化的时候传递的什么。paramsSerializer是params序列化的方式,也是axios初始化的时候传递的,他没有默认值,也不是必须的。函数的params序列化部分处理逻辑如下:
- 如果有自定义的序列化函数就用自定义的序列化函数(应该用的较少吧)
- 如果是URLSearchParams类型的参数,就直接toString。(看了代码才知道还有URLSearchParams这个东西)
- 以上两种都不是采用默认处理逻辑。默认处理方式是下面的代码:
1.如果值为null则跳过,因为这样数据无法拼接没有意义
if (val === null || typeof val === 'undefined') {
return;
}
2.如果值为数组类型,则按数组类型处理,将key变成key[]的形式,否则强行把值变成数组类型。
if (utils.isArray(val)) {
key = key + '[]';
} else {
val = [val];
}
首先了解一件事情如何在GET请求中传递数组参数。它需要将key变成key[]的形式,其次为什么不是数组的时候强行将值变成数组,这些都是为了处理数组的类型。当传递的参数是数组时,需要做出如下处理:
params = {
a:[1,2,3]
}
url = "a[]=1&a[]=2&a[]=3"
这种处理方法中params中只有一个key但是url中却是多个key,因此接下来val必须是一个数组,这是考虑了参数是组数时的情况。
3.对象类型以及时间类型处理
utils.forEach(val, function parseValue(v) {
if (utils.isDate(v)) {
v = v.toISOString();
} else if (utils.isObject(v)) {
v = JSON.stringify(v);
}
parts.push(encode(key) + '=' + encode(v));
});
4.对key和value编码使用encode函数。个人感觉encodeURIComponent足够了,后面加上一大堆的替换应该不影响结果吧。下面有一点需要注意的是空格被编码成了%20,但是下面是替换成了+,因为url中不允许包含空格。最后 escape、encodeURI和encodeURIComponent的区别
function encode(val) {
return encodeURIComponent(val).
replace(/%40/gi, '@').
replace(/%3A/gi, ':').
replace(/%24/g, '$').
replace(/%2C/gi, ',').
replace(/%20/g, '+').
replace(/%5B/gi, '[').
replace(/%5D/gi, ']');
}
然后是hash处理,去除url中#以及其后的部分,这个部分对后端请求是毫无意义的,只有前端框架的router才会用到。最后url+=后面也是十分复杂,这是为了兼容url本身就是包含参数的同时还有params也有的情况。
if (serializedParams) {
var hashmarkIndex = url.indexOf('#');
if (hashmarkIndex !== -1) {
url = url.slice(0, hashmarkIndex);
}
url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams;
}
3.6 headers设置考虑data对content-type的影响
content-type这个请求头是客户端告诉服务器实际发送的数据类型,具体可选值参考https://www.runoob.com/http/http-content-type.html
与他相关的代码如下
if (utils.isFormData(requestData)) {
delete requestHeaders['Content-Type']; // Let the browser set it
}
if ('setRequestHeader' in request) {
utils.forEach(requestHeaders, function setRequestHeader(val, key) {
if (typeof requestData === 'undefined' && key.toLowerCase() === 'content-type') {
// Remove Content-Type if data is undefined
delete requestHeaders[key];
} else {
// Otherwise add header to the request
request.setRequestHeader(key, val);
}
});
}
这段代码是删除Content-Type属性的条件,删除是为了让浏览器自己决定content-type。具体条件有2个,一是data为表单数据,二是data没有值。
3.7 自己定义异常类区分不同的情况(Timeout、Error、Cancel)
// Handle browser request cancellation (as opposed to a manual cancellation)
request.onabort = function handleAbort() {
if (!request) {
return;
}
reject(createError('Request aborted', config, 'ECONNABORTED', request));
// Clean up request
request = null;
};
// Handle low level network errors
request.onerror = function handleError() {
// Real errors are hidden from us by the browser
// onerror should only fire if it's a network error
reject(createError('Network Error', config, null, request));
// Clean up request
request = null;
};
// Set the request timeout in MS
request.timeout = config.timeout;
// Handle timeout
request.ontimeout = function handleTimeout() {
var timeoutErrorMessage = 'timeout of ' + config.timeout + 'ms exceeded';
if (config.timeoutErrorMessage) {
timeoutErrorMessage = config.timeoutErrorMessage;
}
reject(createError(timeoutErrorMessage, config, 'ECONNABORTED',
request));
// Clean up request
request = null;
};
这段代码主要作用是在事件触发后抛出一个异常,onabort 是请求取消事件,onerror是请求出错事件,ontimeout 是请求超时事件,同时也可以设置timeout属性规定超时时间。
什么时候会触发onerror我也不知道,但是从以上代码可以看出是网络错误触发的。MDN也没有说清楚https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/error_event。这些事件具体执行功能只是reject一个异常,向后传递。然后设置request = null;设置request为null应该是为了主动取消请求准备的,因为取消请求事件不能在请求完成之后触发,而这三个事件触发恰好代表请求完成了。而且请求完成了也不能取消,所以onabort有一个判断。最后看一下createError函数,看一下一个error返回需要包含哪些信息。
function enhanceError(error, config, code, request, response) {
error.config = config;
if (code) {
error.code = code;
}
error.request = request;
error.response = response;
error.isAxiosError = true;
error.toJSON = function() {
return {
// Standard
message: this.message,
name: this.name,
// Microsoft
description: this.description,
number: this.number,
// Mozilla
fileName: this.fileName,
lineNumber: this.lineNumber,
columnNumber: this.columnNumber,
stack: this.stack,
// Axios
config: this.config,
code: this.code
};
};
return error;
};
function createError(message, config, code, request, response) {
var error = new Error(message);
return enhanceError(error, config, code, request, response);
};
从上面的代码可以看出它仅仅是对基础error的一个扩展,并不是class TimeoutError extends Error这样子,想了想也没必要太麻烦吧。
3.8 主动取消请求的实现(https://www.jianshu.com/p/e954b9894a51)
主动取消请求需要执行语句request.abort(),这个request肯定不能暴露出来,所以axios对其进行了封装。先回顾axios中取消请求的用法:
axios({
...
cancelToken: new CancelToken(function (cancel) {
cancel()
})
...
})
他要求传递一个cancelToken的值,并且这个值是一个CancelToken的实例,当cancel函数执行的时候,请求就取消了。下面是对ajax封装时与其相关的代码:
if (config.cancelToken) {
// Handle cancellation
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) {
return;
}
request.abort();
reject(cancel);
// Clean up request
request = null;
});
}
这个部分涉及到的位置有点多,专门写一篇来讲解吧
3.9 上传下载进度简单处理
上传下载的进度位置唯一要注意的应该是浏览器的兼容情况
// Handle progress if needed
if (typeof config.onDownloadProgress === 'function') {
request.addEventListener('progress', config.onDownloadProgress);
}
// Not all browsers support upload events
if (typeof config.onUploadProgress === 'function' && request.upload) {
request.upload.addEventListener('progress', config.onUploadProgress);
}
3.10 返回数据的处理
axios还允许强制修改responseType
if (config.responseType) {
try {
request.responseType = config.responseType;
} catch (e) {
// Expected DOMException thrown by browsers not compatible XMLHttpRequest Level 2.
// But, this can be suppressed for 'json' type as it can be parsed by default 'transformResponse' function.
if (config.responseType !== 'json') {
throw e;
}
}
}
通过查看MDN,如果你设置的responseType有问题还可能出现异常,所以这里加一个try catch,正常来说设置成JSON这里应该是不会出现异常的,可能跟浏览器版本有关系吧。除此之外返回数据还有专门的代码进行处理(string转json)这个也是抛出异常如果发现是json就放过的原因吧。
function transformResponse(data) {
/*eslint no-param-reassign:0*/
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) { /* Ignore */ }
}
return data;
}
responseType兼容性
3.11 跨域以及cookie相关的一些处理
// Add xsrf header
// This is only done if running in a standard browser environment.
// Specifically not if we're in a web worker, or react-native.
if (utils.isStandardBrowserEnv()) {
var cookies = require('./../helpers/cookies');
// Add xsrf header
var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
cookies.read(config.xsrfCookieName) :
undefined;
if (xsrfValue) {
requestHeaders[config.xsrfHeaderName] = xsrfValue;
}
}
// Add withCredentials to request if needed
if (!utils.isUndefined(config.withCredentials)) {
request.withCredentials = !!config.withCredentials;
}
读懂这段代码首先需要明白一件事情:withCredentials有什么用?它是一个布尔值,默认为false,如果为true表示在跨域请求时前端授权后端获取cookie。
这段代码首先判断时浏览器环境,然后初始化cookie(这里是对cookie操作的一个封装,write、read、remove,并且要求支持document.cookie才能正常使用,就不把具体代码贴出来了)。如果withCredentials 为true(前端允许跨域)或者是同源你那么就取出cookie里面保存的值,再设置请求头,这里设置的请求头key是在初始化的时候确定的,值是存在cookie中的,而且只能设置一个,猜测应该是Token。
最后一个if里面经典双感叹号强制转bool类型
四、测试(因为对HTTP协议以及浏览器怎么处理这方面不是很了解,所以测试一下以下。前端使用原生ajax,后端使用nodejs的http模块,就不贴测试时的代码了)
4.1 data和params都有值分别使用post请求和get请求后端能否接收到get参数和post参数
- GET请求:需要自己从URL中解析出params参数。但是无法获取data的值。可能是GET请求时浏览器根本不会发送data数据,所以无论axios中在GET请求时是否有值都不会影响结果。
- POST请求:需要自己从URL中解析出params参数。也能获取到data参数。
4.2 分别使用post请求和get请求测试data为null和有值时但是设置content-type分别为application/json和application/x-www-form-urlencoded
- data为null或者undefined:不会发送数据(也可能跟浏览器有关系)。但是axios还是很严谨的进行了判断,如果data为undefined将其变成null。
- data有值:无论content-type是什么,后端都能收到值。这个设置应该只是修改的HTTP报文,没有实际影响。
4.3 设置responseType为json是否会强制修改request.response数据类型为object(默认为string)
responseType只能在请求完成之前设置,默认值为空串(数据类型为string,并且跟后端返回时设置的Content-type无关)设置之后会强制修改响应数据类型,其结果放在xhr.response中,xhr.responseText仍然为string类型。在请求完成之后修改responseType会报DOMException: Failed to set the 'responseType' property on 'XMLHttpRequest': The response type cannot be set if the object's state is LOADING or DONE
的异常。
4.4 测试总结
- 前端ajax请求通过各种收集参数最后也只是构造一个HTTP报文通过浏览发送出去。因此content-type和具体的数据并没有严格的限制,这个东西更像是约定一样,后端框架按照这个约定写的代码,如果前端不遵守约定发送请求,容易出现问题。
- responseType跟后端无关,是方便前端转换数据类型的一种工具。
五、完整源码
function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
var requestData = config.data;
var requestHeaders = config.headers;
if (utils.isFormData(requestData)) {
delete requestHeaders['Content-Type']; // Let the browser set it
}
var request = new XMLHttpRequest();
// HTTP basic authentication
if (config.auth) {
var username = config.auth.username || '';
var password = config.auth.password || '';
requestHeaders.Authorization = 'Basic ' + btoa(username + ':' + password);
}
var fullPath = buildFullPath(config.baseURL, config.url);
request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);
// Set the request timeout in MS
request.timeout = config.timeout;
// Listen for ready state
request.onreadystatechange = function handleLoad() {
if (!request || request.readyState !== 4) {
return;
}
// The request errored out and we didn't get a response, this will be
// handled by onerror instead
// With one exception: request that using file: protocol, most browsers
// will return status as 0 even though it's a successful request
if (request.status === 0 && !(request.responseURL && request.responseURL.indexOf('file:') === 0)) {
return;
}
// Prepare the response
var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null;
var responseData = !config.responseType || config.responseType === 'text' ? request.responseText : request.response;
var response = {
data: responseData,
status: request.status,
statusText: request.statusText,
headers: responseHeaders,
config: config,
request: request
};
settle(resolve, reject, response);
// Clean up request
request = null;
};
// Handle browser request cancellation (as opposed to a manual cancellation)
request.onabort = function handleAbort() {
if (!request) {
return;
}
reject(createError('Request aborted', config, 'ECONNABORTED', request));
// Clean up request
request = null;
};
// Handle low level network errors
request.onerror = function handleError() {
// Real errors are hidden from us by the browser
// onerror should only fire if it's a network error
reject(createError('Network Error', config, null, request));
// Clean up request
request = null;
};
// Handle timeout
request.ontimeout = function handleTimeout() {
var timeoutErrorMessage = 'timeout of ' + config.timeout + 'ms exceeded';
if (config.timeoutErrorMessage) {
timeoutErrorMessage = config.timeoutErrorMessage;
}
reject(createError(timeoutErrorMessage, config, 'ECONNABORTED',
request));
// Clean up request
request = null;
};
// Add xsrf header
// This is only done if running in a standard browser environment.
// Specifically not if we're in a web worker, or react-native.
if (utils.isStandardBrowserEnv()) {
var cookies = require('./../helpers/cookies');
// Add xsrf header
var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
cookies.read(config.xsrfCookieName) :
undefined;
if (xsrfValue) {
requestHeaders[config.xsrfHeaderName] = xsrfValue;
}
}
// Add headers to the request
if ('setRequestHeader' in request) {
utils.forEach(requestHeaders, function setRequestHeader(val, key) {
if (typeof requestData === 'undefined' && key.toLowerCase() === 'content-type') {
// Remove Content-Type if data is undefined
delete requestHeaders[key];
} else {
// Otherwise add header to the request
request.setRequestHeader(key, val);
}
});
}
// Add withCredentials to request if needed
if (!utils.isUndefined(config.withCredentials)) {
request.withCredentials = !!config.withCredentials;
}
// Add responseType to request if needed
if (config.responseType) {
try {
request.responseType = config.responseType;
} catch (e) {
// Expected DOMException thrown by browsers not compatible XMLHttpRequest Level 2.
// But, this can be suppressed for 'json' type as it can be parsed by default 'transformResponse' function.
if (config.responseType !== 'json') {
throw e;
}
}
}
// Handle progress if needed
if (typeof config.onDownloadProgress === 'function') {
request.addEventListener('progress', config.onDownloadProgress);
}
// Not all browsers support upload events
if (typeof config.onUploadProgress === 'function' && request.upload) {
request.upload.addEventListener('progress', config.onUploadProgress);
}
if (config.cancelToken) {
// Handle cancellation
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) {
return;
}
request.abort();
reject(cancel);
// Clean up request
request = null;
});
}
if (requestData === undefined) {
requestData = null;
}
// Send the request
request.send(requestData);
});
};