分析axios设计,学习前端请求库

1,777 阅读13分钟

axios源码探索,主要是学习源码实现原理和揣摩设计思路,不会深究每个开源库具体的函数实现。

#0. 导读

本文学习的版本是 v0.19.2(截止至目前20200331),拉取的axios开源库master分支,最新一次commitCommits on Mar 28, 2020 c120f44d3d29c8e822a92e1d6879b8b77be6b9dc Fixing 'progressEvent' type

在多个项目都是用到了axios之后,再次阅读axios源码,心中不禁会留下一些疑问:

  1. 为什么axios能够当成axios(config)函数调用,也可以当成对象axios.get调用呢?
  2. axios默认返回200~300之间才算是请求成功,如果我想要修改这个判断逻辑能做到吗?
  3. 据说axios是使用XMLHttpRequest实现接口请求的,它的实现原理是什么样的呢?
  4. umi-requesttua-api用到了koa-compose的洋葱模型,KOAJAX基于Koa式的中间件调用栈,axios具体的实现是什么样的呢?
  5. axios的拦截器是什么样的呢?它的原理是什么呢?
  6. axios为什么支持在浏览器和node两种不同的环境下发送请求,那么它能够借助微信提供的原生请求 wx.request API发送请求吗?
  7. 以前的几个项目使用axios进行数据请求时,mock的时候非常不便,那么,71.3K开发者关注的请求库有考虑过如何为开发和测试提供方便吗?
#1. axios初始化

查看package.json文件的main主入口文件为index.js,再追查发现实际主入口文件为**./lib/axios**。

axios.js文件代码行数比较少,主要的功能是生成混合对象axios,对外提供axios.Axiosaxios.create工厂方法和相关扩展实现,比如取消请求相关、all和spred等方法

#1.1 构造axios混合对象

在项目当中常常使用到的是axios({config}),axios[method](url[, config]), axios.create([config])

/**
 * Create an instance of Axios
 *
 * @param {Object} defaultConfig The default config for the instance
 * @return {Axios} A new instance of Axios
 *
 * instance 是函数 Axios.prototype.request 且执行上下文绑定到 context。
 * instance 里面有 Axios.prototype 上面的所有方法,并且这些方法的执行上下文也绑定到 context。
 * instance 里面还有 context 上的方法。
 */
function createInstance(defaultConfig) {
  // 实例化Axios对象
  var context = new Axios(defaultConfig);

  // 把request函数的this指向context
  // 将Axios.prototype.request 的上下文绑定到context
  // bind工具方法实际运行为:Axios.prototype.request.apply(context, arguments)
  var instance = bind(Axios.prototype.request, context);

  // Copy axios.prototype to instance
  // 将 Axios.prototype 上的所有方法的执行上下文绑定到 context , 并且继承给 instance
  utils.extend(instance, Axios.prototype, context);

  // Copy context to instance
  // 复制 context 到 intance 实例
  utils.extend(instance, context);

  // 返回混合对象axios
  return instance;
}

// Create the default instance to be exported
// 传入一个默认的配置
var axios = createInstance(defaults);


// 下面将会给axios实例化的对象增加不同的方法
// Expose Axios class to allow class inheritance
axios.Axios = Axios;

// Factory for creating new instances
// 工厂模式 创建新的实例 用户可以自定义一些参数
axios.create = function create(instanceConfig) {
  return createInstance(mergeConfig(axios.defaults, instanceConfig));
};
  1. bind(Axios.prototype.request, context) 将Axios实例和request函数进行绑定,使得axios(config)实际调用的是Axios.prototype.request进行数据请求。

  2. utils.extend(instance, Axios.prototype, context) 将 Axios.prototype 上的所有方法的执行上下文绑定到 context , 并且继承给 instance,使得我们在实际使用axios[method](url[, config])进行接口请求时,实际执行Axios.prototype上对应的方法。

  3. utils.extend(instance, context) 将context对象直接赋值给instance,这一操作使得我们能够使用默认配置axios.defaults和拦截器axios.interceptors,其背后实际使用的是(new Axios()).defaults和(new Axios()).interceptors

上述三步操作完美的实现了axios既能当成**axios({config})函数调用,也可以当成对象axios[method](url[, config])**调用,基于上述三步之后,我绘制了axios的混合对象关系图

#1.2 代码运行详情

了解完如何构造axios混合对象之后,接下来看看在一般的项目接入过程当中,axios库的核心运行流程是怎么样的。

import axios from "axios";

