摘要:異步時序問題吾輩的博客原文場景死后我們必升天堂,因為活時我們已在地獄。關鍵點異步操作得到結果的時間順序是不確定的如果觸發(fā)事件的頻率較高異步操作的時間過長出現這種問題怎么解決既然關鍵點由兩個要素組成,那么,只要破壞了任意一個即可。
JavaScript 異步時序問題
吾輩的博客原文:https://blog.rxliuli.com/p/de...場景
死后我們必升天堂,因為活時我們已在地獄。
不知你是否遇到過,向后臺發(fā)送了多次異步請求,結果最后顯示的數據卻并不正確 -- 是舊的數據。
具體情況:
用戶觸發(fā)事件,發(fā)送了第 1 次請求
用戶觸發(fā)事件,發(fā)送了第 2 次請求
第 2 次請求成功,更新頁面上的數據
第 1 次請求成功,更新頁面上的數據
嗯?是不是感覺到異常了?這便是多次異步請求時會遇到的異步回調順序與調用順序不同的問題。
思考為什么會出現這種問題?
出現這種問題怎么解決?
為什么會出現這種問題?JavaScript 隨處可見異步,但實際上并不是那么好控制。用戶與 UI 交互,觸發(fā)事件及其對應的處理函數,函數執(zhí)行異步操作(網絡請求),異步操作得到結果的時間(順序)是不確定的,所以響應到 UI 上的時間就不確定,如果觸發(fā)事件的頻率較高/異步操作的時間過長,就會造成前面的異步操作結果覆蓋后面的異步操作結果。
關鍵點
異步操作得到結果的時間(順序)是不確定的
如果觸發(fā)事件的頻率較高/異步操作的時間過長
出現這種問題怎么解決?既然關鍵點由兩個要素組成,那么,只要破壞了任意一個即可。
手動控制異步返回結果的順序
降低觸發(fā)頻率并限制異步超時時間
手動控制返回結果的順序根據對異步操作結果處理情況的不同也有三種不同的思路
后面異步操作得到結果后等待前面的異步操作返回結果
后面異步操作得到結果后放棄前面的異步操作返回結果
依次處理每一個異步操作,等待上一個異步操作完成之后再執(zhí)行下一個
這里先引入一個公共的 wait 函數
/** * 等待指定的時間/等待指定表達式成立 * 如果未指定等待條件則立刻執(zhí)行 * 注: 此實現在 nodejs 10- 會存在宏任務與微任務的問題,切記 async-await 本質上還是 Promise 的語法糖,實際上并非真正的同步函數!!!即便在瀏覽器,也不要依賴于這種特性。 * @param param 等待時間/等待條件 * @returns Promise 對象 */ function wait(param) { return new Promise(resolve => { if (typeof param === "number") { setTimeout(resolve, param) } else if (typeof param === "function") { const timer = setInterval(() => { if (param()) { clearInterval(timer) resolve() } }, 100) } else { resolve() } }) }1. 后面異步操作得到結果后等待前面的異步操作返回結果
/** * 將一個異步函數包裝為具有時序的異步函數 * 注: 該函數會按照調用順序依次返回結果,后面的調用的結果需要等待前面的,所以如果不關心過時的結果,請使用 {@link switchMap} 函數 * @param fn 一個普通的異步函數 * @returns 包裝后的函數 */ function mergeMap(fn) { // 當前執(zhí)行的異步操作 id let id = 0 // 所執(zhí)行的異步操作 id 列表 const ids = new Set() return new Proxy(fn, { async apply(_, _this, args) { const prom = Reflect.apply(_, _this, args) const temp = id ids.add(temp) id++ await wait(() => !ids.has(temp - 1)) ids.delete(temp) return await prom }, }) }
測試一下
;(async () => { // 模擬一個異步請求,接受參數并返回它,然后等待指定的時間 async function get(ms) { await wait(ms) return ms } const fn = mergeMap(get) let last = 0 let sum = 0 await Promise.all([ fn(30).then(res => { last = res sum += res }), fn(20).then(res => { last = res sum += res }), fn(10).then(res => { last = res sum += res }), ]) console.log(last) // 實際上確實執(zhí)行了 3 次,結果也確實為 3 次調用參數之和 console.log(sum) })()2. 后面異步操作得到結果后放棄前面的異步操作返回結果
/** * 將一個異步函數包裝為具有時序的異步函數 * 注: 該函數會丟棄過期的異步操作結果,這樣的話性能會稍稍提高(主要是響應比較快的結果會立刻生效而不必等待前面的響應結果) * @param fn 一個普通的異步函數 * @returns 包裝后的函數 */ function switchMap(fn) { // 當前執(zhí)行的異步操作 id let id = 0 // 最后一次異步操作的 id,小于這個的操作結果會被丟棄 let last = 0 // 緩存最后一次異步操作的結果 let cache return new Proxy(fn, { async apply(_, _this, args) { const temp = id id++ const res = await Reflect.apply(_, _this, args) if (temp < last) { return cache } cache = res last = temp return res }, }) }
測試一下
;(async () => { // 模擬一個異步請求,接受參數并返回它,然后等待指定的時間 async function get(ms) { await wait(ms) return ms } const fn = switchMap(get) let last = 0 let sum = 0 await Promise.all([ fn(30).then(res => { last = res sum += res }), fn(20).then(res => { last = res sum += res }), fn(10).then(res => { last = res sum += res }), ]) console.log(last) // 實際上確實執(zhí)行了 3 次,然而結果并不是 3 次調用參數之和,因為前兩次的結果均被拋棄,實際上返回了最后一次發(fā)送請求的結果 console.log(sum) })()3. 依次處理每一個異步操作,等待上一個異步操作完成之后再執(zhí)行下一個
/** * 將一個異步函數包裝為具有時序的異步函數 * 注: 該函數會按照調用順序依次返回結果,后面的執(zhí)行的調用(不是調用結果)需要等待前面的,此函數適用于異步函數的內里執(zhí)行也必須保證順序時使用,否則請使用 {@link mergeMap} 函數 * 注: 該函數其實相當于調用 {@code asyncLimiting(fn, {limit: 1})} 函數 * 例如即時保存文檔到服務器,當然要等待上一次的請求結束才能請求下一次,不然數據庫保存的數據就存在謬誤了 * @param fn 一個普通的異步函數 * @returns 包裝后的函數 */ function concatMap(fn) { // 當前執(zhí)行的異步操作 id let id = 0 // 所執(zhí)行的異步操作 id 列表 const ids = new Set() return new Proxy(fn, { async apply(_, _this, args) { const temp = id ids.add(temp) id++ await wait(() => !ids.has(temp - 1)) const prom = Reflect.apply(_, _this, args) ids.delete(temp) return await prom }, }) }
測試一下
;(async () => { // 模擬一個異步請求,接受參數并返回它,然后等待指定的時間 async function get(ms) { await wait(ms) return ms } const fn = concatMap(get) let last = 0 let sum = 0 await Promise.all([ fn(30).then(res => { last = res sum += res }), fn(20).then(res => { last = res sum += res }), fn(10).then(res => { last = res sum += res }), ]) console.log(last) // 實際上確實執(zhí)行了 3 次,然而結果并不是 3 次調用參數之和,因為前兩次的結果均被拋棄,實際上返回了最后一次發(fā)送請求的結果 console.log(sum) })()小結
雖然三個函數看似效果都差不多,但還是有所不同的。
是否允許異步操作并發(fā)?否: concatMap, 是: 到下一步
是否需要處理舊的的結果?否: switchMap, 是: mergeMap
降低觸發(fā)頻率并限制異步超時時間思考一下第二種解決方式,本質上其實是 限流 + 自動超時,首先實現這兩個函數。
限流: 限制函數調用的頻率,如果調用的頻率過快則不會真正執(zhí)行調用而是返回舊值
自動超時: 如果到了超時時間,即便函數還未得到結果,也會自動超時并拋出錯誤
下面來分別實現它們
限流實現具體實現思路可見: JavaScript 防抖和節(jié)流
/** * 函數節(jié)流 * 節(jié)流 (throttle) 讓一個函數不要執(zhí)行的太頻繁,減少執(zhí)行過快的調用,叫節(jié)流 * 類似于上面而又不同于上面的函數去抖, 包裝后函數在上一次操作執(zhí)行過去了最小間隔時間后會直接執(zhí)行, 否則會忽略該次操作 * 與上面函數去抖的明顯區(qū)別在連續(xù)操作時會按照最小間隔時間循環(huán)執(zhí)行操作, 而非僅執(zhí)行最后一次操作 * 注: 該函數第一次調用一定會執(zhí)行,不需要擔心第一次拿不到緩存值,后面的連續(xù)調用都會拿到上一次的緩存值 * 注: 返回函數結果的高階函數需要使用 {@link Proxy} 實現,以避免原函數原型鏈上的信息丟失 * * @param {Number} delay 最小間隔時間,單位為 ms * @param {Function} action 真正需要執(zhí)行的操作 * @return {Function} 包裝后有節(jié)流功能的函數。該函數是異步的,與需要包裝的函數 {@link action} 是否異步沒有太大關聯 */ const throttle = (delay, action) => { let last = 0 let result return new Proxy(action, { apply(target, thisArg, args) { return new Promise(resolve => { const curr = Date.now() if (curr - last > delay) { result = Reflect.apply(target, thisArg, args) last = curr resolve(result) return } resolve(result) }) }, }) }自動超時
注: asyncTimeout 函數實際上只是為了避免一種情況,異步請求時間超過節(jié)流函數最小間隔時間導致結果返回順序錯亂。
/** * 為異步函數添加自動超時功能 * @param timeout 超時時間 * @param action 異步函數 * @returns 包裝后的異步函數 */ function asyncTimeout(timeout, action) { return new Proxy(action, { apply(_, _this, args) { return Promise.race([ Reflect.apply(_, _this, args), wait(timeout).then(Promise.reject), ]) }, }) }結合使用
;(async () => { let last = 0 let sum = 0 // 模擬一個異步請求,接受參數并返回它,然后等待指定的時間 async function get(ms) { await wait(ms) return ms } const time = 100 const fn = asyncTimeout(time, throttle(time, get)) await Promise.all([ fn(30).then(res => { console.log(res, last, sum) last = res sum += res }), fn(20).then(res => { console.log(res, last, sum) last = res sum += res }), fn(10).then(res => { console.log(res, last, sum) last = res sum += res }), ]) // last 結果為 10,和 switchMap 的不同點在于會保留最小間隔期間的第一次,而拋棄掉后面的異步結果,和 switchMap 正好相反! console.log(last) // 實際上確實執(zhí)行了 3 次,結果也確實為第一次次調用參數的 3 倍 console.log(sum) })()
起初吾輩因為好奇實現了這種方式,但原以為會和 concatMap 類似的函數卻變成了現在這樣 -- 更像倒置的 switchMap 了。不過由此看來這種方式的可行性并不大,畢竟,沒人需要舊的數據。
總結其實第一種實現方式屬于 rxjs 早就已經走過的道路,目前被 Angular 大量采用(類比于 React 中的 Redux)。但 rxjs 實在太強大也太復雜了,對于吾輩而言,僅僅需要一只香蕉,而不需要拿著香蕉的大猩猩,以及其所處的整個森林(此處原本是被人吐槽面向對象編程的隱含環(huán)境,這里吾輩稍微藉此吐槽一下動不動就上庫的開發(fā)者)。
可以看到吾輩在這里大量使用了 Proxy,那么,原因是什么呢?這個疑問就留到下次再說吧!
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規(guī)行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/104673.html
摘要:模塊化是隨著前端技術的發(fā)展,前端代碼爆炸式增長后,工程化所采取的必然措施。目前模塊化的思想分為和。特別指出,事件不等同于異步,回調也不等同于異步。將會討論安全的類型檢測惰性載入函數凍結對象定時器等話題。 Vue.js 前后端同構方案之準備篇——代碼優(yōu)化 目前 Vue.js 的火爆不亞于當初的 React,本人對寫代碼有潔癖,代碼也是藝術。此篇是準備篇,工欲善其事,必先利其器。我們先在代...
摘要:好吧,本文的主題可能還深入剖析的深復制前端掘金一年前我曾寫過一篇中的一種深復制實現,當時寫這篇文章的時候還比較稚嫩,有很多地方沒有考慮仔細。 翻譯 | 深入理解 CSS 時序函數 - 前端 - 掘金作者:Nicolas(滬江前端開發(fā)工程師) 本文原創(chuàng),轉載請注明作者及出處。 各位,趕緊綁住自己并緊緊抓牢了,因為當你掌握了特別有趣但又復雜的CSS時序函數之后,你將會真正體驗到豎起頭發(fā)般的...
摘要:回調函數不是由該函數的實現方直接調用,而是在特定的事件或條件發(fā)生時由另外的一方調用的,用于對該事件或條件進行響應。若是使用回調函數進行處理,代碼就可以繼續(xù)進行其他任務,而無需空等。參考理解回調函數理解與使用中的回調函數這篇相當不錯回調函數 為什么寫回調函數 對于javascript中回調函數 一直處于理解,但是應用不好的階段,總是在別人家的代碼中看到很巧妙的回調,那時候會有wow c...
摘要:函數會在之后的某個時刻觸發(fā)事件定時器。事件循環(huán)中的這樣一次遍歷被稱為一個。執(zhí)行完畢并出棧。當定時器過期,宿主環(huán)境會把回調函數添加至事件循環(huán)隊列中,然后,在未來的某個取出并執(zhí)行該事件。 原文請查閱這里,略有改動。 本系列持續(xù)更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第四章。 現在,我們將會通過回顧單線程環(huán)境下編程的弊端及如何克服這些困難以創(chuàng)建令人驚嘆...
閱讀 2371·2021-11-24 10:31
閱讀 3426·2021-11-23 09:51
閱讀 2238·2021-11-15 18:11
閱讀 2386·2021-09-02 15:15
閱讀 2452·2019-08-29 17:02
閱讀 2283·2019-08-29 15:04
閱讀 829·2019-08-29 12:27
閱讀 2853·2019-08-28 18:15