源码分析之路——axios

187 阅读12分钟

忙里偷闲,终于干完了某阶段的活儿,正好最近在使用vue写项目,因为团队中的项目最近都被统一起来管理,结构和部署开始一致化,发现项目中封装好的api还挺方便,当然缺陷也是有的,比如错误处理这一块儿一直没有统一化,这是个坑(主要还是因为目前人员流动大,项目又杂乱,没有统一接口字段等等理由),总之这些都是题外话。发现项目中的api是经过了对axios的二次封装,说实话,我还没怎么用过axios,有时候遇到一些简单的问题也不是很清楚怎么去处理,正好用这空闲的晚上来阅读一下axios的源码,学习学习。

源码目录结构

首先奉上github源码地址:axios

可以选择clone一下这个项目到本地,先来看一下目录结构,入口文件index.js就做了一件事情,从lib文件夹中导出axios

module.exports = require('./lib/axios');

下面来看一下这个核心文件夹lib中的结构:

lib
├── adapters // 适配器
│   ├── http.js // node环境中使用http来请求接口
│   └── xhr.js // 浏览器环境中使用XMLHttpRequest来请求接口
├── cancel  // 用于取消请求
│   ├── Cancel.js  // 定义了取消请求返回的信息结构
│   ├── CancelToken.js // 定义用于取消请求的主要方法
│   └── isCancel.js
├── core // 核心文件夹
│   ├── Axios.js // 定义axios构造函数以及原型上的属性和方法
│   ├── buildFullPath.js // 根据相对路径和绝对路径使用buildFullPath构建完整url
│   ├── createError.js // 生成指定error
│   ├── dispatchRequest.js // 连接拦截器的中间件,发起请求的地方
│   ├── enhanceError.js
│   ├── InterceptorManager.js // 定义拦截器
│   ├── mergeConfig.js // 合并配置项
│   ├── settle.js // 根据请求状态,处理Promise
│   └── transformData.js // 格式化data
├── helpers
│   ├── bind.js 
│   ├── buildURL.js // 将get请求中的参数格式化为&形式
│   ├── combineURLs.js
│   ├── cookies.js
│   ├── deprecatedMethod.js
│   ├── isAbsoluteURL.js
│   ├── isURLSameOrigin.js // 是否同源
│   ├── normalizeHeaderName.js // 对对象属性名的进行格式化
│   ├── parseHeaders.js  // header信息转化为对象
│   └── spread.js // 扩展参数,类似于apply
├── axios.js 
├── defaults.js // axios中默认的配置
└── utils.js // 一些工具方法

代码一窥

axios核心代码

其实最核心的axios代码部分不到五十行,先看一下这段源码是如何实现axios的功能设计的:

'use strict';

var utils = require('./utils');
var bind = require('./helpers/bind');
var Axios = require('./core/Axios');
var mergeConfig = require('./core/mergeConfig');
var defaults = require('./defaults');

/**
 * 定义createInstance方法,用来生成axios实例
 */
function createInstance(defaultConfig) {
  var context = new Axios(defaultConfig);
  var instance = bind(Axios.prototype.request, context);

  utils.extend(instance, Axios.prototype, context);

  utils.extend(instance, context);

  return instance;
}

// 创建axios实例,参数为default.js文件的默认配置
var axios = createInstance(defaults);

axios.Axios = Axios;

// 支持用户自定义配置
axios.create = function create(instanceConfig) {
  return createInstance(mergeConfig(axios.defaults, instanceConfig));
};

// 导出Cancel 和 CancelToken
axios.Cancel = require('./cancel/Cancel');
axios.CancelToken = require('./cancel/CancelToken');
axios.isCancel = require('./cancel/isCancel');

// 导出all和spread
axios.all = function all(promises) {
  return Promise.all(promises);
};
axios.spread = require('./helpers/spread');

module.exports = axios;

// 允许在TypeScript中使用axios
module.exports.default = axios;

可以看到这个文件已经是集合了很多功能最后封装导出的一部分,主要是通过Axios构造函数生成一个实例,在这个实例上添加Axios、create、Cancel、CancelToken、isCancel、all和spread等属性或方法,在实例上绑定Axios和create是为了让用户可自定义配置。

详细看一下axios实例是怎么生成的:

function createInstance(defaultConfig) {
  var context = new Axios(defaultConfig);
  var instance = bind(Axios.prototype.request, context);

  utils.extend(instance, Axios.prototype, context);

  utils.extend(instance, context);

  return instance;
}

createInstance函数一共做了这几件事儿:

  • 创建一个新Axios实例
  • 更改Axios.prototype.request的this,执行context实例
  • 绑定原型
  • 绑定作用域
  • 返回这个新实例instance

