如何解決異步接口請求快慢不均導致的數據錯誤問題? – DevUI

DevUI 是一款面向企業中後台產品的開源前端解決方案,它倡導沉浸靈活至簡的設計價值觀,提倡設計者為真實的需求服務,為多數人的設計,拒絕嘩眾取寵、取悅眼球的設計。如果你正在開發 ToB工具類產品,DevUI 將是一個很不錯的選擇!

Kagol.png

引言

搜索功能,我想很多業務都會涉及,這個功能的特點是:

  • 用戶可以在輸入框中輸入一個關鍵字,然後在一個列表中顯示該關鍵字對應的數據;
  • 輸入框是可以隨時修改/刪除全部或部分關鍵字的;
  • 如果是實時搜索🔍(即輸入完關鍵字馬上出結果,不需要額外的操作或過多的等待),接口調用將會非常頻繁。

實時搜索都會面臨一個通用的問題,就是:

瀏覽器請求後台接口都是異步的,如果先發起請求的接口後返回數據,列表/表格中顯示的數據就很可能會是錯亂的。

問題重現

最近測試提了一個搜索(PS:此處的搜索🔍就是用 DevUI 新推出的 CategorySearch 組件實現的)相關的缺陷單,就涉及到了上述問題。

1-1.png

這個bug單大致意思是:

搜索的時候,連續快速輸入或者刪除關鍵字,搜索結果和搜索關鍵字不匹配。

從缺陷單的截圖來看,本意是要搜索關鍵字8.4.7迭代】,表格中的實際搜索結果是8.4.7迭代】過關鍵字的數據。

缺陷單的截圖還非常貼心地貼了兩次請求的信息:

2.png

作為一名「有經驗的」前端開發,一看就是一個通用的技術問題:

  1. 瀏覽器從服務器發起的請求都是異步的;
  2. 由於前一次請求服務器返回比較慢,還沒等第一次請求返回結果,後一次請求就發起了,並且迅速返回了結果,這時表格肯定顯示後一次的結果;
  3. 過了2秒,第一次請求的結果才慢吞吞地返回了,這時表格錯誤地又顯示了第一次請求的結果;
  4. 最終導致了這個bug。

怎麼解決呢?

在想解決方案之前,得想辦法必現這個問題,靠後台接口是不現實的,大部分情況下後台接口都會很快返回結果。

所以要必現這個問題,得先模擬慢接口。

模擬慢接口

為了快速搭建一個後台服務,並模擬慢接口,我們選擇 Koa 這個輕量的 Node 框架。

快速開始

Koa 使用起來非常方便,只需要:

  1. 新建項目文件夾:mkdir koa-server
  2. 創建 package.json:npm init -y
  3. 安裝 Koa:npm i koa
  4. 編寫服務代碼:vi app.js
  5. 啟動:node app.js
  6. 訪問://localhost:3000/

編寫服務代碼

使用以下命令創建 app.js 啟動文件:

vi app.js

在文件中輸入以下 3 行代碼,即可啟動一個 Koa 服務:

const Koa = require('koa'); // 引入 Koa
const app = new Koa(); // 創建 Koa 實例
app.listen(3000); // 監聽 3000 端口

訪問

如果沒有在3000端口啟動任務服務,在瀏覽器訪問:

//localhost:3000/

會顯示以下頁面:

3.png

啟動了我們的 Koa Server 之後,訪問:

//localhost:3000/

會顯示:

4.png

get 請求

剛才搭建的只是一個空服務,什麼路由都沒有,所以顯示了Not Found

我們可以通過中間件的方式,讓我們的 Koa Server 顯示點兒東西。

由於要增加一個根路由,我們先安裝路由依賴

npm i koa-router

然後引入 Koa Router

const router = require('koa-router')();

接着是編寫get接口

app.get('/', async (ctx, next) => {
  ctx.response.body = '<p>Hello Koa Server!</p>';
});

最後別忘了使用路由中間件

app.use(router.routes());

改完代碼需要重啟 Koa 服務,為了方便重啟,我們使用 pm2 這個 Node 進程管理工具來啟動/重啟 Koa 服務,使用起來也非常簡單:

  • 全局安裝 pm2:npm i -g pm2
  • 啟動 Koa Server:pm2 start app.js
  • 重啟 Koa Server:pm2 restart app.js

重啟完 Koa Server,再次訪問

//localhost:3000/

會顯示以下內容:

4-1.png

post 請求

有了以上基礎,就可以寫一個 post 接口,模擬慢接口啦!

編寫 post 接口和 get 接口很類似:

router.post('/getList', async (ctx, next) => {
  ctx.response.body = {
    status: 200,
    msg: '這是post接口返回的測試數據',
    data: [1, 2, 3]
  };
});

這時我們可以使用 Postman 調用下這個 post 接口,如期返回:

5.png

允許跨域

我們嘗試在 NG CLI 項目里調用這個 post 接口:

this.http.post('//localhost:3000/getList', {
  id: 1,
}).subscribe(result => {
  console.log('result:', result);
});

但是在瀏覽器里直接調用,卻得不到想要的結果:

  • result 沒有打印出來
  • 控制台報錯
  • Network請求也是紅色的

