首頁 > 科技

axios 是如何封裝 HTTP 請求的

2021-06-09 13:03:22

作者 | 追公交車的小仙女 責編 | 歐陽姝黎

Axios 毋庸多說大家在前端開發中常用的一個傳送 HTTP 請求的庫,用過的都知道。本文用來整理項目中常用的 Axios 的封裝使用。同時學習源碼,手寫實現 Axios 的核心程式碼。


Axios 常用封裝


是什麼

Axios 是一個基於 promise 的 HTTP 庫,可以用在瀏覽器和 node.js 中。它的特性:

  • 從瀏覽器中創建 XMLHttpRequests
  • 從 node.js 創建 http 請求
  • 支援 Promise API
  • 攔截請求和響應
  • 轉換請求資料和響應資料
  • 取消請求
  • 自動轉換 JSON 資料
  • 客戶端支援防禦 XSRF官網地址:http://www.axios-js.com/zh-cn/docs/#axios-config

Axios 使用方式有兩種:一種是直接使用全局的 Axios 物件;另外一種是通過 axios.create(config) 方法創建一個例項物件,使用該物件。兩種方式的區別是通過第二種方式創建的例項物件更清爽一些;全局的 Axios 物件其實也是創建的例項物件匯出的,它本身上載入了很多預設屬性。後面源碼學習的時候會再詳細說明。

請求

Axios 這個 HTTP 的庫比較靈活,給使用者多種傳送請求的方式,以至於有些混亂。細心整理會發現,全局的 Axios(或者 axios.create(config)創建的物件) 既可以當作物件使用,也可以當作函數使用:

// axios 當作物件使用
axios.request(config)
axios.get(url[, config])
axios.post(url[, data[, config]])
// axios() 當作函數使用。 傳送 POST 請求
axios({
method: 'post',
url: '/user/12345',
data: {
firstName: 'Fred',
lastName: 'Flintstone'
}
});

後面源碼學習的時候會再詳細說明為什麼 Axios 可以實現兩種方式的使用。

取消請求

可以使用 CancelToken.source 工廠方法創建 cancel token:

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 {
// 處理錯誤
}
});

// 取消請求(message 參數是可選的)
source.cancel('Operation canceled by the user.');

source 有兩個屬性:一個是 source.token 標識請求;另一個是 source.cancel() 方法,該方法呼叫後,可以讓 CancelToken 例項的 promise 狀態變為 resolved,從而觸發 xhr 物件的 abort() 方法,取消請求。

攔截

Axios 還有一個奇妙的功能點,可以在傳送請求前對請求進行攔截,對相應結果進行攔截。結合業務場景的話,在中臺系統中完成登入後,獲取到後端返回的 token,可以將 token 新增到 header 中,以後所有的請求自然都會加上這個自定義 header。

//攔截1 請求攔截
instance.interceptors.request.use(function(config){
//在傳送請求之前做些什麼
const token = sessionStorage.getItem('token');
if(token){
const newConfig = {
...config,
headers: {
token: token
}
}
return newConfig;
}else{
return config;
}
}, function(error){
//對請求錯誤做些什麼
return Promise.reject(error);
});

我們還可以利用請求攔截功能實現 取消重複請求,也就是在前一個請求還沒有返回之前,使用者重新發送了請求,需要先取消前一次請求,再發送新的請求。比如搜尋框自動查詢,當用戶修改了內容重新發送請求的時候需要取消前一次請求,避免請求和響應混亂。再比如表單提交按鈕,使用者多次點選提交按鈕,那麼我們就需要取消掉之前的請求,保證只有一次請求的傳送和響應。

實現原理是使用一個物件記錄已經發出去的請求,在請求攔截函數中先判斷這個物件中是否記錄了本次請求資訊,如果已經存在,則取消之前的請求,將本次請求新增進去物件中;如果沒有記錄過本次請求,則將本次請求資訊新增進物件中。最後請求完成後,在響應攔截函數中執行刪除本次請求資訊的邏輯。

// 攔截2   重複請求,取消前一個請求
const promiseArr = {};
instance.interceptors.request.use(function(config){
console.log(Object.keys(promiseArr).length)
//在傳送請求之前做些什麼
let source=null;
if(config.cancelToken){
// config 配置中帶了 source 資訊
source = config.source;
}else{
const CancelToken = axios.CancelToken;
source = CancelToken.source();
config.cancelToken = source.token;
}
const currentKey = getRequestSymbol(config);
if(promiseArr[currentKey]){
const tmp = promiseArr[currentKey];
tmp.cancel("取消前一個請求");
delete promiseArr[currentKey];
promiseArr[currentKey] = source;
}else{
promiseArr[currentKey] = source;
}
return config;

}, function(error){
//對請求錯誤做些什麼
return Promise.reject(error);
});
// 根據 url、method、params 生成唯一標識,大家可以自定義自己的生成規則
function getRequestSymbol(config){
const arr = [];
if(config.params){
const data = config.params;
for(let key of Object.keys(data)){
arr.push(key+"&"+data[key]);
}
arr.sort();
}
return config.url+config.method+arr.join("");
}