这个步骤是不是似曾相识?反正我是怎么瞅着怎么觉得像是new的过程:

在《JS高级程序设计(第三版)》书里有描述new的具体过程:

  • 创建一个新对象;
  • 将构造函数的作用域赋给新对象(因此this就指向了这个新对象);
  • 执行构造函数中的代码(为这个新对象添加属性);
  • 返回新对象;

我在这篇博客里记录了 关于new操作符,有具体的模拟实现new的方法:

function newObj() {
    var o = {}; // 创建一个新对象
    var Con = [].shift.call(arguments); // 取出第一个参数,即传入的构造函数,此时原arguments已经去掉了构造函数
    Con.apply(o, arguments); // 将剩下的参数绑定到这个构造函数上
    o.__proto__ = Con.prototype; // 绑定原型
    // o.constructor = Con;
    return o;
}

好,又扯远了。

回归正题,下面可以看到axios的构造函数是从core文件夹中引入的,打开Axios.js这个文件。

Axios构造函数

这个函数代码也不长,主要是定义了Axios构造函数,给自身加了默认配置defaults和拦截器interceptors的属性,在原型上绑定request、getUri方法,并为axios配置方法别名,也就是我们常用的delete、get、head、options、post、put和patch的请求方式。

比如get请求可以直接这么用:

axios.get('/user?ID=12345')
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

这里主要定义了这些基本方法,至于all和spread在其他文件中定义,后面会分析到。

Axios.js源码:

function Axios(instanceConfig) {
  this.defaults = instanceConfig;
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

/**
 * Dispatch a request
 *
 * @param {Object} config The config specific for this request (merged with this.defaults)
 */
Axios.prototype.request = function request(config) {
  if (typeof config === 'string') {
    config = arguments[1] || {};
    config.url = arguments[0];
  } else {
    config = config || {};
  }

  config = mergeConfig(this.defaults, config);

  if (config.method) {
    config.method = config.method.toLowerCase();
  } else if (this.defaults.method) {
    config.method = this.defaults.method.toLowerCase();
  } else {
    config.method = 'get';
  }

  // Hook up interceptors middleware
  var chain = [dispatchRequest, undefined];
  var promise = Promise.resolve(config);

  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    chain.push(interceptor.fulfilled, interceptor.rejected);
  });

  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }

  return promise;
};

Axios.prototype.getUri = function getUri(config) {
  config = mergeConfig(this.defaults, config);
  return buildURL(config.url, config.params, config.paramsSerializer).replace(/^\?/, '');
};

// Provide aliases for supported request methods
utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
  Axios.prototype[method] = function(url, config) {
    return this.request(utils.merge(config || {}, {
      method: method,
      url: url
    }));
  };
});

utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
  Axios.prototype[method] = function(url, data, config) {
    return this.request(utils.merge(config || {}, {
      method: method,
      url: url,
      data: data
    }));
  };
});

module.exports = Axios;

其中,下面这串代码是跟拦截器相关的,它把实际的请求初始化放在chain数组中,然后分别遍历interceptors的request请求,分别把成功回调和失败回调插入到数组头部;而interceptors的response响应则放在数组尾部,这个数组最后大概长成这样: [请求拦截器success, 请求拦截器error, dispatchRequest, undefined, 响应拦截器success, 响应拦截器error]

接着while语句开始执行整个请求流程,顺序是 请求拦截器 -> dispatchRequest -> 响应拦截器

  var chain = [dispatchRequest, undefined];

  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    chain.push(interceptor.fulfilled, interceptor.rejected);
  });
  
  // 最终将 chain 里的方法包成 promise,并返回
  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }
  return promise;

拦截器类

先来科普一下拦截器:

拦截器分为请求拦截器和响应拦截器,顾名思义: 请求拦截器(interceptors.request)是指可以拦截住每次或指定http请求,并可修改配置项 响应拦截器(interceptors.response)可以在每次http请求后拦截住每次或指定http请求,并可修改返回结果项。

接着看一下拦截器是怎么实现的?Axios.js文件中引入了拦截器,var InterceptorManager = require('./InterceptorManager');

定义拦截器的核心其实是一个数组,

function InterceptorManager() {
  this.handlers = [];
}

// 增加一个请求拦截器,注意是2个函数,一个处理成功,一个处理失败
InterceptorManager.prototype.use = function use(fulfilled, rejected) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected
  });
  return this.handlers.length - 1;
};

InterceptorManager.prototype.eject = function eject(id) {
  if (this.handlers[id]) {
    this.handlers[id] = null;
  }
};

