JS前端常見的競態(tài)問題解決方法詳解
什么是競態(tài)問題
競態(tài)問題,又叫競態(tài)條件(race condition),它旨在描述一個系統(tǒng)或者進程的輸出依賴于不受控制的事件出現(xiàn)順序或者出現(xiàn)時機。
此詞源自于兩個信號試著彼此競爭,來影響誰先輸出。
簡單來說,競態(tài)問題出現(xiàn)的原因是無法保證異步操作的完成會按照他們開始時同樣的順序。舉個??:
- 有一個分頁列表,快速地切換第二頁,第三頁;
- 先后請求 data2 與 data3,分頁器顯示當前在第三頁,并且進入 loading;
- 但由于網(wǎng)絡(luò)的不確定性,先發(fā)出的請求不一定先響應,所以有可能 data3 比 data2 先返回;
- 在 data2 最終返回后,分頁器指示當前在第三頁,但展示的是第二頁的數(shù)據(jù)。
這就是競態(tài)條件,在前端開發(fā)中,常見于搜索,分頁,選項卡等切換的場景。
那么如何解決競態(tài)問題呢?在以上這些場景中,我們很容易想到:
當發(fā)出新的請求時,取消掉上次請求即可。
取消過期請求
XMLHttpRequest 取消請求
XMLHttpRequest(XHR)是一個內(nèi)建的瀏覽器對象,它允許使用 JavaScript 發(fā)送 HTTP 請求。
如果請求已被發(fā)出,可以使用 abort() 方法立刻中止請求。
const xhr= new XMLHttpRequest();
xhr.open('GET', 'https://xxx');
xhr.send();
xhr.abort(); // 取消請求
fetch API 取消請求
fetch 號稱是 AJAX 的替代品,出現(xiàn)于 ES6,它也可以發(fā)出類似 XMLHttpRequest 的網(wǎng)絡(luò)請求。
主要的區(qū)別在于 fetch 使用了 promise,要中止 fetch 發(fā)出的請求,需要使用 AbortController。
const controller = new AbortController();
const signal = controller.signal;
fetch('/xxx', {
signal,
}).then(function(response) {
//...
});
controller.abort(); // 取消請求
相比原生 API,大多項目都會選擇 axios 進行請求。
axios 取消請求
axios 是一個 HTTP 請求庫,本質(zhì)是對原生 XMLHttpRequest 的封裝后基于 promise 的實現(xiàn)版本,因此 axios 請求也可以被取消。
可以利用 axios 的 CancelToken API 取消請求。
const source = axios.CancelToken.source();
axios.get('/xxx', {
cancelToken: source.token
}).then(function (response) {
// ...
});
source.cancel() // 取消請求
在 cancel 時,axios 會在內(nèi)部調(diào)用 promise.reject() 與 xhr.abort()。

所以我們在處理請求錯誤時,需要判斷 error 是否是 cancel 導致的,避免與常規(guī)錯誤一起處理。
axios.get('/xxx', {
cancelToken: source.token
}).catch(function(err) {
if (axios.isCancel(err)) {
console.log('Request canceled', err.message);
} else {
// 處理錯誤
}
});
但 cancelToken 從 v0.22.0 開始已被 axios 棄用。原因是基于實現(xiàn)該 API 的提案 cancelable promises proposal 已被撤銷。
從 v0.22.0 開始,axios 支持以 fetch API 方式的 AbortController 取消請求
const controller = new AbortController();
axios.get('/xxx', {
signal: controller.signal
}).then(function(response) {
//...
});
controller.abort() // 取消請求
同樣,在處理請求錯誤時,也需要判斷 error 是否來自 cancel。
可取消的 promise
原生 promise 并不支持 cancel,但 cancel 對于異步操作來說又是個很常見的需求。所以社區(qū)很多倉庫都自己實現(xiàn)了 promise 的 cancel 能力。
我們以awesome-imperative-promise 為例,來看看 cancel 的實現(xiàn),它的 cancel 實現(xiàn)基于指令式 promise, 源碼一共只有 40 行。
什么是指令式 promise?
我們普遍使用的 promise,它的 resolve/reject 只能在 new Promise 內(nèi)部調(diào)用,而指令式 promise 支持在 promise 外部手動調(diào)用 resolve/reject 等指令。
通過它的用法能更好地理解何為指令式 promise:
import { createImperativePromise } from 'awesome-imperative-promise';
const { resolve, reject, cancel } = createImperativePromise(promise);
resolve("some value");
// or
reject(new Error());
// or
cancel();
內(nèi)部的 cancel 方法其實就是將 resolve,reject 設(shè)為 null,讓 promise 永遠不會 resolve/reject。