instance.interceptors.response.use(function(response){
const currentKey = getRequestSymbol(response.config);
delete promiseArr[currentKey];
return response;
}, function(error){
//對請求錯誤做些什麼
return Promise.reject(error);
});

最後,我們可以在響應攔截函數中統一處理返回碼的邏輯:

// 響應攔截
instance.interceptors.response.use(function(response){
// 401 沒有登入跳轉到登入頁面
if(response.data.code===401){
window.location.href = "http://127.0.0.1:8080/#/login";
}else if(response.data.code===403){
// 403 無許可權跳轉到無許可權頁面
window.location.href = "http://127.0.0.1:8080/#/noAuth";
}
return response;
}, function(error){
//對請求錯誤做些什麼
return Promise.reject(error);
})

檔案下載

通常檔案下載有兩種方式:一種是通過檔案在伺服器上的對外地址直接下載;還有一種是通過介面將檔案以二進位制流的形式下載。

第一種:同域名 下使用 a 標籤下載:

// httpServer.js
const express = require("express");
const path = require('path');
const app = express();

//靜態檔案地址
app.use(express.static(path.join(__dirname, 'public')))
app.use(express.static(path.join(__dirname, '../')));
app.listen(8081, () => {
console.log("伺服器啟動成功!")
});
// index.html
<a href="test.txt" download="test.txt">下載</a>

第二種:二進位制檔案流的形式傳遞,我們直接訪問該介面並不能下載檔案,一定程度保證了資料的安全性。比較多的場景是:後端接收到查詢參數,查詢資料庫然後通過插件動態生成 excel 檔案,以檔案流的方式讓前端下載。

這時候,我們可以將請求檔案下載的邏輯進行封裝。將二進位制檔案流存在 Blob 物件中,再將其轉為 url 物件,最後通過 a 標籤下載。

//封裝下載
export function downLoadFetch(url, params = {}, config={}) {
//取消
const downSource = axios.CancelToken.source();
document.getElementById('downAnimate').style.display = 'block';
document.getElementById('cancelBtn').addEventListener('click', function(){
downSource.cancel("使用者取消下載");
document.getElementById('downAnimate').style.display = 'none';
}, false);
//參數
config.params = params;
//超時時間
config.timeout = config.timeout ? config.timeout : defaultDownConfig.timeout;
//類型
config.responseType = defaultDownConfig.responseType;
//取消下載
config.cancelToken = downSource.token;
return instance.get(url, config).then(response=>{
const content = response.data;
const url = window.URL.createObjectURL(new Blob([content]));
//創建 a 標籤
const link = document.createElement('a');
link.style.display = 'none';
link.href = url;
//檔名 Content-Disposition: attachment; filename=download.txt
const filename = response.headers['content-disposition'].split(";")[1].split("=")[1];
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return {
status: 200,
success: true
}
})
}

https://juejin.cn/post/6878912072780873742

手寫 Axios 核心程式碼

寫了這麼多用法終於到正題了,手寫 Axios 核心程式碼。Axios 這個庫源碼不難閱讀,沒有特別複雜的邏輯,大家可以放心閱讀 。

源碼入口是這樣查詢:在項目 node_modules 目錄下,找到 axios 模組的 package.json 檔案,其中 "main": "index.js", 就是檔案入口。一步步我們可以看到源碼是怎麼串起來的。

模仿上面的目錄結構,我們創建自己的目錄結構:

axios-js
│ index.html

└─lib
adapter.js
Axios.js
axiosInstance.js
CancelToken.js
InterceptorManager.js

Axios 是什麼

上面有提到我們使用的全局 Axios 物件其實也是構造出來的 axios,既可以當物件使用呼叫 get、post 等方法,也可以直接當作函數使用。這是因為全局的 Axios 其實是函數物件 instance 。源碼位置在 axios/lib/axios.js 中。具體程式碼如下:

// axios/lib/axios.js
//創建 axios 例項
function createInstance(defaultConfig) {
var context = new Axios(defaultConfig);
//instance 物件是 bind 返回的函數
var instance = bind(Axios.prototype.request, context);
// Copy axios.prototype to instance
utils.extend(instance, Axios.prototype, context);
// Copy context to instance
utils.extend(instance, context);
return instance;
}

// 例項一個 axios
var axios = createInstance(defaults);

