axios 是如何封裝 HTTP 請求的

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

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

Axios 常用封裝

是什麼

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

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

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