一文讀懂 Axios 核心源碼思想

閱讀完本文,下面的問題會迎刃而解,

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

我們以特性作爲入口,解答上述問題的同時一起感受下 Axios 源碼極簡封裝的藝術。

Features

前兩個特性解釋了爲什麼 Axios 可以同時用於瀏覽器和 Node.js 的原因,簡單來說就是通過判斷是服務器還是瀏覽器環境,來決定使用 XMLHttpRequest 還是 Node.js 的 HTTP 來創建請求,這個兼容的邏輯被叫做適配器,對應的源碼在 lib/defaults.js 中,

// 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 對於適配器的封裝。

Adapter xhr

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

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

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

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 的處理,

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 的狀態碼。

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 和閉包等特性,實現了一系列的狀態控制,其中對於攔截器,取消請求的實現體現了其極簡的封裝藝術,值得學習和借鑑。

參考鏈接

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/aQSrjClavo2Yiukcauw8SQ