// 向這個例項新增 Axios 屬性
axios.Axios = Axios;

// 向這個例項新增 create 方法
axios.create = function create(instanceConfig) {
return createInstance(mergeConfig(axios.defaults, instanceConfig));
};
// 向這個例項新增 CancelToken 方法
axios.CancelToken = require('./cancel/CancelToken');
// 匯出例項 axios
module.exports.default = axios;

根據上面的源碼,我們可以簡寫一下自己實現 Axios.js 和 axiosInstance.js:

// Axios.js
//Axios 主體
function Axios(config){
}

// 核心方法,傳送請求
Axios.prototype.request = function(config){
}

Axios.prototype.get = function(url, config={}){
return this.request({url: url, method: 'GET', ...config});
}

Axios.prototype.post = function(url, data, config={}){
return this.request({url: url, method: 'POST', data: data, ...config})
}
export default Axios;

在 axiosInstance.js 檔案中,例項化一個 Axios 得到 context,再將原型物件上的方法繫結到 instance 物件上,同時將 context 的屬性新增到 instance 上。這樣 instance 就成為了一個函數物件。既可以當作物件使用,也可以當作函數使用。

// axiosInstance.js
//創建例項
function createInstance(config){
const context = new Axios(config);
var instance = Axios.prototype.request.bind(context);
//將 Axios.prototype 屬性擴展到 instance 上
for(let k of Object.keys(Axios.prototype)){
instance[k] = Axios.prototype[k].bind(context);
}
//將 context 屬性擴展到 instance 上
for(let k of Object.keys(context)){
instance[k] = context[k]
}
return instance;
}

const axios = createInstance({});
axios.create = function(config){
return createInstance(config);
}
export default axios;

也就是說 axios.js 中匯出的 axios 物件並不是 new Axios() 方法返回的物件 context,而是 Axios.prototype.request.bind(context) 執行返回的 instance,通過遍歷 Axios.prototype 並改變其 this 指向到 context;遍歷 context 物件讓 instance 物件具有 context 的所有屬性。這樣 instance 物件就無敵了, 既擁有了 Axios.prototype 上的所有方法,又具有了 context 的所有屬性。

請求實現

我們知道 Axios 在瀏覽器中會創建 XMLHttpRequest 物件,在 node.js 環境中創建 http 傳送請求。Axios.prototype.request() 是傳送請求的核心方法,這個方法其實呼叫的是 dispatchRequest 方法,而 dispatchRequest 方法呼叫的是 config.adapter || defaults.adapter 也就是自定義的 adapter 或者預設的 defaults.adapter,預設defaults.adapter 呼叫的是 getDefaultAdapter 方法,源碼:

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;
}

哈哈哈,getDefaultAdapter 方法最終根據當前的環境返回不同的實現方法,這裡用到了 介面卡模式。我們只用實現 xhr 傳送請求即可:

//介面卡 adapter.js
function getDefaultAdapter(){
var adapter;
if(typeof XMLHttpRequest !== 'undefined'){
//匯入 XHR 物件請求
adapter = (config)=>{
return xhrAdapter(config);
}
}
return adapter;
}
function xhrAdapter(config){
return new Promise((resolve, reject)=>{
var xhr = new XMLHttpRequest();
xhr.open(config.method, config.url, true);
xhr.send();
xhr.onreadystatechange = ()=>{
if(xhr.readyState===4){
if(xhr.status>=200&&xhr.status<300){
resolve({
data: {},
status: xhr.status,
statusText: xhr.statusText,
xhr: xhr
})
}else{
reject({
status: xhr.status
})
}
}
};
})
}
export default getDefaultAdapter;

這樣就理順了,getDefaultAdapter 方法每次執行會返回一個 Promise 物件,這樣 Axios.prototype.request 方法可以得到執行 xhr 傳送請求的 Promise 物件。

給我們的 Axios.js 添加發送請求的方法:

//Axios.js
import getDefaultAdapter from './adapter.js';
Axios.prototype.request = function(config){
const adapter = getDefaultAdapter(config);
var promise = Promise.resolve(config);
var chain = [adapter, undefined];
while(chain.length){
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
}

攔截器實現

攔截器的原理在於 Axios.prototype.request 方法中的 chain 陣列,把請求攔截函數新增到 chain 陣列前面,把響應攔截函數新增到陣列後面。這樣就可以實現傳送前攔截和響應後攔截的效果。

創建 InterceptorManager.js

//InterceptorManager.js 
//攔截器
function InterceptorManager(){
this.handlers = [];
}
InterceptorManager.prototype.use = function(fulfilled, rejected){
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length -1;
}

export default InterceptorManager;

在 Axios.js 檔案中,建構函式有 interceptors屬性:

//Axios.js
function Axios(config){
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
}
}

