源碼拾遺系列(一):Axios

這是 源碼拾遺系列 的第一篇文章,閱讀完本文,下面的問題會迎刃而解,html

  • Axios 的適配器原理是什麼?
  • Axios 是如何實現請求和響應攔截的?
  • Axios 取消請求的實現原理?
  • CSRF 的原理是什麼?Axios 是如何防範客戶端 CSRF 攻擊?
  • 請求和響應數據轉換是怎麼實現的?

全文約兩千字,閱讀完大約須要 6 分鐘,文中 Axios 版本爲 0.21.1node

咱們以特性做爲入口,解答上述問題的同時一塊兒感覺下 Axios 源碼極簡封裝的藝術。react

Features

  • 從瀏覽器建立 XMLHttpRequest
  • 從 Node.js 建立 HTTP 請求
  • 支持 Promise API
  • 攔截請求與響應
  • 取消請求
  • 自動裝換 JSON 數據
  • 支持客戶端 XSRF 攻擊

前兩個特性解釋了爲何 Axios 能夠同時用於瀏覽器和 Node.js 的緣由,簡單來講就是經過判斷是服務器仍是瀏覽器環境,來決定使用 XMLHttpRequest 仍是 Node.js 的 HTTP 來建立請求,這個兼容的邏輯被叫作適配器,對應的源碼在 lib/defaults.js 中,ios

// defaults.js
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;
}
複製代碼

以上是適配器的判斷邏輯,經過偵測當前環境的一些全局變量,決定使用哪一個 adapter。 其中對於 Node 環境的判斷邏輯在咱們作 ssr 服務端渲染的時候,也能夠複用。接下來咱們來看一下 Axios 對於適配器的封裝。git

Adapter xhr

定位到源碼文件 lib/adapters/xhr.js,先來看下總體結構,github

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    // ...
  })
}
複製代碼

導出了一個函數,接受一個配置參數,返回一個 Promise。咱們把關鍵的部分提取出來,web

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    var requestData = config.data;

    var request = new XMLHttpRequest();

    request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true);
    request.onreadystatechange = function handleLoad() {}
    request.onabort = function handleAbort() {}
    request.onerror = function handleError() {}
    request.ontimeout = function handleTimeout() {}

    request.send(requestData);
  });
};
複製代碼

是否是感受很熟悉?沒錯,這就是 XMLHttpRequest 的使用姿式呀,先建立了一個 xhr 而後 open 啓動請求,監聽 xhr 狀態,而後 send 發送請求。咱們來展開看一下 Axios 對於 onreadystatechange 的處理,spring

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;
};
複製代碼

首先對狀態進行過濾,只有當請求完成時(readyState === 4)才往下處理。 須要注意的是,若是 XMLHttpRequest 請求出錯,大部分的狀況下咱們能夠經過監聽 onerror 進行處理,可是也有一個例外:當請求使用文件協議(file://)時,儘管請求成功了可是大部分瀏覽器也會返回 0 的狀態碼。json

Axios 針對這個例外狀況也作了處理。axios

請求完成後,就要處理響應了。這裏將響應包裝成一個標準格式的對象,做爲第三個參數傳遞給了 settle 方法,settle 在 lib/core/settle.js 中定義,

function settle(resolve, reject, response) {
  var validateStatus = response.config.validateStatus;
  if (!response.status || !validateStatus || validateStatus(response.status)) {
    resolve(response);
  } else {
    reject(createError(
      'Request failed with status code ' + response.status,
      response.config,
      null,
      response.request,
      response
    ));
  }
};
複製代碼

settle 對 Promise 的回調進行了簡單的封裝,確保調用按必定的格式返回。

以上就是 xhrAdapter 的主要邏輯,剩下的是對請求頭,支持的一些配置項以及超時,出錯,取消請求等回調的簡單處理,其中對於 XSRF 攻擊的防範是經過請求頭實現的。

咱們先來簡單回顧下什麼是 XSRF (也叫 CSRF跨站請求僞造)。

CSRF

背景:用戶登陸後,須要存儲登陸憑證保持登陸態,而不用每次請求都發送帳號密碼。

怎麼樣保持登陸態呢?

目前比較常見的方式是,服務器在收到 HTTP請求後,在響應頭裏添加 Set-Cookie 選項,將憑證存儲在 Cookie 中,瀏覽器接受到響應後會存儲 Cookie,根據瀏覽器的同源策略,下次向服務器發起請求時,會自動攜帶 Cookie 配合服務端驗證從而保持用戶的登陸態。

因此若是咱們沒有判斷請求來源的合法性,在登陸後經過其餘網站向服務器發送了僞造的請求,這時攜帶登陸憑證的 Cookie 就會隨着僞造請求發送給服務器,致使安全漏洞,這就是咱們說的 CSRF,跨站請求僞造。

因此防範僞造請求的關鍵就是檢查請求來源,refferer 字段雖然能夠標識當前站點,可是不夠可靠,如今業界比較通用的解決方案仍是在每一個請求上附帶一個 anti-CSRF token,這個的原理是攻擊者沒法拿到 Cookie,因此咱們能夠經過對 Cookie 進行加密(好比對 sid 進行加密),而後配合服務端作一些簡單的驗證,就能夠判斷當前請求是否是僞造的。

Axios 簡單地實現了對特殊 csrf token 的支持,

// 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()) {
  // Add xsrf header
  var xsrfValue = (config.withCredentials || isURLSameOrigin(fullPath)) && config.xsrfCookieName ?
    cookies.read(config.xsrfCookieName) :
    undefined;

  if (xsrfValue) {
    requestHeaders[config.xsrfHeaderName] = xsrfValue;
  }
}
複製代碼