// 添加请求拦截器
axios.interceptors.request.use(function (config) {
    // 在发送请求之前对request config 进行处理
    return config;
  }, function (error) {
    // 在发送请求之前发生error
    return Promise.reject(error);
  });

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
    // 默认情况下,请求httpStatus为 200 ~ 300,会在这里进行处理【正常】响应对象response
    return response;
  }, function (error) {
    // 默认情况下,请求httpStatus不在 200 ~ 300,会在这里进行处理【异常】响应对象response
    return Promise.reject(error);
  });

// 开始请求数据
axios({
  method: 'get',
  url: 'https://gateway.cn/user/alvin',
  responseType: 'json'
}).then(function response(res) {
  console.log("print::: ", res)
})

① 通过axios提供的axios.interceptors对象添加请求和响应拦截器,最终会通过use方法InterceptorManager.prototype.use = function use(fulfilled, rejected)将拦截器添加到私有变量handlers当中,等待后续请求时发生作用。【如图中第0步】

② 使用axios(config)的方法开始发送请求,其本质是通过function createInstance(defaultConfig)工厂方法完成创建axios对象,并且在这个方法内完成axios混合对象的构建。【如图中第1步】

③ 通过上面分析axios混合对象之后会发现,axios(config)本质上是在调用Axios.prototype.request方法,并在这个方法内完成每次请求时的Promise请求调用链chain生成。【如图中第2步】

通过request执行之后,返回的请求调用链如下:

const chain = [ 请求拦截器的成功方法,请求拦截器的失败方法 [,... ],dispatchRequest, undefined, 响应拦截器的成功方法,响应拦截器的失败方法 [,... ]]

  • axios.interceptors.request.use 注册的请求拦截器会Array.unshift进chain数组

  • axios.interceptors.response.use注册的请求拦截器会Array.push进chain数组

④ 在构造整个请求链的过程中,发现chain的初始值为[dispatchRequest, undefined],其中dispatchRequest表示请求库的核心adapter,在浏览器模式为XMLHttpRequest,在node模式下为http或者https模块,并且如果自定义了adapter对象,会优先使用。

//   core/dispatchRequest.js 文件
module.exports = function dispatchRequest(config) {

  // ...

  // 获取请求适配器,如果自定义适配器则优先使用
  var adapter = config.adapter || defaults.adapter;
    return adapter(config).then(
        function onAdapterResolution(response) {  }
        function onAdapterRejection(reason) {  }
    )
}

// defaults.js 文件
function getDefaultAdapter() {
  var adapter;
  
  // 浏览器模式
  if (typeof XMLHttpRequest !== 'undefined') {
    adapter = require('./adapters/xhr');
  }
  // node模式
  else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    adapter = require('./adapters/http');
  }
  return adapter;
}

至此,整个axios请求核心流程基本分析完成,接下来揣摩axios几个重要模块设计思路

#2 axios重要模块 —— XMLHttpRequest对象

在前面我们通过梳理axios执行流程之后发现,axios在浏览器端的核心adapter为XMLHttpRequest,下面稍微了解下这个对象。

下面的信息全部通过typescript类型文件lib.es6.dom.d.ts进行获取

// responseType类型
type XMLHttpRequestResponseType = "" | "arraybuffer" | "blob" | "document" | "json" | "text";

// XMLHttpRequest 父类对象
interface XMLHttpRequestEventTarget {
    // 当请求失败时调用该方法,接受 abort 对象作为参数
    onabort: ((this: XMLHttpRequest, ev: Event) => any) | null;
  	// 当请求发生错误时调用该方法,接受 error 对象作为参数。
    onerror: ((this: XMLHttpRequest, ev: ErrorEvent) => any) | null;
  	// 当一个 HTTP 请求正确加载出内容后返回时调用,接受 load 对象作为参数。
    onload: ((this: XMLHttpRequest, ev: Event) => any) | null;
  	// 当内容加载完成,不管失败与否,都会调用该方法,接受 loadend 对象作为参数。
    onloadend: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null;
  	// 当一个 HTTP 请求开始加载数据时调用,接受 loadstart 对象作为参数。
    onloadstart: ((this: XMLHttpRequest, ev: Event) => any) | null;
  	// 间歇调用该方法用来获取请求过程中的信息,接受 progress 对象作为参数。
    onprogress: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null;
  	// 当超时时调用,接受 timeout 对象作为参数;只有设置了 XMLHttpRequest 对象的 timeout 属性时,才可能发生超时事件。
    ontimeout: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null;
    // ...
}


interface XMLHttpRequest extends XMLHttpRequestEventTarget {

