JS前端常見(jiàn)的競(jìng)態(tài)問(wèn)題解決方法詳解
什么是競(jìng)態(tài)問(wèn)題
競(jìng)態(tài)問(wèn)題,又叫競(jìng)態(tài)條件(race condition),它旨在描述一個(gè)系統(tǒng)或者進(jìn)程的輸出依賴于不受控制的事件出現(xiàn)順序或者出現(xiàn)時(shí)機(jī)。
此詞源自于兩個(gè)信號(hào)試著彼此競(jìng)爭(zhēng),來(lái)影響誰(shuí)先輸出。
簡(jiǎn)單來(lái)說(shuō),競(jìng)態(tài)問(wèn)題出現(xiàn)的原因是無(wú)法保證異步操作的完成會(huì)按照他們開(kāi)始時(shí)同樣的順序。舉個(gè)??:
- 有一個(gè)分頁(yè)列表,快速地切換第二頁(yè),第三頁(yè);
- 先后請(qǐng)求 data2 與 data3,分頁(yè)器顯示當(dāng)前在第三頁(yè),并且進(jìn)入 loading;
- 但由于網(wǎng)絡(luò)的不確定性,先發(fā)出的請(qǐng)求不一定先響應(yīng),所以有可能 data3 比 data2 先返回;
- 在 data2 最終返回后,分頁(yè)器指示當(dāng)前在第三頁(yè),但展示的是第二頁(yè)的數(shù)據(jù)。
這就是競(jìng)態(tài)條件,在前端開(kāi)發(fā)中,常見(jiàn)于搜索,分頁(yè),選項(xiàng)卡等切換的場(chǎng)景。
那么如何解決競(jìng)態(tài)問(wèn)題呢?在以上這些場(chǎng)景中,我們很容易想到:
當(dāng)發(fā)出新的請(qǐng)求時(shí),取消掉上次請(qǐng)求即可。
取消過(guò)期請(qǐng)求
XMLHttpRequest 取消請(qǐng)求
XMLHttpRequest
(XHR)是一個(gè)內(nèi)建的瀏覽器對(duì)象,它允許使用 JavaScript 發(fā)送 HTTP 請(qǐng)求。
如果請(qǐng)求已被發(fā)出,可以使用 abort()
方法立刻中止請(qǐng)求。
const xhr= new XMLHttpRequest(); xhr.open('GET', 'https://xxx'); xhr.send(); xhr.abort(); // 取消請(qǐng)求
fetch API 取消請(qǐng)求
fetch 號(hào)稱是 AJAX 的替代品,出現(xiàn)于 ES6,它也可以發(fā)出類似 XMLHttpRequest 的網(wǎng)絡(luò)請(qǐng)求。
主要的區(qū)別在于 fetch 使用了 promise,要中止 fetch 發(fā)出的請(qǐng)求,需要使用 AbortController
。
const controller = new AbortController(); const signal = controller.signal; fetch('/xxx', { signal, }).then(function(response) { //... }); controller.abort(); // 取消請(qǐng)求
相比原生 API,大多項(xiàng)目都會(huì)選擇 axios 進(jìn)行請(qǐng)求。
axios 取消請(qǐng)求
axios 是一個(gè) HTTP 請(qǐng)求庫(kù),本質(zhì)是對(duì)原生 XMLHttpRequest 的封裝后基于 promise 的實(shí)現(xiàn)版本,因此 axios 請(qǐng)求也可以被取消。
可以利用 axios 的 CancelToken API 取消請(qǐng)求。
const source = axios.CancelToken.source(); axios.get('/xxx', { cancelToken: source.token }).then(function (response) { // ... }); source.cancel() // 取消請(qǐng)求
在 cancel 時(shí),axios 會(huì)在內(nèi)部調(diào)用 promise.reject() 與 xhr.abort()。
所以我們?cè)谔幚碚?qǐng)求錯(cuò)誤時(shí),需要判斷 error 是否是 cancel 導(dǎo)致的,避免與常規(guī)錯(cuò)誤一起處理。
axios.get('/xxx', { cancelToken: source.token }).catch(function(err) { if (axios.isCancel(err)) { console.log('Request canceled', err.message); } else { // 處理錯(cuò)誤 } });
但 cancelToken 從 v0.22.0
開(kāi)始已被 axios 棄用。原因是基于實(shí)現(xiàn)該 API 的提案 cancelable promises proposal 已被撤銷。
從 v0.22.0
開(kāi)始,axios 支持以 fetch API 方式的 AbortController 取消請(qǐng)求
const controller = new AbortController(); axios.get('/xxx', { signal: controller.signal }).then(function(response) { //... }); controller.abort() // 取消請(qǐng)求
同樣,在處理請(qǐng)求錯(cuò)誤時(shí),也需要判斷 error 是否來(lái)自 cancel。
可取消的 promise
原生 promise 并不支持 cancel,但 cancel 對(duì)于異步操作來(lái)說(shuō)又是個(gè)很常見(jiàn)的需求。所以社區(qū)很多倉(cāng)庫(kù)都自己實(shí)現(xiàn)了 promise 的 cancel 能力。
我們以awesome-imperative-promise
為例,來(lái)看看 cancel 的實(shí)現(xiàn),它的 cancel 實(shí)現(xiàn)基于指令式 promise, 源碼一共只有 40 行。
什么是指令式 promise?
我們普遍使用的 promise,它的 resolve/reject 只能在 new Promise 內(nèi)部調(diào)用,而指令式 promise 支持在 promise 外部手動(dòng)調(diào)用 resolve/reject 等指令。
通過(guò)它的用法能更好地理解何為指令式 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 方法其實(shí)就是將 resolve,reject 設(shè)為 null,讓 promise 永遠(yuǎn)不會(huì) resolve/reject。
一直沒(méi)有 resolve 也沒(méi)有 reject 的 Promise 會(huì)造成內(nèi)存泄露嗎?
有興趣的同學(xué)可以了解下 http://chabaoo.cn/article/258149.htm
我個(gè)人認(rèn)為,如果沒(méi)有保留對(duì) promise 的引用,就不會(huì)造成內(nèi)存泄露。
回到 promise cancel,可以看到,雖然 API 命名為 cancel,但實(shí)際上沒(méi)有任何 cancel 的動(dòng)作,promise 的狀態(tài)還是會(huì)正常流轉(zhuǎn),只是回調(diào)不再執(zhí)行,被“忽略”了,所以看起來(lái)像被 cancel 了。
因此解決競(jìng)態(tài)問(wèn)題的方法,除了「取消請(qǐng)求」,還可以「忽略請(qǐng)求」。
當(dāng)請(qǐng)求響應(yīng)時(shí),只要判斷返回的數(shù)據(jù)是否需要,如果不是則忽略即可。
忽略過(guò)期請(qǐng)求
我們又有哪些方式來(lái)忽略過(guò)期的請(qǐng)求呢?
封裝指令式 promise
利用指令式 promise,我們可以手動(dòng)調(diào)用 cancel API 來(lái)忽略上次請(qǐng)求。
但是如果每次都需要手動(dòng)調(diào)用,會(huì)導(dǎo)致項(xiàng)目中相同的模板代碼過(guò)多,偶爾也可能忘記 cancel。
我們可以基于指令式 promise 封裝一個(gè)自動(dòng)忽略過(guò)期請(qǐng)求的高階函數(shù) onlyResolvesLast
。
在每次發(fā)送新請(qǐng)求前,cancel 掉上一次的請(qǐng)求,忽略它的回調(diào)。
function onlyResolvesLast(fn) { // 保存上一個(gè)請(qǐng)求的 cancel 方法 let cancelPrevious = null; const wrappedFn = (...args) => { // 當(dāng)前請(qǐng)求執(zhí)行前,先 cancel 上一個(gè)請(qǐng)求 cancelPrevious && cancelPrevious(); // 執(zhí)行當(dāng)前請(qǐng)求 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… 的實(shí)現(xiàn)。
只需要將 onlyResolvesLast 包裝一下請(qǐng)求方法,就能實(shí)現(xiàn)自動(dòng)忽略,減少很多模板代碼。
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 標(biāo)識(shí)每次請(qǐng)求
除了指令式 promise,我們還可以給「請(qǐng)求標(biāo)記 id」的方式來(lái)忽略上次請(qǐng)求。
具體思路是:
- 利用全局變量記錄最新一次的請(qǐng)求 id
- 在發(fā)請(qǐng)求前,生成唯一 id 標(biāo)識(shí)該次請(qǐng)求
- 在請(qǐng)求回調(diào)中,判斷 id 是否是最新的 id,如果不是,則忽略該請(qǐng)求的回調(diào)
偽代碼如下:
let fetchId = 0; // 保存最新的請(qǐng)求 id const getUsers = () => { // 發(fā)起請(qǐng)求前,生成新的 id 并保存 const id = fetchId + 1; fetchId = id; await 請(qǐng)求 // 判斷是最新的請(qǐng)求 id 再處理回調(diào) if (id === fetchId) { // 請(qǐng)求處理 } }
上面的使用方法也會(huì)在項(xiàng)目中產(chǎn)生很多模板代碼,稍做封裝后也能實(shí)現(xiàn)一套同樣用法的 onlyResolvesLast
:
function onlyResolvesLast(fn) { // 利用閉包保存最新的請(qǐng)求 id let id = 0; const wrappedFn = (...args) => { // 發(fā)起請(qǐng)求前,生成新的 id 并保存 const fetchId = id + 1; id = fetchId; // 執(zhí)行請(qǐng)求 const result = fn.apply(this, args); return new Promise((resolve, reject) => { // result 可能不是 promise,需要包裝成 promise Promise.resolve(result).then((value) => { // 只處理最新一次請(qǐng)求 if (fetchId === id) { resolve(value); } }, (error) => { // 只處理最新一次請(qǐng)求 if (fetchId === id) { reject(error); } }); }) }; return wrappedFn; }
用法也一樣,使用 onlyResolvesLast
包裝一下請(qǐng)求方法,實(shí)現(xiàn)過(guò)期請(qǐng)求自動(dòng)忽略。
而且,這樣的實(shí)現(xiàn)不依賴指令式 promise,也更輕量。
「取消」和「忽略」的比較
「取消」更實(shí)際
如果請(qǐng)求被「取消」了沒(méi)有到達(dá)服務(wù)端,那么可以一定程度減輕服務(wù)的壓力。
但是取消請(qǐng)求也依賴底層的請(qǐng)求 API,比如 XMLHttpRequest 需要用 abort,而 fetch API 和 axios 需要用 AbortController。
「忽略」更通用
而「忽略」的方式,不依賴請(qǐng)求的 API,更加通用,更容易抽象和封裝。本質(zhì)上所有的異步方法都可以使用 onlyResolvesLast
來(lái)忽略過(guò)期的調(diào)用。
一個(gè)更實(shí)際,一個(gè)更通用,兩者的使用需要根據(jù)具體場(chǎng)景來(lái)權(quán)衡。
總結(jié)
在前端常見(jiàn)的搜索,分頁(yè),選項(xiàng)卡等切換的場(chǎng)景中。由于網(wǎng)絡(luò)的不確定性,先發(fā)出的請(qǐng)求不一定先響應(yīng),這會(huì)造成競(jìng)態(tài)問(wèn)題。
解決競(jìng)態(tài)問(wèn)題,我們可以選擇「取消」或「忽略」過(guò)期請(qǐng)求。
- 「取消請(qǐng)求」,XMLHttpRequest 可以使用 abort 方法,fetch API 以及 axios 可以使用 AbortController
- 「忽略請(qǐng)求」,可以基于指令式 promise 或請(qǐng)求 id 的方式封裝高階函數(shù)來(lái)減少模板代碼
兩種方式各有各的好,需要根據(jù)實(shí)際場(chǎng)景權(quán)衡利弊。
其實(shí)解決方式不止這些,像 React Query,GraphQL,rxjs 等都有競(jìng)態(tài)處理,感興趣的同學(xué)可以再繼續(xù)深入了解。
以上就是JS前端常見(jiàn)的競(jìng)態(tài)問(wèn)題解決方法詳解的詳細(xì)內(nèi)容,更多關(guān)于JS前端競(jìng)態(tài)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Three.js添加陰影和簡(jiǎn)單后期處理實(shí)現(xiàn)示例詳解
這篇文章主要為大家介紹了Three.js添加陰影和簡(jiǎn)單后期處理實(shí)現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-04-04JavaScript與JQuery框架基礎(chǔ)入門教程
這篇文章主要介紹了jQuery和JavaScript入門基礎(chǔ)知識(shí)學(xué)習(xí)指南,jQuery是當(dāng)下最主流人氣最高的JavaScript庫(kù),需要的朋友可以參考下2021-07-07