// 遍历this.handlers,并将this.handlers里的每一项作为参数传给fn执行
InterceptorManager.prototype.forEach = function forEach(fn) {
  utils.forEach(this.handlers, function forEachHandler(h) {
    if (h !== null) {
      fn(h);
    }
  });
};

module.exports = InterceptorManager;

我们可以把每一次请求想象成一条管道里的流过的水。当一个请求发出的时候,会先流过 interceptors 的 request 部分,接着请求会发出,当接受到响应时,会先流过 interceptors 的 response 部分,最后返回,这条管道大概如下:

interceptors.request -> request -> interceptors.response -> response

现在,我们再回看Axios中拦截器相关的代码,

// 最终将 chain 里的方法包成 promise,并返回
  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }
  return promise;

这时候返回的promise大概会是这样:

Promise.resolve(config)
    .then(interceptor.request.fulfilled, interceptor.request.rejected)
    .then(dispatchRequest, undefined)
    .then(interceptor.response.fulfilled, interceptor.response.rejected)

还记得前面说过的chain数组吗?其中的dispatchRequest是真正发起请求的地方,它主要做了三件事:

  1. 拿到config对象,对config进行传给http请求适配器前的最后处理
  2. http请求适配器根据config配置,发起请求
  3. 请求完成后,如果成功则根据header、data、和数据转换后的response并返回

dispatchRequest主要代码:

function throwIfCancellationRequested(config) {
  if (config.cancelToken) {
    config.cancelToken.throwIfRequested();
  }
}

module.exports = function dispatchRequest(config) {
  throwIfCancellationRequested(config);

  config.headers = config.headers || {};

// 格式化data
  config.data = transformData(
    config.data,
    config.headers,
    config.transformRequest
  );

// 合并header
  config.headers = utils.merge(
    config.headers.common || {},
    config.headers[config.method] || {},
    config.headers || {}
  );

// 删除header属性里无用的属性
  utils.forEach(
    ['delete', 'get', 'head', 'post', 'put', 'patch', 'common'],
    function cleanHeaderConfig(method) {
      delete config.headers[method];
    }
  );

  var adapter = config.adapter || defaults.adapter;

  return adapter(config).then(function onAdapterResolution(response) {
    throwIfCancellationRequested(config);

    response.data = transformData(
      response.data,
      response.headers,
      config.transformResponse
    );

    return response;
  }, function onAdapterRejection(reason) {
    if (!isCancel(reason)) {
      throwIfCancellationRequested(config);
      if (reason && reason.response) {
        reason.response.data = transformData(
          reason.response.data,
          reason.response.headers,
          config.transformResponse
        );
      }
    }

    return Promise.reject(reason);
  });
};

如此一来,整个axios的请求流程就明朗了:

interceptor请求拦截器 -> dispatchRequest发请求 -> 格式化请求的数据 -> 请求适配器 -> 格式化响应的数据 -> interceptor响应拦截器

到现在已经介绍了拦截器和dispatchRequest,我们发现中间还有几步没介绍,一部分是数据的格式化transformData,和请求的适配。

数据的格式化

在上面讲过的dispatchRequest代码中,可以看到,请求前使用了transformData来转换config.data,请求后的成功和失败的回调中也都使用了transformData来转换响应数据。有使用过axios经验的应该都知道,默认情况下axios将会自动的将传入的data对象序列化为JSON字符串,将响应数据中的JSON字符串转换为JavaScript对象。这就是transformData的功劳。

transformData定义在core文件夹中,源码如下:

module.exports = function transformData(data, headers, fns) {
  utils.forEach(fns, function transform(fn) {
    data = fn(data, headers);
  });

  return data;
};

很短,它只是将传入的回调函数遍历处理了而已,真正核心的代码其实是transformRequest和transformResponse,看一下dispatchRequest中是怎么使用transformData的?

  // 响应前
  config.data = transformData(
    config.data,
    config.headers,
    config.transformRequest
  );
  
  // 响应后
  response.data = transformData(
    response.data,
    response.headers,
    config.transformResponse
  );

找到transformRequest和transformResponse的定义:

transformRequest: [function transformRequest(data, headers) {
    normalizeHeaderName(headers, 'Accept');
    normalizeHeaderName(headers, 'Content-Type');
    if (utils.isFormData(data) ||
      utils.isArrayBuffer(data) ||
      utils.isBuffer(data) ||
      utils.isStream(data) ||
      utils.isFile(data) ||
      utils.isBlob(data)
    ) {
      return data;
    }
    if (utils.isArrayBufferView(data)) {
      return data.buffer;
    }
    if (utils.isURLSearchParams(data)) {
      setContentTypeIfUnset(headers, 'application/x-www-form-urlencoded;charset=utf-8');
      return data.toString();
    }
    if (utils.isObject(data)) {
      setContentTypeIfUnset(headers, 'application/json;charset=utf-8');
      return JSON.stringify(data);
    }
    return data;
  }],


  transformResponse: [function transformResponse(data) {
    if (typeof data === 'string') {
      try {
        data = JSON.parse(data);
      } catch (e) { /* Ignore */ }
    }
    return data;
  }],

