axios 设计的可取之处

203 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第29天,点击查看活动详情

1.axios 设计之美

axios 是一个被前端广泛使用的请求库,对应上述分层结构中,属于框架/类库层,我们来总结一下它的功能特点: 在浏览器端,使用 XMLHttpRequest 发送请求;

  • 支持 Node.js 端发送请求;
  • 支持 Promise API,使用 Promise 风格语法;
  • 支持请求和响应拦截;
  • 支持自定义修改请求和返回内容;
  • 支持请求取消;
  • 默认支持 XSRF 防御。

下面,我们主要从拦截器思想、适配器思想、安全思想三方面展开,分析 axios 设计的可取之处。

2.拦截器思想

拦截器思想是 axios 带来的最具启发性的思想之一。它赋予了分层开发时借助拦截行为,注入自定义能力的功能。简单来说,axios 的拦截器主要由:任务注册 → 任务编排 → 任务调度(执行)三步组成。

我们先看任务注册,在请求发出前,可以使用axios.interceptors.request.use方法注入拦截逻辑,比如:

axios.interceptors.request.use(function (config) {

    // 请求发送前做一些事情,比如添加 headers

    return config;

  }, function (error) {

    // 请求出现错误时,处理逻辑

    return Promise.reject(error);

  });

在请求返回后,用axios.interceptors.response.use方法注入拦截逻辑,比如:

axios.interceptors.response.use(function (response) {

    // 响应返回 2xx 时,做一些操作,比如响应状态码为 401 时,自动跳转到登录页

    return response;

  }, function (error) {

    // 响应返回 2xx 外响应码时,错误处理逻辑

    return Promise.reject(error);

  });

任务注册部分的源码实现也不复杂:

// lib/core/Axios.js

function Axios(instanceConfig) {

  this.defaults = instanceConfig;

  this.interceptors = {

    request: new InterceptorManager(),

    response: new InterceptorManager()

  };

}

// lib/core/InterceptorManager.js

function InterceptorManager() {

  this.handlers = [];

}

InterceptorManager.prototype.use = function use(fulfilled, rejected) {

  this.handlers.push({

    fulfilled: fulfilled,

    rejected: rejected

  });

  // 返回当前的索引,用于移除已注册的拦截器

  return this.handlers.length - 1;

};

如上代码,我们定义的请求/响应拦截器,会在每一个 axios 实例的 Interceptors 属性中维护,this.interceptors.requestthis.interceptors.response也都是一个 InterceptorManager 实例,该实例的handlers属性以数组的形式存储了使用方定义的一个个拦截器逻辑。

注册了任务,我们再来看看任务编排时是如何将拦截器串联起来,并在任务调度阶段执行各个拦截器的。如下源码:

// lib/core/Axios.js

Axios.prototype.request = function request(config) {

  config = mergeConfig(this.defaults, 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;

};

我们通过chain数组来编排调度任务,dispatchRequest方法实际执行请求的发送,编排过程实现:在实际发送请求的方法dispatchRequest前插入请求拦截器,在dispatchRequest方法后,插入响应拦截器。

任务调度其实就是通过一个 While 循环,通过一个 Promise 实例,遍历迭代chain数组方法,并基于 Promise 回调特性,将各个拦截器串联执行起来。

3. 适配器思想

axios 同时支持 Node.js 环境和浏览器环境发送请求,在浏览器中我们可以选用 XMLHttpRequest 或 Fetch 方法发送请求,但是在 Node.js 中,需要通过 HTTP 模块发送请求。对此,axiso 是如何设计实现的呢?

为了支持适配不同环境,axios 实现了适配器:Adapter,具体实现在dispatchRequest方法中:

// lib/core/dispatchRequest.js

module.exports = function dispatchRequest(config) {

  // ...

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



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

    // ...

    return response;

  }, function onAdapterRejection(reason) {

    // ...

    return Promise.reject(reason);

  });

};

如上代码,axios 支持使用方实现自己的 Adapter,自定义不同环境中的请求实现方式,也提供了默认的 Adapter。默认 Adapter 逻辑代码如下:

function getDefaultAdapter() {

  var adapter;

  if (typeof XMLHttpRequest !== 'undefined') {

    // 浏览器端使用 XMLHttpRequest 方法

    adapter = require('./adapters/xhr');

  } else if (typeof process !== 'undefined' && 

    Object.prototype.toString.call(process) === '[object process]') {

    // Node.js 端,使用 HTTP 模块

    adapter = require('./adapters/http');

  }

  return adapter;

}

