【源碼拾遺】axios —— 極簡封裝的藝術
本文分析了axios是如何通過樸素而簡單的方式,實現了網路請求封裝中的各種擴展功能的
歡迎關注專欄熵與單子的代碼本
封裝,是程序員除了造輪子之外最喜歡做的事了。各種API、庫要想用得順手,一般都要根據實際需求進行一層封裝,以便優化一些粗糙的調用方式,添加一些擴展的實用功能。
一個好的封裝,除了滿足應用場景、具備通用性易用性外,特別重要的一個要求就是:
薄
用最少的代碼,既要實現調用優化和功能擴展,又不能增加過多體積和性能的負擔。
很多經典的框架和庫都為「薄」提供了很好的範例。
Axios是近年來備受推崇的一個網路請求庫,它以基於Promise的方式封裝了瀏覽器的XMLHttpRequest和伺服器端node http請求,使得我們可以用es6推薦的非同步方式處理網路請求。
為何axios能得到大家的青睞,在不同的生態圈都有優異的表現呢?在官方文檔中,是這樣介紹它的feature的:
- 從瀏覽器創建XMLHttpRequest
- 從node.js創建http請求
- 支持Promise API
- 攔截請求與響應
- 轉換請求與響應數據
- 取消請求
- 自動轉換JSON數據
- 支持客戶端XSRF攻擊防護
可以看到,除了Promise封裝網路請求的基本功能外,它還實現了多個實用的擴展功能,但最終打包出來的axios.min.js文件體積只有13KB,確實是做到了「薄」。
下面本文將帶學習賞鑒axios的源碼是如何通過樸素而巧妙的方式實現攔截請求與響應、轉換請求與響應數據、自動轉換JSON數據、支持客戶端XSRF攻擊防護等功能的,有以下兩點說明:
- 本文不在分析XMLHttpRequest具體封裝的代碼,作為歷史的特殊產物XMLHttpRequest API的設計較為粗糙,不具有太多借鑒學習意義,且已逐漸被Fetch API替代
- 取消請求這一功能不做分析,該功能是基於cancelable promises的提案實現的,該提案已被撤回,開發組目前暫停了該功能的更新支持,今後將換用其他方式(如果有的話)實現:
攔截請求與響應
功能的使用
首先我們來看文檔中關於axios攔截器的使用,axios攔截器分為請求攔截器和響應攔截器。用戶可以通過then方法為請求添加回調,而攔截器中的回調將在then中的回調之前執行:
// 添加請求攔截器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); });// 添加響應攔截器axios.interceptors.response.use(function (response) { // Do something with response data return response; }, function (error) { // Do something with response error return Promise.reject(error); });
移除已經設置的攔截器
var myInterceptor = axios.interceptors.request.use(function () {/*...*/});axios.interceptors.request.eject(myInterceptor);
給自定義的axios實例添加攔截器
var instance = axios.create();instance.interceptors.request.use(function () {/*...*/});
Promise then() 方法回顧
axios作為基於Promise的請求封裝,其攔截器是通過then方法實現的,通過阮一峰老師的教程(怎麼又是他...),我們先簡單回顧一下該方法:
Promise 實例具有then方法,也就是說,then方法是定義在原型對象Promise.prototype上的。它的作用是為 Promise 實例添加狀態改變時的回調函數。前面說過,then方法的第一個參數是Resolved狀態的回調函數,第二個參數(可選)是Rejected狀態的回調函數。
then方法返回的是一個新的Promise實例(注意,不是原來那個Promise實例)。因此可以採用鏈式寫法,即then方法後面再調用另一個then方法。
getJSON("/posts.json").then(function(json) { return json.post;}).then(function(post) { // ...});
上面的代碼使用then方法,依次指定了兩個回調函數。第一個回調函數完成以後,會將返回結果作為參數,傳入第二個回調函數。
採用鏈式的then,可以指定一組按照次序調用的回調函數。這時,前一個回調函數,有可能返回的還是一個Promise對象(即有非同步操作),這時後一個回調函數,就會等待該Promise對象的狀態發生變化,才會被調用。
getJSON("/post/1.json").then(function(post) { return getJSON(post.commentURL);}).then(function funcA(comments) { console.log("Resolved: ", comments);}, function funcB(err){ console.log("Rejected: ", err);});
上面代碼中,第一個then方法指定的回調函數,返回的是另一個Promise對象。這時,第二個then方法指定的回調函數,就會等待這個新的Promise對象狀態發生變化。如果變為Resolved,就調用funcA,如果狀態變為Rejected,就調用funcB。
源碼分析
通過Axios的構造函數,我們可以看出,interceptors中的request和response兩者都是通過一個InterceptorManager的對象進行管理:
libcoreAxios.js L15
function Axios(instanceConfig) { this.defaults = instanceConfig; this.interceptors = { request: new InterceptorManager(), response: new InterceptorManager() };}
來到InterceptorManager構造函數的定義文件:
libcoreInterceptorManager.js L5
function InterceptorManager() { this.handlers = [];}/** * 在棧中添加interceptor * @param {Function} fulfilled 作為Promise的resolve回調 * @param {Function} rejected 作為Promise的reject回調 * @return {Number} interceptor ID以便移除時使用 */InterceptorManager.prototype.use = function use(fulfilled, rejected) { this.handlers.push({ fulfilled: fulfilled, rejected: rejected }); return this.handlers.length - 1;};/** * 從棧中移除interceptor * @param {Number} id use方法返回的ID */InterceptorManager.prototype.eject = function eject(id) { if (this.handlers[id]) { this.handlers[id] = null; }};/** * 遍歷所有的interceptor,會跳過已移除的interceptor * @param {Function} fn 遍歷執行的回調 */InterceptorManager.prototype.forEach = function forEach(fn) { utils.forEach(this.handlers, function forEachHandler(h) { if (h !== null) { fn(h); } });};module.exports = InterceptorManager;
InterceptorManager的對象中是通過handlers數組變數存儲攔截器,數組每項同時包含了分別作為Promise中resolve和reject的回調。InterceptorManager類中還包含了對該數組變數的添加、移除、遍歷方法。
值得一提的是,移除方法是通過直接將該項設為null實現的,而不是用splice剪切該數組,遍歷方法中也增加了相應的null值處理。這樣做一方面使得每一項ID保持為項的數組索引不變,另一方面也避免了重新剪切拼接數組的性能損失。
InterceptorManager的具體使用依然要回到Axios類的定義中來:
libcoreAxios.js L46
Axios.prototype.request = function request(config) { // 掛載interceptor中間件 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()); } return promise;};
當執行request的時候,實際請求(dispatchRequest)、請求攔截、響應攔截通過一個數組變數chain來存儲和管理,最終都通過then方法添加到promise中,具體的存儲和掛載過程如下圖:
通過巧妙的利用unshift、push、shift等數組隊列、棧方法,實現了請求攔截、執行請求、響應攔截的流程設定,注意無論是請求攔截還是響應攔截,越先添加的攔截器總是越「貼近」執行請求本身。
轉換請求與響應數據
功能使用
通過transformRequest和transformResponse,可鏈式的傳遞函數數組對數據進行轉換:
{ // 轉換請求數據,注意只有"PUT", "POST", 和"PATCH"有請求體 // 最後一個轉換函數必須返回合法的請求數據類型 transformRequest: [function (data) { // Do whatever you want to transform the data return data; }], // 轉換返回數據,將在then回調之前執行 transformResponse: [function (data) { // Do whatever you want to transform the data return data; }],}
源碼分析
在實際執行請求的transformResponse中,會調用transformData函數,對請求和響應數據進行轉換:
libcoredispatchRequest.js L30, L56, L69
module.exports = function dispatchRequest(config) { // Transform request data config.data = transformData( config.data, config.headers, config.transformRequest ); return adapter(config).then(function onAdapterResolution(response) { // Transform response data response.data = transformData( response.data, response.headers, config.transformResponse ); return response; }, function onAdapterRejection(reason) { if (!isCancel(reason)) { // Transform response data if (reason && reason.response) { reason.response.data = transformData( reason.response.data, reason.response.headers, config.transformResponse ); } } return Promise.reject(reason); });};
在transformData函數中會遍歷調用設置的函數數組中的各個函數:
libcore ransformData.js L13
module.exports = function transformData(data, headers, fns) { utils.forEach(fns, function transform(fn) { data = fn(data, headers); }); return data;};
值得一提的是,通過閱讀源碼可知,用戶設置的轉換函數,可將headers作為第二個參數,這樣在轉換函數中可以根據headers中的信息執行不同的操作。
自動轉換JSON數據
在默認情況下,axios將會自動的將傳入的data對象序列化為JSON字元串,將響應數據中的JSON字元串轉換為JavaScript對象。這是一個非常實用的功能,但實現起來非常簡單:
libdefaults.js L28
var defaults = { transformRequest: [function transformRequest(data, headers) { if (utils.isObject(data)) { setContentTypeIfUnset(headers, "application/json;charset=utf-8"); return JSON.stringify(data); } return data; }], transformResponse: [function transformResponse(data) { if (typeof data === "string") { try { data = JSON.parse(data); } catch (e) { /* Ignore */ } } return data; }],};
支持客戶端XSRF攻擊防護
XSRF攻擊,即「跨站請求偽造」(Cross Site Request Forgery)攻擊。通過竊取用戶cookie,讓用戶在本機(即擁有身份 cookie 的瀏覽器端)發起用戶所不知道的請求。防護XSRF攻擊的一種方法是設置特殊的xsrf token,axios實現了對這種方法的支持:
libdefaults.js L68
{ // `xsrfCookieName` is the name of the cookie to use as a value for xsrf token xsrfCookieName: "XSRF-TOKEN", // default // `xsrfHeaderName` is the name of the http header that carries the xsrf token value xsrfHeaderName: "X-XSRF-TOKEN", // default}
libadaptersxhr.js L108
module.exports = function xhrAdapter(config) { return new Promise(function dispatchXhrRequest(resolve, reject) { // 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()) { var cookies = require("./../helpers/cookies"); // Add xsrf header var xsrfValue = (config.withCredentials || isURLSameOrigin(config.url)) && config.xsrfCookieName ? cookies.read(config.xsrfCookieName) : undefined; if (xsrfValue) { requestHeaders[config.xsrfHeaderName] = xsrfValue; } } });};
除了以上feature中列出的主要擴展功能外,axios還提供了諸如內置的jwt、錯誤處理等其他實用功能。
總結
我們可以看到,axios在實現封裝網路請求所需的各項擴展功能時,都是使用的最樸素JavaScript源生方法,並且總是通過簡單的鏈式then方法將這些功能與核心的promise對象關聯。此外各種優化性能的方法,也都是採用的很基本的原理。
這對於現在前端離了工具庫就寫不了代碼的現狀,很有啟發意義。
推薦閱讀:
※QQ音樂播放器簡易開發
※【連載】Web應用到底是如何工作的?
※為什麼要禁止跨域的 Ajax 請求?
※開了N個知乎窗口,標題都有(1 條消息),點開其中一個窗口的消息提示後,(1 條消息)消失,緊接著其他所有標題都會陸續更新,什麼技術?
※知乎長貼只有「更多」按鈕的瀏覽方式,是否影響帖子的易讀性?