資料請求是我們開發中非常重要的一環,如何優雅地進行抽象處理,不是一件很容易的事情,也是經常被忽略的事情,處理不好的話,重複的程式碼散落在各處,維護成本極高。
所以我們需要好好梳理下資料請求涉及到哪些方面,對它有整體的管控,從而設計出擴充套件性高的方案。
案例分析
下面我們以 axios
這個請求庫進行講解。
假如我們在頁面中發出一個 POST 請求,類似這樣:
axios.post('/user/create', { name: 'beyondxgb' }).then((result) => {
// do something
});
複製程式碼
後來發現需要防止 CSRF
,那我們需要在請求中的 headers
加上 X-XSRF-TOKEN
,所以變成這樣:
axios.post('/user/create', { name: 'beyondxgb' }, {
headers: {
'X-XSRF-TOKEN': 'xxxxxxxx',
},
}).then((result) => {
// do something
});
複製程式碼
這時可以發現,難道每次發起 post
請求都需要這樣配置嗎?所以會想到把這部分配置抽離出來,抽象出類似這樣一個方法:
function post(url, data, config) {
return axios.post(url, data, {
headers: {
'X-XSRF-TOKEN': 'xxxxxxxx',
},
...config,
});
}
複製程式碼
所以我們需要對引數配置進行抽象。
到了測試流程的時候,發現服務端的請求不是總返回成功的,那怎麼辦?那就 catch
處理一下:
post('/user/create', { name: 'beyondxgb' }).then((result) => {
// do something
}).catch((error) => {
// deal with error
// 200
// 503
// SESSION EXPIRED
// ...
});
複製程式碼
寫下來總感覺哪裡不對啊,原來請求錯誤有這麼多情況,我整個專案有很多請求資料的地方呢,這部分程式碼肯定是通用的,抽象出來!
function dealWithRequestError(error) {
// deal with error
// 200
// 503
// SESSION EXPIRED
// ...
}
function post(url, data, config) {
return axios.post(url, data, {
headers: {
'X-XSRF-TOKEN': 'xxxxxxxx',
},
...config,
}).catch(dealWithRequestError);
}
複製程式碼
所以我們需要對異常處理進行抽象。
專案上線前業務方可能提出穩定性的需求,這時我們需要對請求進行監控,把介面請求成功和失敗的情況都記錄下來。同樣,我們把這部分程式碼也要寫到公用的地方,類似這樣:
function post(url, data, config) {
return axios.post(url, data, {
headers: {
'X-XSRF-TOKEN': 'xxxxxxxx',
},
...config,
}).then((result) => {
// 記錄成功情況
...
return result;
})
.catch((error) => {
// 記錄失敗情況
...
return dealWithRequestError(error);
);
}
複製程式碼
所以我們需要對請求監控進行抽象。
方案設計
從上面對一個簡單的 post
請求的案例分析中,我們可以看到,資料請求主要涉及三方面 引數配置、異常處理 和 請求監控。上面例子的處理還是比較粗糙,整體上還是需要進行程式碼組織和分層。
引數配置
首先,我們處理下引數的配置,上面的例子只是對 post
請求作了分析,其實對於其他比如 get
,put
都一樣的,我們可以對這些請求作統一的處理。
request.js
import axios from 'axios';
// The http header that carries the xsrf token value { X-XSRF-TOKEN: '' }
const csrfConfig = {
'X-XSRF-TOKEN': '',
};
// Build uniform request
async function buildRequest(method, url, params, options) {
let param = {};
let config = {};
if (method === 'get') {
param = { params, ...options };
} else {
param = JSON.stringify(params);
config = {
headers: {
...csrfConfig,
},
};
config = Object.assign({}, config, options);
}
return axios[method](url, param, config);
}
export const get = (url, params = {}, options) => buildRequest('get', url, params, options);
export const post = (url, params = {}, options) => buildRequest('post', url, params, options);
複製程式碼
這樣的話,我們對外就暴露出 get
和 post
的方法,其他請求類似,在此只用 get
和 post
作為示例,入參分別是 API地址,資料 和 擴充套件配置。
異常處理
其實異常處理場景會比較複雜,不是簡單地 catch
一下,往往伴隨著業務邏輯和UI的互動,異常主要有兩方面,全域性異常和業務異常。
全域性異常,也可以說是通用的異常,比如服務端返回503,網路異常,登入失效,無許可權等,這些異常是可以預料並可控的,只要和服務端約定好格式,捕獲下異常再展示出來即可。
業務異常,指的是和業務邏輯緊密相關的,比如提交失敗,資料校驗失敗等,這些異常往往每個介面有不一樣的情況,而且需要個性化展示錯誤,所以這部分可能不能進行統一處理,有時候需要把展示錯誤交到 View
層去實現。
在實現上,我們不會直接在上面的請求方法中直接 catch
,而是利用 axios
提供的 interceptors
功能,這樣可以將異常的處理和核心的請求方法隔離出來,畢竟這部分是要和 UI
進行互動的。我們來看看如何實現:
error.js
import axios from 'axios';
// Add a response interceptor
axios.interceptors.response.use((response) => {
const { config, data } = response;
// 和服務端約定的 Code
const { code } = data;
switch (code) {
case 200:
return data;
case 401:
// 登入失效
break;
case 403:
// 無許可權
break;
default:
break;
}
if (config.showError) {
// 介面配置指定需要個性化展示錯誤
return Promise.reject(data);
}
// 預設展示錯誤
// ... Toast error
}, (error) => {
// 通用錯誤
if (axios.isCancel(error)) {
// Request cancel
} else if (navigator && !navigator.onLine) {
// Network is disconnect
} else {
// Other error
}
return Promise.reject(error);
});
複製程式碼
axios
的 interceptors
功能,其實就是一個鏈式呼叫,可以在請求前和請求後做事情,這裡我們在請求後進行攔截處理,對返回的資料進行校驗和捕獲異常,對於通用的錯誤我們直接通過 UI
互動將錯誤展示出來,對於業務上的錯誤我們檢查下介面有沒有配置說要個性化展示錯誤,如果有的話,將錯誤處理交給頁面,如果沒有的話,進行錯誤兜底處理。
請求監控
請求監控這塊和異常處理類似,只不過這裡只是記錄情況,不涉及到 UI
上的互動或者和業務程式碼的互動,所以可以把這部分邏輯直接寫在異常處理那裡,或者在請求後再新增一個攔截器,單獨處理。
monitor.js
axios.interceptors.response.use((response) => {
const { status, data, config } = response;
// 根據返回的資料和介面引數配置,對請求進行埋點
}, (error) => {
// 根據返回的資料和介面引數配置,對請求進行埋點
});
複製程式碼
比較建議這樣做,保持每個模組獨立,符合單一功能原則(SRP)。
好了,到現在為止,引數配置、異常處理 和 請求監控 都設計完了,有三個檔案:
request.js
:請求庫配置,對外暴露出get
,post
方法。error.js
:請求的一些異常處理,涉及到和外面對接的是該介面是否需要個性化展示錯誤。monitor.js
:請求的情況記錄,比較獨立的一塊。
那在頁面上呼叫的時候可以這樣子:
import { get, post } from 'request.js';
get('/user/info').then((data) => {});
post('/user/update', { name: 'beyondxgb' }, { showError: true }).then((data) => {
if (data.code !== 200) {
// 展示錯誤
} else {
// do something
}
});
複製程式碼
再仔細思考下,覺得還不是最完美的,API
名稱直接在頁面上引用,這樣會給自己埋坑,如果後面 API
名稱改了,而且這個 API
在多個頁面被呼叫,那維護成本就高了。我們有兩種方法,第一種就是將所有 API
獨立配置在一個檔案中,給頁面去讀取,第二種辦法就是我們在請求庫和頁面之前再加一層,叫 service
,也就是所謂的服務層,對外暴露介面方法給頁面,這樣頁面完全不需要關注介面是什麼或者介面是如何取資料的,而且以後介面的任何修改,只要在服務層進行修改即可,對頁面沒有任何影響。
當然我是採取第二種方法,類似這樣子:
services.js
import { get, post } from 'request.js';
// fetch random data
export async function fetchRandomData(params) {
return get('https://randomuser.me/api', params);
}
// update user info
export async function updateUserInfo(params, options) {
return post('/user/info', params, { showError: true, ...options });
}
複製程式碼
這樣子的話,頁面就不會直接和請求庫進行互動,而是跟服務層獲取對應的方法。
import { fetchRandomData, updateUserInfo } from 'services.js';
fetchRandomData().then((data) => {});
updateUserInfo({ name: 'beyondxgb' }).then((data) => {
if (data.code !== 200) {
// 展示錯誤
} else {
// do something
}
});
複製程式碼
我們來看看最終的方案是這樣子的:
延伸擴充套件
上面講的都是以 axios
這個請求庫為例,其實思想是互通的,換一個請求庫也是一樣的處理的方法。不知大家有沒有注意到,把請求庫引數配置和異常處理兩個模組獨立出來,完全是利用了 interceptors
的特性,這也是我喜歡 axios
的原因之一,我覺得這個設計得很好,類似中介軟體的做法,在請求資料到達頁面之前,我們可以通過寫攔截器對資料進行過濾、加工、校驗、異常監控等。
我覺得任何一個請求庫都可以實現這個功能,就算請求庫是有歷史包袱,也可以自己在外面包一層。比如說有請求庫 abc
,它有一個 request
方法,可以這樣複寫它:
import abc from 'abc';
function dispatchRequest(options) {
const reqConfig = Object.assign({}, options);
return abc.request(reqConfig).then(response => ({
response,
options,
})).catch(error => (
Promise.reject({
error,
options,
})
));
}
class Request {
constructor(config) {
this.default = config;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager(),
};
}
}
Request.prototype.request = function request(config = {}) {
// Add interceptors
const chain = [dispatchRequest, undefined];
let promise = Promise.resolve(options);
// Add request interceptors
this.interceptors.request.forEach((interceptor) => {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
// Add response interceptors
this.interceptors.response.forEach((interceptor) => {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
};
複製程式碼
更多
前面我們很好地解決了資料請求的問題,還有另一方面,也是和資料請求緊密相關的,就是資料模擬(Mock) 了,在專案開發前期服務端沒有準備好資料之前,我們只有自己在本地進行 Mock
資料了,或者很多公司已經有比較好的平臺實現這個功能了,我這裡介紹下不借助平臺,只是在本地啟動一個小工具即可實現 Mock
資料。
這裡我自己寫了一個小工具 @ris/mock,只要把它作為中介軟體注入到 webpack-dev-server
中就好了。
webpack.config.js
const mock = require('@ris/mock');
module.exports = {
//...
devServer: {
compress: true,
port: 9000,
after: (app) => {
// Start mock data
mock(app);
},
}
};
複製程式碼
這時候在專案根目錄建立 mock
資料夾,資料夾裡建一個 rules.js
檔案,rules.js
裡面配置的是介面的對映規則,類似這樣子:
module.exports = {
'GET /api/user': { name: 'beyondxgb' },
'POST /api/form/create': { success: true },
'GET /api/cases/list': (req, res) => { res.end(JSON.stringify([{ id: 1, name: 'demo' }])); },
'GET /api/user/list': 'user/list.json',
'GET /api/user/create': 'user/create.js',
};
複製程式碼
配置規則後,請求介面的時候,就會被轉發,轉發的時候可以是一個 物件
,函式
,檔案
,詳細使用可以參考文件。
結語
在資料請求方案的設計中,也證實了我們的“寫程式碼”是“程式設計”,而不是“程式編寫”,我們要對自己的程式碼負責,如何讓自己的程式碼可維護性高,易擴充套件,是優秀工程師的基本素養。
以上的方案已沉澱在 RIS 中,包含程式碼組織結構和技術實現,可以初始化一個 Standard 應用看看,之前的文章《RIS,建立 React 應用的新選擇》 有簡單提過,歡迎大家體驗。