Interceptor

攔截器是 Axios 的一個特點 Feature,咱們先簡單回顧下使用方式,

// 攔截器能夠攔截請求或響應
// 攔截器的回調將在請求或響應的 then 或 catch 回調前被調用
var instance = axios.create(options);

var requestInterceptor = axios.interceptors.request.use(
  (config) => {
    // do something before request is sent
    return config;
  },
  (err) => {
    // do somthing with request error
    return Promise.reject(err);
  }
);

// 移除已設置的攔截器
axios.interceptors.request.eject(requestInterceptor)
複製代碼

那麼攔截器是怎麼實現的呢?

定位到源碼 lib/core/Axios.js 第 14 行,

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

經過 Axios 的構造函數能夠看到,攔截器 interceptors 中的 request 和 response 二者都是一個叫作 InterceptorManager 的實例,這個 InterceptorManager 是什麼?

定位到源碼 lib/core/InterceptorManager.js

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

InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected,
    synchronous: options ? options.synchronous : false,
    runWhen: options ? options.runWhen : null
  });
  return this.handlers.length - 1;
};

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

InterceptorManager.prototype.forEach = function forEach(fn) {
  utils.forEach(this.handlers, function forEachHandler(h) {
    if (h !== null) {
      fn(h);
    }
  });
};
複製代碼

InterceptorManager 是一個簡單的事件管理器,實現了對攔截器的管理,

經過 handlers 存儲攔截器,而後提供了添加,移除,遍歷執行攔截器的實例方法,存儲的每個攔截器對象都包含了做爲 Promise 中 resolve 和 reject 的回調以及兩個配置項。

值得一提的是,移除方法是經過直接將攔截器對象設置爲 null 實現的,而不是 splice 剪切數組,遍歷方法中也增長了相應的 null 值處理。這樣作一方面使得每一項ID保持爲項的數組索引不變,另外一方面也避免了從新剪切拼接數組的性能損失。

攔截器的回調會在請求或響應的 then 或 catch 回調前被調用,這是怎麼實現的呢?

回到源碼 lib/core/Axios.js 中第 27 行,Axios 實例對象的 request 方法,

咱們提取其中的關鍵邏輯以下,

Axios.prototype.request = function request(config) {
  // Get merged config
  // Set config.method
  // ...
  var requestInterceptorChain = [];
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

	var responseInterceptorChain = [];
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
  });

  var promise;

  var chain = [dispatchRequest, undefined];

  Array.prototype.unshift.apply(chain, requestInterceptorChain);

  chain.concat(responseInterceptorChain);

  promise = Promise.resolve(config);

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

  return promise;
};
複製代碼

能夠看到,當執行 request 時,實際的請求(dispatchRequest)和攔截器是經過一個叫 chain 的隊列來管理的。整個請求的邏輯以下,

  1. 首先初始化請求和響應的攔截器隊列,將 resolve,reject 回調依次放入隊頭
  2. 而後初始化一個 Promise 用來執行回調,chain 用來存儲和管理實際請求和攔截器
  3. 將請求攔截器放入 chain 隊頭,響應攔截器放入 chain 隊尾
  4. 隊列不爲空時,經過 Promise.then 的鏈式調用,依次將請求攔截器,實際請求,響應攔截器出隊
  5. 最後返回鏈式調用後的 Promise

這裏的實際請求是對適配器的封裝,請求和響應數據的轉換都在這裏完成。

那麼數據轉換是如何實現的呢?

Transform data

定位到源碼 lib/core/dispatchRequest.js

function dispatchRequest(config) {
  throwIfCancellationRequested(config);

  // Transform request data
  config.data = transformData(
    config.data,
    config.headers,
    config.transformRequest
  );
  
  var adapter = config.adapter || defaults.adapter;

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

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

    return response;
  }, function onAdapterRejection(reason) {
    if (!isCancel(reason)) {
      throwIfCancellationRequested(config);

      // Transform response data
      if (reason && reason.response) {
        reason.response.data = transformData(
          reason.response.data,
          reason.response.headers,
          config.transformResponse
        );
      }
    }

    return Promise.reject(reason);
  });
};
複製代碼