  	// 当 readyState 发生改变时回调处理函数
    onreadystatechange: ((this: XMLHttpRequest, ev: Event) => any) | null;

		// 返回 一个无符号短整型(unsigned short)数字,代表请求的状态码
		// 0	UNSENT	代理被创建,但尚未调用 open() 方法。
		// 1	OPENED	open() 方法已经被调用。
		// 2	HEADERS_RECEIVED	send() 方法已经被调用,并且头部和状态已经可获得。
		// 3	LOADING	下载中; responseText 属性已经包含部分数据。
		// 4	DONE	下载操作已完成。
    readonly readyState: number;
  
		// 请求返回的响应结果
    readonly response: any;
		// 此请求的响应为文本,或者当请求未成功或还是未发送时未 null
    readonly responseText: string;

		// 返回响应数据的枚举类型。它允许我们手动的设置返回数据的类型。
		// 如果我们将它设置为一个空字符串,它将使用默认的"text"类型。
  	responseType: XMLHttpRequestResponseType;
		
		// 返回响应的序列化(serialized)URL,如果该 URL 为空,则返回空字符串
    readonly responseURL: string;
		// 返回一个无符号短整型(unsigned short)数字,代表请求的响应状态。
    readonly status: number;
		// 包含 HTTP 服务器返回响应状态的字符串。与 XMLHTTPRequest.status 不同的是,它包含完整的响应状态文本
    readonly statusText: string;

		// 用来指定跨域 Access-Control 请求是否应带有授权信息,如 cookie 或授权 header 头。
    withCredentials: boolean;

		// 表示一个请求在被自动终止前所消耗的毫秒数。默认值为 0,意味着没有超时时间。
		// 超时并不能应用在同步请求中,否则会抛出一个 InvalidAccessError 异常。
		// 当发生超时时,timeout 事件将会被触发。
  	timeout: number;

		// 返回所有响应头信息(响应头名和值),如果响应头还没有接收,则返回 null。
		// 注意:使用该方法获取的 response headers 与在开发者工具 Network 面板中看到的响应头不一致
    getAllResponseHeaders(): string;
		// 返回指定响应头的值,如果响应头还没有被接收,或该响应头不存在,则返回 null。
    getResponseHeader(name: string): string | null;
    
		// 设置 HTTP 请求头信息。
		// 注意:在这之前,你必须确认已经调用了 open() 方法打开了一个 url
    setRequestHeader(name: string, value: string): void;
  
  	// upload方法返回的是一个XMLHttpRequestUpload对象,通过监听progress事件的回调完成上传和下载能力
    readonly upload: XMLHttpRequestUpload;

		// 如果请求已经被发送,则立刻中止请求
    abort(): void;
    
    // 初始化一个请求。该方法只能在 JavaScript 代码中使用,若要在 native code 中初始化请求,请使用 openRequest()。
    // 参数签名:
    //     method - 请求所使用的 HTTP 方法,如 GET、POST、PUT、DELETE
    //     url - 请求的 URL 地址
    //     async - 一个可选的布尔值参数,默认值为 true,表示执行异步操作。
    //           - 如果值为 false,则 send() 方法不会返回任何东西,直到接收到了服务器的返回数据
    //     user - 用户名,可选参数,用于授权。默认参数为空字符串
    //     password - 密码,可选参数,用于授权。默认参数为空字符串
    open(method: string, url: string): void;
    open(method: string, url: string, async: boolean, username?: string | null, password?: string | null): void;
    
    // 参数详情可查看 XMLHttpRequestUpload ,监听函数具有相同的实现
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;

    // ...
}

interface XMLHttpRequestUpload extends XMLHttpRequestEventTarget {
  	// 监听事件的type类型为:
  	//     onloadstart	获取开始
  	//     onprogress	数据传输进行中
  	//     onabort	获取操作终止
  	//     onerror	获取失败
  	//     onload	获取成功
  	//     ontimeout	获取操作在用户规定的时间内未完成
  	//     onloadend	获取完成(不论成功与否)
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}


① 通过透传请求时的config.onDownloadProgress函数来回调监听下载进度。request.addEventListener('progress', config.onDownloadProgress)

② 通过透传请求时的config.onDownloadProgress函数来回调监听下载进度。request.addEventListener('progress', config.onDownloadProgress)

③ 在超时和请求异常的功能上,借助了父类的onerrorontimeout方式来完成。具体代码如下:

var request = new XMLHttpRequest();