一直沒有 resolve 也沒有 reject 的 Promise 會造成內(nèi)存泄露嗎?
有興趣的同學可以了解下 http://www.dbjr.com.cn/article/258149.htm
我個人認為,如果沒有保留對 promise 的引用,就不會造成內(nèi)存泄露。
回到 promise cancel,可以看到,雖然 API 命名為 cancel,但實際上沒有任何 cancel 的動作,promise 的狀態(tài)還是會正常流轉(zhuǎn),只是回調(diào)不再執(zhí)行,被“忽略”了,所以看起來像被 cancel 了。
因此解決競態(tài)問題的方法,除了「取消請求」,還可以「忽略請求」。
當請求響應時,只要判斷返回的數(shù)據(jù)是否需要,如果不是則忽略即可。
忽略過期請求
我們又有哪些方式來忽略過期的請求呢?
封裝指令式 promise
利用指令式 promise,我們可以手動調(diào)用 cancel API 來忽略上次請求。
但是如果每次都需要手動調(diào)用,會導致項目中相同的模板代碼過多,偶爾也可能忘記 cancel。
我們可以基于指令式 promise 封裝一個自動忽略過期請求的高階函數(shù) onlyResolvesLast。
在每次發(fā)送新請求前,cancel 掉上一次的請求,忽略它的回調(diào)。
function onlyResolvesLast(fn) {
// 保存上一個請求的 cancel 方法
let cancelPrevious = null;
const wrappedFn = (...args) => {
// 當前請求執(zhí)行前,先 cancel 上一個請求
cancelPrevious && cancelPrevious();
// 執(zhí)行當前請求
const result = fn.apply(this, args);
// 創(chuàng)建指令式的 promise,暴露 cancel 方法并保存
const { promise, cancel } = createImperativePromise(result);
cancelPrevious = cancel;
return promise;
};
return wrappedFn;
}
以上就是 github.com/slorber/awe… 的實現(xiàn)。
只需要將 onlyResolvesLast 包裝一下請求方法,就能實現(xiàn)自動忽略,減少很多模板代碼。
const fn = (duration) =>
new Promise(r => {
setTimeout(r, duration);
});
const wrappedFn = onlyResolvesLast(fn);
wrappedFn(500).then(() => console.log(1));
wrappedFn(1000).then(() => console.log(2));
wrappedFn(100).then(() => console.log(3));
// 輸出 3
使用唯一 id 標識每次請求
除了指令式 promise,我們還可以給「請求標記 id」的方式來忽略上次請求。
具體思路是:
- 利用全局變量記錄最新一次的請求 id
- 在發(fā)請求前,生成唯一 id 標識該次請求
- 在請求回調(diào)中,判斷 id 是否是最新的 id,如果不是,則忽略該請求的回調(diào)
偽代碼如下:
let fetchId = 0; // 保存最新的請求 id
const getUsers = () => {
// 發(fā)起請求前,生成新的 id 并保存
const id = fetchId + 1;
fetchId = id;
await 請求
// 判斷是最新的請求 id 再處理回調(diào)
if (id === fetchId) {
// 請求處理
}
}
上面的使用方法也會在項目中產(chǎn)生很多模板代碼,稍做封裝后也能實現(xiàn)一套同樣用法的 onlyResolvesLast:
function onlyResolvesLast(fn) {
// 利用閉包保存最新的請求 id
let id = 0;
const wrappedFn = (...args) => {
// 發(fā)起請求前,生成新的 id 并保存
const fetchId = id + 1;
id = fetchId;
// 執(zhí)行請求
const result = fn.apply(this, args);
return new Promise((resolve, reject) => {
// result 可能不是 promise,需要包裝成 promise
Promise.resolve(result).then((value) => {
// 只處理最新一次請求
if (fetchId === id) {
resolve(value);
}
}, (error) => {
// 只處理最新一次請求
if (fetchId === id) {
reject(error);
}
});
})
};
return wrappedFn;
}
用法也一樣,使用 onlyResolvesLast 包裝一下請求方法,實現(xiàn)過期請求自動忽略。
而且,這樣的實現(xiàn)不依賴指令式 promise,也更輕量。
「取消」和「忽略」的比較
「取消」更實際
如果請求被「取消」了沒有到達服務端,那么可以一定程度減輕服務的壓力。
但是取消請求也依賴底層的請求 API,比如 XMLHttpRequest 需要用 abort,而 fetch API 和 axios 需要用 AbortController。
「忽略」更通用
而「忽略」的方式,不依賴請求的 API,更加通用,更容易抽象和封裝。本質(zhì)上所有的異步方法都可以使用 onlyResolvesLast 來忽略過期的調(diào)用。
一個更實際,一個更通用,兩者的使用需要根據(jù)具體場景來權(quán)衡。
總結(jié)
在前端常見的搜索,分頁,選項卡等切換的場景中。由于網(wǎng)絡(luò)的不確定性,先發(fā)出的請求不一定先響應,這會造成競態(tài)問題。
解決競態(tài)問題,我們可以選擇「取消」或「忽略」過期請求。
- 「取消請求」,XMLHttpRequest 可以使用 abort 方法,fetch API 以及 axios 可以使用 AbortController
- 「忽略請求」,可以基于指令式 promise 或請求 id 的方式封裝高階函數(shù)來減少模板代碼
兩種方式各有各的好,需要根據(jù)實際場景權(quán)衡利弊。
其實解決方式不止這些,像 React Query,GraphQL,rxjs 等都有競態(tài)處理,感興趣的同學可以再繼續(xù)深入了解。
以上就是JS前端常見的競態(tài)問題解決方法詳解的詳細內(nèi)容,更多關(guān)于JS前端競態(tài)的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Three.js添加陰影和簡單后期處理實現(xiàn)示例詳解
這篇文章主要為大家介紹了Three.js添加陰影和簡單后期處理實現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-04-04
JavaScript與JQuery框架基礎(chǔ)入門教程
這篇文章主要介紹了jQuery和JavaScript入門基礎(chǔ)知識學習指南,jQuery是當下最主流人氣最高的JavaScript庫,需要的朋友可以參考下2021-07-07

