前端資料請求的終極方案

止戰之殤發表於2019-02-25

資料請求是我們開發中非常重要的一環,如何優雅地進行抽象處理,不是一件很容易的事情,也是經常被忽略的事情,處理不好的話,重複的程式碼散落在各處,維護成本極高。

所以我們需要好好梳理下資料請求涉及到哪些方面,對它有整體的管控,從而設計出擴充套件性高的方案。

案例分析

下面我們以 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 請求作了分析,其實對於其他比如 getput 都一樣的,我們可以對這些請求作統一的處理。

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);
複製程式碼

這樣的話,我們對外就暴露出 getpost 的方法,其他請求類似,在此只用 getpost 作為示例,入參分別是 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);
});
複製程式碼

axiosinterceptors 功能,其實就是一個鏈式呼叫,可以在請求前和請求後做事情,這裡我們在請求後進行攔截處理,對返回的資料進行校驗和捕獲異常,對於通用的錯誤我們直接通過 UI 互動將錯誤展示出來,對於業務上的錯誤我們檢查下介面有沒有配置說要個性化展示錯誤,如果有的話,將錯誤處理交給頁面,如果沒有的話,進行錯誤兜底處理。

請求監控

請求監控這塊和異常處理類似,只不過這裡只是記錄情況,不涉及到 UI 上的互動或者和業務程式碼的互動,所以可以把這部分邏輯直接寫在異常處理那裡,或者在請求後再新增一個攔截器,單獨處理。

monitor.js

axios.interceptors.response.use((response) => {
  const { status, data, config } = response;
  // 根據返回的資料和介面引數配置,對請求進行埋點
}, (error) => {
  // 根據返回的資料和介面引數配置,對請求進行埋點
});
複製程式碼

比較建議這樣做,保持每個模組獨立,符合單一功能原則(SRP)。

好了,到現在為止,引數配置異常處理請求監控 都設計完了,有三個檔案:

  • request.js:請求庫配置,對外暴露出 getpost 方法。
  • 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 應用的新選擇》 有簡單提過,歡迎大家體驗。

相關文章