6.png

由於本地啟動的項目端口號(4200)和 Koa Server 的(3000)不同,瀏覽器認為這個接口跨域,因此攔截了。

NG CLI 項目本地鏈接:

//localhost:4200/

Koa Server 鏈接:

//localhost:3000/

Koa 有一個中間件可以允許跨域:koa2-cors

這個中間件的使用方式,和路由中間件很類似。

先安裝依賴:

npm i koa2-cors

然後引入:

const cors = require('koa2-cors');

再使用中間件:

app.use(cors());

這時我們再去訪問:

//localhost:4200/

就能得到想要的結果啦!

7.png

慢接口

post 接口已經有了,怎麼模擬慢接口呢?

其實就是希望服務器延遲返回結果。

在 post 接口之前增加延遲的邏輯:

  async function delay(time) {
    return new Promise(function(resolve, reject) { 
      setTimeout(function() {
        resolve();
      }, time);
    });
  }

  await delay(5000); // 延遲 5s 返回結果

  ctx.response.body = { ... };

再次訪問 getList 接口,發現前面接口會一直pending,5s 多才真正返回結果。

8.png

9.png

取消慢接口請求

能模擬慢接口,就能輕易地必現測試提的問題啦!

先必現這個問題,然後嘗試修復這個問題,最後看下這個問題還出不出現,不出現說明我們的方案能解決這個bug,問題還有說明我們得想別的辦法。

這是修復bug正確的打開方式。

最直觀的方案就是再發起第二次請求之後,如果第一次請求未返回,那就直接取消這次請求,使用第二次請求的返回結果。

怎麼取消一次http請求呢?

Angular 的異步事件機制是基於 RxJS 的,取消一個正在執行的 http 請求非常方便。

前面已經看到 Angular 使用 HttpClient 服務來發起 http 請求,並調用subscribe 方法來訂閱後台的返回結果:

this.http.post('//localhost:3000/getList', {
  id: 1,
}).subscribe(result => {
  console.log('result:', result);
});

要取消 http 請求,我們需要先把這個訂閱存到組件一個變量里:

private getListSubscription: Subscription;

this.getListSubscription = this.http.post('//localhost:3000/getList', {
  id: 1,
}).subscribe(result => {
  console.log('result:', result);
});

然後在重新發起 http 請求之前,取消上一次請求的訂閱即可。

this.getListSubscription?.unsubscribe(); // 重新發起 http 請求之前,取消上一次請求的訂閱

this.getListSubscription = this.http.post(...);

其他 http 庫如何取消請求

至此這個缺陷算是解決了,其實這是一個通用的問題,不管是在什麼業務,使用什麼框架,都會遇到異步接口慢導致的數據錯亂問題。

那麼,如果使用 fetch 這種瀏覽器原生的 http 請求接口或者 axios 這種業界廣泛使用的 http 庫,怎麼取消正在進行的 http 請求呢?

fetch

先來看下 fetch,fetch 是瀏覽器原生提供的 AJAX 接口,使用起來也非常方便。

使用 fetch 發起一個 post 請求:

fetch('//localhost:3000/getList', {
   method: 'POST',
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  },
  body: JSON.stringify({
    id: 1
  })
}).then(result => {
  console.log('result', result);
});

可以使用 AbortController 來實現請求取消:

this.controller?.abort(); // 重新發起 http 請求之前,取消上一次請求

const controller = new AbortController(); //  創建 AbortController 實例
const signal = controller.signal;
this.controller = controller;

fetch('//localhost:3000/getList', {
   method: 'POST',
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  },
  body: JSON.stringify({
    id: 1
  }),
  signal, // 信號參數,用來控制 http 請求的執行
}).then(result => {
  console.log('result', result);
});

axios

再來看看 axios,先看下如何使用 axios 發起 post 請求。

先安裝:

npm i axios

再引入:

import axios from 'axios';

發起 post 請求:

axios.post('//localhost:3000/getList', {
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  },
  data: {
    id: 1,
  },
})
.then(result => {
  console.log('result:', result);
});

axios 發起的請求可以通過 cancelToken 來取消。

this.source?.cancel('The request is canceled!');

this.source = axios.CancelToken.source(); // 初始化 source 對象

axios.post('//localhost:3000/getList', {
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  },
  data: {
    id: 1,
  },
}, { // 注意是第三個參數
  cancelToken: this.source.token, // 這裡聲明的 cancelToken 其實相當於是一個標記或者信號
})
.then(result => {
  console.log('result:', result);
});

小結

本文通過實際項目中遇到的問題,總結缺陷分析和解決的通用方法,並對異步接口請求導致的數據錯誤問題進行了深入的解析。

加入我們

我們是DevUI團隊,歡迎來這裡和我們一起打造優雅高效的人機設計/研發體系。招聘郵箱:[email protected]

文/DevUI Kagol

往期文章推薦

《號外號外!DevUI Admin V1.0 發佈啦!》

《讓我們一起建設 Vue DevUI 項目吧!🥳 》

《2021年最值得推薦的7個Angular前端組件庫》