axios源码解析

【axios源码】封装ajax请求

2020-01-23  本文已影响0人  可以秀但没必要

文本主要涉及到axios封装ajax请求的方法,首先从自己封装一个ajax请求会遇到的困难出发,然后看axios源码里面是怎么解决这些问题的。第一节简单介绍了ajax,第二节描述了一下我自己在封装ajax请求遇到的问题,尽管问题考虑的也没有axios完整。第三节开始分析axios源码,由于源码不能按顺序分解,因此从它解决了什么问题出发去分析,所以感觉更乱了,建议看这一节时对着源码加上注释。最后,还有很多地方与这个位置相关的处理不在这个函数里面,所有漏了一些数据处理部分没讲。希望对读者能有一点启发去实现自己的封装。本文并不适合观看,因为表达能力有限然而东西太多,所以需要配合源码慢慢的看。

一、预备知识

1.1 什么是ajax

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 问题(不完全,真实情况比以下复杂的多)

  1. 如果请求需要Authorization验证怎么办?
  2. url输入不规范怎么处理?主要体现在绝对地址和相对地址上面。比如http://localhost/test 和 /test
  3. get请求的参数怎么拼接?
  4. 请求完毕了该返回什么?正常的请求可以直接返回,异常的请求(500警告)该返回什么错误?
  5. 请求出错、请求取消、请求超时该怎么返回?
  6. Cookies怎么处理?
  7. 跨域请求怎么办?

三、源码分析(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);

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种情况都是绝对地址

问题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序列化部分处理逻辑如下:

  1. 如果有自定义的序列化函数就用自定义的序列化函数(应该用的较少吧)
  2. 如果是URLSearchParams类型的参数,就直接toString。(看了代码才知道还有URLSearchParams这个东西)
  3. 以上两种都不是采用默认处理逻辑。默认处理方式是下面的代码:

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 返回数据的处理

responseType介绍

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参数

4.2 分别使用post请求和get请求测试data为null和有值时但是设置content-type分别为application/json和application/x-www-form-urlencoded

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 测试总结

五、完整源码

 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);
  });
};

上一篇下一篇

猜你喜欢

热点阅读