// 请求异常处理
request.onerror = function handleError() { // ... }
// 请求超时处理
request.ontimeout = function handleTimeout() { // ... }

思考

如果说请求超时和请求异常会由XMLHttpRequest对象在底层实现上自动回调对应的注册函数handleError或handleTimeout,那么,取消请求呢?

取消请求在触发的主体上应该为调用者,XMLHttpRequest能做的就是在接受到取消请求命令的时候配合处理而已。

#3 axios 重要模块 —— 请求取消

在某些场景下,我们希望能够主动取消已经发送的请求,比如请求响应结果存在互相覆盖,因为每一个请求响应的时长不固定时,实际运行的结果可能会有点不是我们所希望发生的,这时候我们希望能够有一个取消请求的能力去处理掉请求响应顺序带来的问题。观察发现axios实现了两种取消请求的实现:

① 给axios添加一个CancelToken对象,能够提供一个source方法返回一个source对象,source.token为每次请求时传给配置对象的canceToken属性,在请求发送之后,能够通过source.cancel方法来取消请求

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/alvin', {
  cancelToken: source.token
}).catch(function (e) {
  if (axios.isCancel(e)) {
    console.log('Request canceled', e.message);
  } else {
    // 处理错误
  }
});

// 取消请求 (请求原因是可选的)
source.cancel('Operation canceled by the user.');

② axios.CancelToken是一个类,直接把其实例化对象传送给请求配置中的cancelToken属性,CancelToken 的构造函数参数支持传入一个 executor 方法,该方法的一个参数为取消请求函数cancelFn,通过将cancelFn赋值给原本作用域cancel,通过调用cancel方法来取消请求。

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/alvin', {
  cancelToken: new CancelToken(function executor(cancelFn) {
    cancel = cancelFn;
  })
});

// 取消请求
cancel();
  • 揣摩实现思路

通过代码分析发现,上述两种调用的实现方式,其核心是一致的。每次发送请求的异步过程中,通过config.cancelToken字段注册进去的是一个 pending 状态的 Promise。然后在这个Promise fulfilled的调用链当中调用XMLHttpRequest对象的abort()方法完成取消请求的处理。具体实现如下:

// CancelToken.js 取消请求的核心文件

function CancelToken(executor) {
	// ...
  
  var resolvePromise;
  // 构造一个pending状态的Promsie
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
		// ...
    
    // token.reason是取消的请求的原因,由外部config传入
    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

CancelToken.source = function source() {
  var cancel;
  // 这个实现和观察到的第二种方式相同
  var token = new CancelToken(function executor(c) {
    cancel = c;
  });
  return {
    token: token,
    cancel: cancel
  };
};

xhr.js文件中,每次请求都会存在一个promise等待着后续的resolve处理

if (config.cancelToken) {
  // 处理取消请求
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (!request) {
      return;
    }

    request.abort();
    reject(cancel);
    // 清空请求对象
    request = null;
  });
}

至此,取消请求的核心实现也已经完成。

#4 axios功能地图

#4 对比实现,跳出数据请求范围

在学习axios请求库的同时,也在微信公众号、掘金、github、google中搜罗下来发现很多请求库,通过学习了解之后大致可以分成两种类型:

① 第一种:基于代码运行环境提供的数据请求能力(例如: window.fetch/XMLHttpRequest/http/https)进行包装,在不同的请求库当中实现包装的方式各有不同,有的基于Promise请求链,有的基于koa-compose洋葱模型,最终实现为代码开发提效的能力。代表有:axios umi-request KOAXAJ tua-api

②第二种:跳出fetch请求方法,转而和ui相关流行库进行结合,比如通过Hooks可以触达UI这一特性,在React框架的基础上封装出了SWR

#写在最后
  1. 在阅读源码的过程中,遇到了不少的问题,其中印象最深的是关于axios混合对象构造,刚开始非常不能理解为什么要使用extend和bind,后来是看到了若川大佬的axios的文章才渐渐梳理出了自己的理解

  2. 我最近阅读源码用的最顺手的方法,结合最简单的demo和测试用例,再辅以思维导图和流程图,基本上cover能让我在官方库的设计思想和实现细节上不断来回切换,非常好用。

  3. 思考一:在请求数据的方式上,是否还存在某些特殊场景下的好工具呢?

  4. 思考4二:在现有的请求库当中,是否还有其他更优雅的实现方式呢?

#参考内容

1.MDN XMLHttpRequest文档

2.学习 axios 源码整体架构,打造属于自己的请求库

附: 文章相关图片资源:github.com/Aastasia/ma…