記一次axios原始碼排查

Nekron發表於2018-09-28

一、axios介紹

現在社群中有數量龐大的ajax(http)庫,為何選擇使用axios呢?

首先,因為它提供的API是Promise式的,目前業務程式碼基本都已經使用async/await來包裹非同步api了。

那為何不使用基於fetch的類庫呢?

因為,選用axios更重要的原因是,需要用到請求的abort。

abort

大部分場景中如果後端處理開銷不大,前端使用類似Promise.race或標記位等方式都可以實現前端業務邏輯中的abort。但是如果該請求是一個非常重型的,對資料庫讀寫有壓力的請求時,一個實實在在的abort還是有必要的。

當然,可以在後端介面上,設計為建立任務、執行任務、取消任務這樣的模式。

由於目前fetch沒有abort方式(AbortController目前尚在實驗階段),所以只能使用XMLHttpRequest類來實現具備abort能力的ajax。

二、為何解讀?

axios提供了cancel:

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // An executor function receives a cancel function as a parameter
    cancel = c;
  })
});

// cancel the request
cancel();
複製程式碼

實際業務程式碼示意:

axios({
    method: 'get',
    url: '***',
}).then(response => {
    // 業務邏輯
}).catch(err => {
    if (axios.isCancel(err)) {
        // 取消請求
    } else {
        // 業務邏輯錯誤
    }
})
複製程式碼

期望的結果是,當cancel後,會在業務程式碼的catch中捕獲一個Cancel型別的錯誤。但實際使用中,該cancelError並沒有觸發,而是進入了response相關的業務邏輯。

於是,開始了一波debug。一開始懷疑是axios的坑,但當我開啟github,看到該專案**4.8萬+**的star數時,我確信:

一定是業務程式碼用錯了!

三、程式碼

1. 檔案結構

沒有全部細看,把主流程的js看了一遍。

axios/lib
│
└───adpaters
│   │   ... ajax/http類的封裝
│
└───cancel
│   │   ... 取消請求的相關程式碼
│
└───core
│   │
│   └───Axios.js 核心類,其餘方法沒細看
│
└───helpers
│   │   ... 工具函式集,沒看
│
└───axios.js 入口檔案,例項化了核心類
│
└───defaults.js 預設配置
複製程式碼

2. 主流程

  請求發起   
     |
     ▼
+----------+
| req中介軟體 | axios稱之為request interceptors
+----------+
     |
     ▼
+----------+
| dispatch | 發起請求,內部包含了一些入參轉化邏輯,不展開
+----------+
     |
     ▼
+----------+
| Adapter  | 介面卡,根據環境決定使用http還是xhr模組
+----------+
     |
     ▼
+----------+
| res中介軟體 | axios稱之為response interceptors
+----------+
     |
     ▼
+----------+
|transform | 返回值進行一次轉換
+----------+
     |
     ▼
  請求結束
複製程式碼

3. 中介軟體

axios可以通過axios.interceptors來擴充套件request/response的中介軟體:

// Add a request interceptor
axios.interceptors.request.use(function (config) {
    // Do something before request is sent
    return config;
  }, function (error) {
    // Do something with request error
    return Promise.reject(error);
  });

// Add a response interceptor
axios.interceptors.response.use(function (response) {
    // Do something with response data
    return response;
  }, function (error) {
    // Do something with response error
    return Promise.reject(error);
  });
複製程式碼

最後排查結果是某一箇中介軟體出了問題導致的bug,下文再詳細展開,先聚焦在中介軟體相關的原始碼上:

// core/Axios.js  
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());
}
複製程式碼

核心程式碼不長,它的目的是,轉換出一個Promise陣列:

[
    ReqInterceptor_1_success, ReqInterceptor_1_error,
    ReqInterceptor_2_success, ReqInterceptor_2_error,
    ...,
    dispatchRequest, undefined,
    ResInterceptor_1_success, ResInterceptor_1_error,
    ...,
]
複製程式碼

再將該陣列轉換為鏈式的Promise:

return Promise.resolve(
    config,
).then(
    ReqInterceptor_1_success, ReqInterceptor_1_error,     
).then(
    ReqInterceptor_2_success, ReqInterceptor_2_error,
).then(
    dispatchRequest, undefined,
).then(
    ResInterceptor_1_success, ResInterceptor_1_error,
)
複製程式碼

4. 請求取消

先貼一下主要原始碼:

// cancel/CancelToken.js
function CancelToken(executor) {
  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類的建構函式,它的入參需要是一個函式,該函式的第一個入參會返回cancel(message) => void函式,該函式的作用是給CancelToken例項新增一個CancelError型別的reason屬性。

axios有兩個時機來取消請求。

第一種,在dispatchRequest方法中,在發起請求之前,如果cancel函式執行,throwIfCancellationRequested會直接把cancelToken.reason丟擲。

// core/dispatchRequest.js
function dispatchRequest(config) {
    throwIfCancellationRequested(config);
    
    // ...
}
複製程式碼

官網示例中的cancel示例就是這第一種取消方式。實際上,請求並沒有在呼叫諸如axios.get方法時立刻發出,而是在microtask中執行(Event Loop相關文件可查閱此處)。具體原始碼參看上文中介軟體部分,即使沒有任何request中介軟體,請求也是在Promise.resolve(config)的後續中觸發。

第二種,在請求發出以後,如果cancel函式執行,在實際的xhr模組中會觸發abort。

// adapters/xhr.js
config.cancelToken.promise.then(function onCanceled(cancel) {
    // 此處then會在CancelToken的resolvePromise執行後觸發
    request.abort();
    reject(cancel);
});
複製程式碼

四、問題排查

1. 大致思路

確認原始碼以後,CancelError理論上都會被正確throw,並沒有犯比較低階的return new Error('*')問題。(可以想想為什麼~)

既然如此,Error被丟擲,那就一定是半路被捕獲了。

那最有可能的原因是中介軟體出了問題,把CancelError給吞了。

2. 真相

最後確認,的確是有一個responseInterceptor:

axiosInstance.interceptors.response.use((resp: AxiosResponse) => {
    // 
}, (error: AxiosError): void => {
    onResponseError(error);
});

// 而onResponseError是一個空方法
function onResponseError() {};
這會導致整個Promise鏈路變為:
Promise.resolve().then(() => {
    return dispatch();
})
// response中介軟體
.then(data => {
    return transform(data);
}, err => {
    catchError(err); // 1. 沒有繼續丟擲錯誤
}).then(data => {
    // 2. 錯誤被中介軟體捕獲後,進入後續resolved邏輯
}).catch(err => {
    // 3. 無法捕獲cancel錯誤
});
複製程式碼

相關文章