可以看到request主要处理了data为buffer、url的搜索参数、对象时的情况,response则将返回的字符串类型的响应转换为对象。

请求适配器

还是在dispatchRequest中使用到的,axios会根据当前的环境判断使用不同的adapter,如果是浏览器环境,使用XMLHttpRequest来请求,如果是node环境,使用http模块来请求数据。当然,axios也留给了用户通过config自行配置适配器的接口的。这个请求适配器实则是一个函数,如下:

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

取消请求

以上主要介绍了axios的核心代码也是基础部分,axios还居于一些其他的特性,比如取消请求、处理并发请求等。

先看看取消请求是怎么做的?

其实在实际项目中,我还没用到过取消请求,也是在看了axios的介绍才知道还有这个操作,axios取消请求一共有两种方式:

// 第一种取消方法
axios.get(url, {
  cancelToken: new axios.CancelToken(cancel => {
    if (/* 取消条件 */) {
      cancel('取消日志');
    }
  })
});

// 第二种取消方法
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get(url, {
  cancelToken: source.token
});
source.cancel('取消日志');

源码分析: cancelToken方法的定义在cancel文件夹中,

function CancelToken(executor) {
  if (typeof executor !== 'function') {
    throw new TypeError('executor must be a function.');
  }

  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      return;
    }

    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

这段代码的核心部分是

this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

这里得到一个实例属性promise,在请求的适配器中继续给这个promise添加then方法

if (config.cancelToken) {
      // Handle cancellation
      config.cancelToken.promise.then(function onCanceled(cancel) {
        if (!request) {
          return;
        }

        request.abort();
        reject(cancel);
        // Clean up request
        request = null;
      });
    }

然后我们就可以通过传给CancelToken的参数executor(一个函数)来执行cancel方法,promise属性的状态变为rejected, 并执行request.abort()方法,并且返回 reason 信息。 执行的整个流程如下:

执行 cancel 方法 -> 生成 reason 信息 -> promise resolve -> request abort

客户端防止xsrf攻击

axios的另一个特性是可以防止xsrf攻击,继续科普一下:

XSRF跨站请求伪造(Cross-site request forgery),是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。

如何防范xsrf攻击?

当用户进行登录请求的时候,这时候后端应该把包含 xsrf 字段的 cookie 保存在 session 中并且返还给前端,前端需要获取到 cookie 中的值并且能放入 ajax 请求体或请求头中,后端把这个值与 session 中的相应值进行判断就可以了,根据跨域不可访问不同域的 cookie ,攻击者也很难猜测出 xsrf 的值,那么这样就防范了 xsrf 攻击

axios中是怎么做的? axios会在浏览器环境中判断,如果设置了跨域请求时需要凭证且请求的域名和页面的域名相同时,读取cookie中xsrf token 的值,并设置到承载 xsrf token 的值的 HTTP 头中。

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

支持并发

axios中有两个支持处理并发请求的助手函数axios.all和axios.spread。

其实这两个函数的定义也是非常的简单:

axios.all = function all(promises) {
  return Promise.all(promises);
};

function spread(callback) {
  return function wrap(arr) {
    return callback.apply(null, arr);
  };
};

axios.all实际上就是调用了 Promise.all的方法,而axios.spread其实类似于apply的作用,all的使用方式是axios.all([axios请求1, axios请求2, ....]),它仍然返回then,但是then方法中要传入 axios.spread(callback)

示例:

  return axios.get('/user/12345');
}
function getUserPermissions() {
  return axios.get('/user/12345/permissions');
}
axios.all([getUserAccount(), getUserPermissions()])
  .then(axios.spread(function (acct, perms) {
    //两个请求现已完成
  }));

什么情况下会使用并发?

由于项目中本来一个接口获取到的数据,被拆分到了多个接口,但是还要能全部返回数据之后才能做页面显示,虽然发很多http请求会慢,但是因为是异步的,而且符合业务需求,所以可以使用promise All。(比如说上传表单中多张图片,每次只能上传一张时,需要等所有图片上传完成才可以发起提交的请求这时候可以使用axios.all操作)

最后

到此 axios 的大部分实现已经简单分析完成。由于本人才疏学浅,也是刚开始写源码分析类的文章,如有表述的不合理或者是错误的地方,欢迎小伙伴指正。