Bootstrap

手撕83K STAR的Axios设计思想,并进行能力增强

概述

Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。并且,同时具备如下特征:

  • 拦截请求和响应

  • 转换请求数据和响应数据

  • 取消请求

  • 自动转换 JSON 数据

  • 客户端支持防御 XSRF

本文将深入源码、抽离Axios核心数据模型,学习它值得借鉴的地方。并通过源码理解拦截器、适配器,以及取消请求等模块如何实现。最后将通过实现Axios缓存接口数据的功能,一步步掌握Axios库的精髓。看完你一定能对实现接口节流、请求失败重发、取消重复请求等需求手到擒来。

Axios设计理解

首先我们来看下采用axios实例发起一个URL请求的大体数据流转流程。

通过追踪 axios 完整的请求流程源码,可以看出项目抽离出了很多的数据模型和转换模块,比如:Axios构造函数、defaults 默认配置参数、拦截器、适配器,以及数据转换函数等等,具备很高的抽象性。

同时,它具备良好的分层架构,这里指的是底层请求能力、以及上层业务增强能力完美解耦。底层上采用适配器的思想,使得项目完美的兼容浏览器端和 Node.js 服务端。在上层业务实例配置上,依次读取默认配置、实例配置、方法配置,并从后往上进行覆盖,让实例的可扩展性更灵活、细度更细。请求方法的执行链路上,具备清晰流程设计,当然也得益于axios对数据模型高度抽象,更方便在流程链路上做业务无伤害侵入。比如在,defaults 默认配置项上配置的 adapter 适配器,适配器属于底层请求上的封装,不存在上层业务对其的侵入,因此往往可以对适配器进行增强,在发起真正URL上注入更多的扩展能力,比如:请求失败重发、接口节流,以及我们文末的案例:缓存接口数据。

下面我们从源码上看看 axios 是如何实现拦截器,以及它的适配器思想。

拦截器实现

拦截器赋予了项目开发时注入自定义行为的能力。通常在请求拦截器中实现自定义请求头,在响应拦截器完成对返回数据自定义code的解析,异常统一处理等。axios的拦截器的由注册->编排->执行三部分组成。

下面是项目中请求拦截器的注册执行,那源码是如何实现的呢?

axios.interceptors.request.use(
	config=>{
    // 自定义操作,通过在此新增自定义请求头token
  	return config
  },
  error=>{
  	return Promise.reject(error)
  }
)

挂载拦截器

Axios.interceptors属性挂载拦截器。

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

任务管理设计

InterceptorManager 构造函数处理任务注册、注销等。

function InterceptorManager() {
  this.handlers = [];
}
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;
  }
};

拦截器实例内部维护了 数组属性存放任务列表,每个任务由 fulfilled、rejected 两种处理函数组成。通过调用 函数可以注册任务,并返回当前任务的索引值。拦截器实例可以调用 函数清空对应索引的任务。拦截器还有更多实现,详细可以自行查看代码。

任务编排与执行

拦截器的执行是在axios实例的request方法中,看下源码:

Axios.prototype.request = function request(config) {
  // 省略其他代码
  
  // 连接拦截器中间件
  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;
};

内部通过 数组进行任务的编排, 是发起URL请求封装的方法。遇到请求拦截器时,会遍历其 任务列表,并插入到 数组的头部,遇到响应拦截器便将其添加到任务编排数组的末尾。每个任务是按 在前、 在后放置。

存在编排任务时,依次从前往后执行,因此先执行请求拦截器任务,再是发起请求,最后才是响应拦截器。当遇到失败,执行 ,抛出错误,任务不再往下执行。

适配器思想

axios 广泛应用于浏览器和 Node.js 服务器,那浏览器通常采用 XMLHttpRequest 或者 Fetch 发起请求,而 Node 服务器端则采用Http模块,那 axios 是如何兼容的呢?

答案是:适配器adapter。通过各自环境独有变量判别对象,如浏览器的 XMLHttpRequest ,服务器端的 process,从而返回不同的适配器。

function getDefaultAdapter() {
  var adapter;
  if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  } else if (typeof process !== 'undefined') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  }
  return adapter;
}
适配器是什么?

查看 文件,可以看到适配器只是一个返回 对象的函数!注意这个,后面我们将自定义扩展适配器!内部请求的实现,不是关注重点,感兴趣可以去看源码。

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    // 内部URL请求实现
  }
}

取消重复请求

在浏览器中 XMLHttpRequest 请求可以通过 abort 取消请求。在axios中,同样提供了取消请求的能力,下面看看axios如何取消请求。

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 {
     // 处理错误
  }
});

// 方式二:
let cancel;
axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // executor 函数接收一个 cancel 函数作为参数
    cancel = c;
  })
});
// 取消请求
cancel();

我们知道Axios可以通过 CancelToken 取消请求,并且存在两种取消的方式。那内部的机制是怎样的呢?

CancelToken是什么?

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

  var resolvePromise;
  // 创建个promise对象
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
    if (token.reason) {
      return;
    }
    // 这里的方法,未来某时刻才执行
    // reason 存在值,则取消请求
    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}
