摘要:實時搜索都會面臨一個通用的問題,就是瀏覽器請求后台接口都是異步的,如果先發起請求的接口后返回數據,列表/表格中顯示的數據就很可能會是錯亂的。
本文分享自華為雲社區《如何解決異步接口請求快慢不均導致的數據錯誤問題?》,原文作者:Kagol 。
引言
搜索功能,我想很多業務都會涉及,這個功能的特點是:
- 用戶可以在輸入框中輸入一個關鍵字,然后在一個列表中顯示該關鍵字對應的數據;
- 輸入框是可以隨時修改/刪除全部或部分關鍵字的;
- 如果是實時搜索 (即輸入完關鍵字馬上出結果,不需要額外的操作或過多的等待),接口調用將會非常頻繁。
實時搜索都會面臨一個通用的問題,就是:
瀏覽器請求后台接口都是異步的,如果先發起請求的接口后返回數據,列表/表格中顯示的數據就很可能會是錯亂的。
問題重現
最近測試提了一個搜索(PS:此處的搜索 就是用 DevUI 新推出的 CategorySearch 組件實現的)相關的缺陷單,就涉及到了上述問題。
這個bug單大致意思是:
搜索的時候,連續快速輸入或者刪除關鍵字,搜索結果和搜索關鍵字不匹配。
從缺陷單的截圖來看,本意是要搜索關鍵字8.4.7迭代】,表格中的實際搜索結果是8.4.7迭代】過關鍵字的數據。
缺陷單的截圖還非常貼心地貼了兩次請求的信息:
作為一名“有經驗的”前端開發,一看就是一個通用的技術問題:
- 瀏覽器從服務器發起的請求都是異步的;
- 由於前一次請求服務器返回比較慢,還沒等第一次請求返回結果,后一次請求就發起了,並且迅速返回了結果,這時表格肯定顯示后一次的結果;
- 過了2秒,第一次請求的結果才慢吞吞地返回了,這時表格錯誤地又顯示了第一次請求的結果;
- 最終導致了這個bug。
怎么解決呢?
在想解決方案之前,得想辦法必現這個問題,靠后台接口是不現實的,大部分情況下后台接口都會很快返回結果。
所以要必現這個問題,得先模擬慢接口。
模擬慢接口
為了快速搭建一個后台服務,並模擬慢接口,我們選擇 Koa 這個輕量的 Node 框架。
快速開始
Koa 使用起來非常方便,只需要:
- 新建項目文件夾:mkdir koa-server
- 創建 package.json:npm init -y
- 安裝 Koa:npm i koa
- 編寫服務代碼:vi app.js
- 啟動:node app.js
- 訪問:http://localhost:3000/
編寫服務代碼
使用以下命令創建 app.js 啟動文件:
vi app.js
在文件中輸入以下 3 行代碼,即可啟動一個 Koa 服務:
const Koa = require('koa'); // 引入 Koa const app = new Koa(); // 創建 Koa 實例 app.listen(3000); // 監聽 3000 端口
訪問
如果沒有在3000端口啟動任務服務,在瀏覽器訪問:
http://localhost:3000/
會顯示以下頁面:
啟動了我們的 Koa Server 之后,訪問:
http://localhost:3000/
會顯示:
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,再次訪問
http://localhost:3000/
會顯示以下內容:
post 請求
有了以上基礎,就可以寫一個 post 接口,模擬慢接口啦!
編寫 post 接口和 get 接口很類似:
router.post('/getList', async (ctx, next) => { ctx.response.body = { status: 200, msg: '這是post接口返回的測試數據', data: [1, 2, 3] }; });
這時我們可以使用 Postman 調用下這個 post 接口,如期返回:
允許跨域
我們嘗試在 NG CLI 項目里調用這個 post 接口:
this.http.post('http://localhost:3000/getList', { id: 1, }).subscribe(result => { console.log('result:', result); });
但是在瀏覽器里直接調用,卻得不到想要的結果:
- result 沒有打印出來
- 控制台報錯
- Network請求也是紅色的
由於本地啟動的項目端口號(4200)和 Koa Server 的(3000)不同,瀏覽器認為這個接口跨域,因此攔截了。
NG CLI 項目本地鏈接:
http://localhost:4200/
Koa Server 鏈接:
http://localhost:3000/
Koa 有一個中間件可以允許跨域:koa2-cors
這個中間件的使用方式,和路由中間件很類似。
先安裝依賴:
npm i koa2-cors
然后引入:
const cors = require('koa2-cors');
再使用中間件:
app.use(cors());
這時我們再去訪問:
http://localhost:4200/
就能得到想要的結果啦!
慢接口
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 多才真正返回結果。
取消慢接口請求
能模擬慢接口,就能輕易地必現測試提的問題啦!
先必現這個問題,然后嘗試修復這個問題,最后看下這個問題還出不出現,不出現說明我們的方案能解決這個bug,問題還有說明我們得想別的辦法。
這是修復bug正確的打開方式。
最直觀的方案就是再發起第二次請求之后,如果第一次請求未返回,那就直接取消這次請求,使用第二次請求的返回結果。
怎么取消一次http請求呢?
Angular 的異步事件機制是基於 RxJS 的,取消一個正在執行的 http 請求非常方便。
前面已經看到 Angular 使用 HttpClient 服務來發起 http 請求,並調用subscribe 方法來訂閱后台的返回結果:
this.http.post('http://localhost:3000/getList', { id: 1, }).subscribe(result => { console.log('result:', result); });
要取消 http 請求,我們需要先把這個訂閱存到組件一個變量里:
private getListSubscription: Subscription; this.getListSubscription = this.http.post('http://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('http://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('http://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('http://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('http://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); });
小結
本文通過實際項目中遇到的問題,總結缺陷分析和解決的通用方法,並對異步接口請求導致的數據錯誤問題進行了深入的解析。