一个 Adapter 需要返回一个 Promise 实例(这是因为axios 内部通过 Promise 链式调用完成请求调度),我们分别看看在浏览器端和 Node.js 端具体 Adapter 实现逻辑:

module.exports = function xhrAdapter(config) {

  return new Promise(function dispatchXhrRequest(resolve, reject) {

    var requestData = config.data;

    var requestHeaders = config.headers;

    var request = new XMLHttpRequest();

    var fullPath = buildFullPath(config.baseURL, config.url);

    request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);



    // Listen for ready state

    request.onreadystatechange = function handleLoad() {

    	// ....

    };

    // Handle browser request cancellation (as opposed to a manual cancellation)

    request.onabort = function handleAbort() {

      // ...

    };

    // Handle low level network errors

    request.onerror = function handleError() {

      // ...

    };

    // Handle timeout

    request.ontimeout = function handleTimeout() {

      // ...

    };

    // ...



    request.send(requestData);

  });

};

如上代码,就是一个典型的使用 XMLHttpRequest 发送请求的实现内容。在 Node.js 端的实现,精简后代码如下:

var http = require('http');

/*eslint consistent-return:0*/

module.exports = function httpAdapter(config) {

  return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {

    var resolve = function resolve(value) {

      resolvePromise(value);

    };

    var reject = function reject(value) {

      rejectPromise(value);

    };

    var data = config.data;

    var headers = config.headers;

    var options = {

      // ...

    };



    var transport = http;

    var req = http.request(options, function handleResponse(res) {

      // ...

    });

    // Handle errors

    req.on('error', function handleRequestError(err) {

      // ...

    });

    // Send the request

    if (utils.isStream(data)) {

      data.on('error', function handleStreamError(err) {

        reject(enhanceError(err, config, null, req));

      }).pipe(req);

    } else {

      req.end(data);

    }
  });
};

上述代码主要是调用 Node.js HTTP 模块,进行请求的发送和处理,当然,真实源码实现还需要考虑 HTTPS 以及 Redirect 等问题,这里我们不再展开。

讲到这里,可能你会问,什么场景下,才会需要自定义 Adapter 进行请求发送呢?比如在测试阶段或特殊环境中,我们可以 mock 请求:

if (isEnv === 'ui-test') {

	adapter = require('axios-mock-adapter')

}

实现一个自定义的 Adapter 也并不困难,说到底它也只是一个 Node.js 模块,导出一个 Promise 实例即可:

module.exports = function myAdapter(config) {

  // ...

  return new Promise(function(resolve, reject) {

    // ...

    sendRequest(resolve, reject, response);

    // ....

  });
}

相信你学会了这些内容,就对 axios-mock-adapter 这个库的实现原理了然于胸了。

4. 安全思想

说到请求,自然关联着安全问题。在本小节最后部分,我们对 axios 中的一些安全机制进行解析,涉及相关攻击手段:CSRF。

Cross—Site Request Forgery,攻击者盗用了你的身份,以你的名义发送恶意请求,对服务器来说,这个请求是完全合法的,但是却完成了攻击者期望的一个操作,比如以你的名义发送邮件、发消息,盗取你的账号,添加系统管理员,甚至购买商品、虚拟货币转账等。

在 axios 中,主要依赖双重 cookie 的方式防御 CSRF。具体来说,对于攻击者,获取用户 cookie 是比较困难的,因此,我们可以在请求中携带一个 cookie 值,来保证请求的安全性。这里我们将相关流程梳理为:

  • 用户访问页面,后端向请求域中注入一个 cookie,一般该 cookie 值为加密随机字符串;
  • 在前端通过 Ajax 请求数据时,取出上述 cookie,添加到 URL 参数或者请求 header 中;
  • 后端接口验证请求中携带的 cookie 值是否合法,不合法(不一致),则拒绝请求。

我们看 axios 源码:

// lib/defaults.js

var defaults = {

  adapter: getDefaultAdapter(),

  // ...

  xsrfCookieName: 'XSRF-TOKEN',

  xsrfHeaderName: 'X-XSRF-TOKEN',

};

在这里,axios 默认配置了默认xsrfCookieNamexsrfHeaderName,实际开发中可以按具体情况传入配置。在具体请求时,以lib/adapters/xhr.js为例:

// 添加 xsrf header

if (utils.isStandardBrowserEnv()) {

  var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?

    cookies.read(config.xsrfCookieName) :

    undefined;

  if (xsrfValue) {

    requestHeaders[config.xsrfHeaderName] = xsrfValue;

  }

}

由此可见,对一个成熟请求库的设计来说,安全防范这个话题永不过时。