這樣我們在 Axios.prototype.request 方法中對攔截器新增處理:

//Axios.js
Axios.prototype.request = function(config){
const adapter = getDefaultAdapter(config);
var promise = Promise.resolve(config);
var chain = [adapter, undefined];
//請求攔截
this.interceptors.request.handlers.forEach(item=>{
chain.unshift(item.rejected);
chain.unshift(item.fulfilled);

});
//響應攔截
this.interceptors.response.handlers.forEach(item=>{
chain.push(item.fulfilled);
chain.push(item.rejected)
});
console.dir(chain);
while(chain.length){
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
}

所以攔截器的執行順序是:請求攔截2 -> 請求攔截1 -> 傳送請求 -> 響應攔截1 -> 響應攔截2

取消請求

來到 Axios 最精彩的部分了,取消請求。我們知道 xhr 的 xhr.abort(); 函數可以取消請求。那麼什麼時候執行這個取消請求的操作呢?得有一個訊號告訴 xhr 物件什麼時候執行取消操作。取消請求就是未來某個時候要做的事情,你能想到什麼呢?對,就是 Promise。Promise 的 then 方法只有 Promise 物件的狀態變為 resolved 的時候才會執行。我們可以利用這個特點,在 Promise 物件的 then 方法中執行取消請求的操作。看程式碼:

//CancelToken.js
// 取消請求
function CancelToken(executor){
if(typeof executor !== 'function'){
throw new TypeError('executor must be a function.')
}
var resolvePromise;
this.promise = new Promise((resolve)=>{
resolvePromise = resolve;
});
executor(resolvePromise)
}
CancelToken.source = function(){
var cancel;
var token = new CancelToken((c)=>{
cancel = c;
})
return {
token,
cancel
};
}
export default CancelToken;

當我們執行 const source = CancelToken.source()的時候,source 物件有兩個欄位,一個是 token 物件,另一個是 cancel 函數。在 xhr 請求中:

//介面卡
// adapter.js
function xhrAdapter(config){
return new Promise((resolve, reject)=>{
...
//取消請求
if(config.cancelToken){
// 只有 resolved 的時候才會執行取消操作
config.cancelToken.promise.then(function onCanceled(cancel){
if(!xhr){
return;
}
xhr.abort();
reject("請求已取消");
// clean up xhr
xhr = null;
})
}
})
}

CancelToken 的建構函式中需要傳入一個函數,而這個函數的作用其實是為了將能控制內部 Promise 的 resolve 函數暴露出去,暴露給 source 的 cancel 函數。這樣內部的 Promise 狀態就可以通過 source.cancel() 方法來控制啦,秒啊~

node 後端介面

node 後端簡單的介面程式碼:

const express = require("express");
const bodyParser = require('body-parser');
const app = express();
const router = express.Router();
//檔案下載
const fs = require("fs");
// get 請求
router.get("/getCount", (req, res)=>{
setTimeout(()=>{
res.json({
success: true,
code: 200,
data: 100
})
}, 1000)
})


// 二進位制檔案流
router.get('/downFile', (req, res, next) => {
var name = 'download.txt';
var path = './' + name;
var size = fs.statSync(path).size;
var f = fs.createReadStream(path);
res.writeHead(200, {
'Content-Type': 'application/force-download',
'Content-Disposition': 'attachment; filename=' + name,
'Content-Length': size
});
f.pipe(res);
})

// 設定跨域訪問
app.all("*", function (request, response, next) {
// 設定跨域的域名,* 代表允許任意域名跨域;http://localhost:8080 表示前端請求的 Origin 地址
response.header("Access-Control-Allow-Origin", "http://127.0.0.1:5500");
//設定請求頭 header 可以加那些屬性
response.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With');
//暴露給 axios https://blog.csdn.net/w345731923/article/details/114067074
response.header("Access-Control-Expose-Headers", "Content-Disposition");
// 設定跨域可以攜帶 Cookie 資訊
response.header('Access-Control-Allow-Credentials', "true");
//設定請求頭哪些方法是合法的
response.header(
"Access-Control-Allow-Methods",
"PUT,POST,GET,DELETE,OPTIONS"
);
response.header("Content-Type", "application/json;charset=utf-8");
next();
});

// 介面資料解析
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({
extended: false
}))
app.use('/api', router) // 路由註冊

app.listen(8081, () => {
console.log("伺服器啟動成功!")
});

git 地址

如果大家能夠跟著源碼敲一遍,相信一定會有很多收穫。

手寫 Axios 核心程式碼 github 地址:https://github.com/YY88Xu/axios-js
Axios 封裝:https://github.com/YY88Xu/vue2-component


IT145.com E-mail:sddin#qq.com