// 取消请求时执行方法
CancelToken.prototype.throwIfRequested = function throwIfRequested() {
  if (this.reason) {throw this.reason;}
};

CancelToken.source = function source() {
  var cancel;
  var token = new CancelToken(function executor(c) {cancel = c;});
  return {token: token,cancel: cancel};
};

值得借鉴的是 CancelToken 传入了执行器 executor ,并将内部 cancel 函数作为 executor 的参数暴露出去。这里的 cancel 函数可以在未来的某个时刻执行,当它执行后 CancelToken 实例将挂载上reason属性,值为new Cancel。 此时,当前请求就会被取消。

如何取消请求

// CancelToken.js 
CancelToken.prototype.throwIfRequested = function throwIfRequested() {
  if (this.reason) {throw this.reason;}
};

// dispatchRequest.js 内部方法
function throwIfCancellationRequested(config) {
  if (config.cancelToken) {
    config.cancelToken.throwIfRequested();
  }
}

module.exports = function dispatchRequest(config) {
  throwIfCancellationRequested(config);
  // 后续前往发起请求相关逻辑
}

取消请求是在执行 派发请求时判断配置上是否存在 ,且是否存在 ,即是否执行了 函数。若存在,则直接抛出取消请求的 ,派发请求流程也就不往下走了。不管拦截器、或者取消请求,其实都是在触发适配器前处理,业务层不会响应底层请求适配器。这也是axios库很值得借鉴的地方,严格分层。

案例:缓存接口数据

缓存对实时性要求不严格的接口数据,可以减少一次网络请求,页面响应更加及时。针对此需求,我们可以提前设计出指标,做出更健硕的方案。比如:

通过模型设计之美章节,我们知道发起真正URL请求是在adapter适配器。所以可以考虑通过对默认适配器进行增强拦截,若存在缓存数据,则直接缓存数据,否则进入请求。增强适配器的好处,也更利用axios的扩展性,我们通过覆盖全局修改适配器,或者仅仅变更单个请求适配器,更加灵活。 下面看看详细设计方案。

1. 判断同个请求

当发起请求的URL、和参数是一致的时候,我们可以认为他们是同一个请求,因此可以将两参数转换拼接作为缓存接口数据的key值。看看具体代码:

import buildURL from 'axios/lib/helpers/buildURL';
export default function buildSortedURL(...args: any[]) {
	const builtURL = buildURL(...args);
	const [urlPath, queryString] = builtURL.split('?');
	if (queryString) {
		const paramsPair = queryString.split('&');
		return `${urlPath}?${paramsPair.sort().join('&')}`;
	}
	return builtURL;
}

2. 适配器增强

采用适配器增强

export default function cacheAdapterEnhancer(adapter: AxiosAdapter, options: Options = {}): AxiosAdapter {
	const {
    // 开启默认缓存
		enabledByDefault = true,
    // 自定义缓存标志
		cacheFlag = 'cache',
    // 用于缓存数据
		defaultCache = new LRUCache({ maxAge: FIVE_MINUTES, max: CAPACITY }),
	} = options;

	return config => {
		const { url, method, params, paramsSerializer, forceUpdate } = config;
    // 是否使用缓存
		const useCache = ((config as any)[cacheFlag] !== void 0 && (config as any)[cacheFlag] !== null)
			? (config as any)[cacheFlag]
			: enabledByDefault;
     // 请求
		if (method === 'get' && useCache) {
			// 如果提供指定的缓存,则使用它
			const cache: ICacheLike = isCacheLike(useCache) ? useCache : defaultCache;
			// 构造缓存的key
			const index = buildSortedURL(url, params, paramsSerializer);
      // 获取接口数据缓存
			let responsePromise = cache.get(index);
      // 缓存不存在,或者配置forceUpdate = true不使用缓存,则直接进去请求
			if (!responsePromise || forceUpdate) {
				responsePromise = (async () => {
					try {
						return await adapter(config);
					} catch (reason) {
						cache.del(index);
						throw reason;
					}
				})();
				// 将未转换的响应promise放入缓存
				cache.set(index, responsePromise);
				return responsePromise;
			}
			return responsePromise;
		}
    // 执行默认适配器
		return adapter(config);
	};
}

3. 使用文档

const http = axios.create({
	baseURL: '/',
	adapter: cacheAdapterEnhancer(axios.defaults.adapter,
    { enabledByDefault: false, cacheFlag: 'useCache'})
});

enabledByDefault:启用所有请求的缓存,而无需在请求配置中进行明确定义。

cacheFlag:配置标志以明确定义axios请求中的缓存使用情况。

defaultCache:默认情况下将用于存储请求的CacheLike实例,除非使用请求配置定义自定义Cache。

总结

至此我们便阅读了Axios项目的架构设计亮点、以及拦截器底层实现、适配思想,并通过对默认适配器的增强实现了接口数据缓存功能。当然Axios的功能不仅于此,借鉴增强适配器的思路,还可以实现请求节流、请求失败重发机制、取消重复请求此类常用需求。如果你感兴趣可以查找axios-extension这个库。