這裏的 throwIfCancellationRequested 方法用於取消請求,關於取消請求稍後咱們再討論,能夠看到發送請求是經過調用適配器實現的,在調用前和調用後會對請求和響應數據進行轉換。

轉換經過 transformData 函數實現,它會遍歷調用設置的轉換函數,轉換函數將 headers 做爲第二個參數,因此咱們能夠根據 headers 中的信息來執行一些不一樣的轉換操做,

// 源碼 core/transformData.js
function transformData(data, headers, fns) {
  utils.forEach(fns, function transform(fn) {
    data = fn(data, headers);
  });

  return data;
};
複製代碼

Axios 也提供了兩個默認的轉換函數,用於對請求和響應數據進行轉換。默認狀況下,

Axios 會對請求傳入的 data 作一些處理,好比請求數據若是是對象,會序列化爲 JSON 字符串,響應數據若是是 JSON 字符串,會嘗試轉換爲 JavaScript 對象,這些都是很是實用的功能,

對應的轉換器源碼能夠在 lib/default.js 的第 31 行找到,

var defaults = {
	// Line 31
  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) {
    var result = data;
    if (utils.isString(result) && result.length) {
      try {
        result = JSON.parse(result);
      } catch (e) { /* Ignore */ }
    }
    return result;
  }],
}
複製代碼

咱們說 Axios 是支持取消請求的,怎麼個取消法呢?

CancelToken

其實不論是瀏覽器端的 xhr 或 Node.js 裏 http 模塊的 request 對象,都提供了 abort 方法用於取消請求,因此咱們只須要在合適的時機調用 abort 就能夠實現取消請求了。

那麼,什麼是合適的時機呢?控制權交給用戶就合適了。因此這個合適的時機應該由用戶決定,也就是說咱們須要將取消請求的方法暴露出去,Axios 經過 CancelToken 實現取消請求,咱們來一塊兒看下它的姿式。

首先 Axios 提供了兩種方式建立 cancel token,

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

// 方式一,使用 CancelToken 實例提供的靜態屬性 source
axios.post("/user/12345", { name: "monch" }, { cancelToken: source.token });
source.cancel();

// 方式二,使用 CancelToken 構造函數本身實例化
let cancel;

axios.post(
  "/user/12345",
  { name: "monch" },
  {
    cancelToken: new CancelToken(function executor(c) {
      cancel = c;
    }),
  }
);

cancel();
複製代碼

到底什麼是 CancelToken?定位到源碼 lib/cancel/CancelToken.js 第 11 行,

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);
  });
}
複製代碼

CancelToken 就是一個由 promise 控制的極簡的狀態機,實例化時會在實例上掛載一個 promise,這個 promise 的 resolve 回調暴露給了外部方法 executor,這樣一來,咱們從外部調用這個 executor方法後就會獲得一個狀態變爲 fulfilled 的 promise,那有了這個 promise 後咱們如何取消請求呢?

是否是隻要在請求時拿到這個 promise 實例,而後在 then 回調裏取消請求就能夠了?

定位到適配器的源碼 lib/adapters/xhr.js 第 158 行,

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

    request.abort();
    reject(cancel);
    // Clean up request
    request = null;
  });
}
複製代碼

以及源碼 lib/adaptors/http.js 第 291 行,

if (config.cancelToken) {
  // Handle cancellation
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (req.aborted) return;

    req.abort();
    reject(cancel);
  });
}
複製代碼

果真如此,在適配器裏 CancelToken 實例的 promise 的 then 回調裏調用了 xhr 或 http.request 的 abort 方法。試想一下,若是咱們沒有從外部調用取消 CancelToken 的方法,是否是意味着 resolve 回調不會執行,適配器裏的 promise 的 then 回調也不會執行,就不會調用 abort 取消請求了。

小結

Axios 經過適配器的封裝,使得它能夠在保持同一套接口規範的前提下,同時用在瀏覽器和 node.js 中。源碼中大量使用 Promise 和閉包等特性,實現了一系列的狀態控制,其中對於攔截器,取消請求的實現體現了其極簡的封裝藝術,值得學習和借鑑。

參考連接

寫在最後

本文首發於個人 博客,才疏學淺,不免有錯誤,文章有誤之處還望不吝指正!

若是有疑問或者發現錯誤,能夠在相應的 issues 進行提問或勘誤

若是喜歡或者有所啓發,歡迎 star,對做者也是一種鼓勵

(完)

相關文章
相關標籤/搜索