吾輩的博客原文: https://blog.rxliuli.com/p/de...
死後咱們必昇天堂,由於活時咱們已在地獄。
不知你是否遇到過,向後臺發送了屢次異步請求,結果最後顯示的數據卻並不正確 -- 是舊的數據。node
具體狀況:git
嗯?是否是感受到異常了?這即是屢次異步請求時會遇到的異步回調順序與調用順序不一樣的問題。github
JavaScript 隨處可見異步,但實際上並非那麼好控制。用戶與 UI 交互,觸發事件及其對應的處理函數,函數執行異步操做(網絡請求),異步操做獲得結果的時間(順序)是不肯定的,因此響應到 UI 上的時間就不肯定,若是觸發事件的頻率較高/異步操做的時間過長,就會形成前面的異步操做結果覆蓋後面的異步操做結果。數據庫
關鍵點編程
既然關鍵點由兩個要素組成,那麼,只要破壞了任意一個便可。瀏覽器
根據對異步操做結果處理狀況的不一樣也有三種不一樣的思路緩存
這裏先引入一個公共的 wait
函數服務器
/** * 等待指定的時間/等待指定表達式成立 * 若是未指定等待條件則馬上執行 * 注: 此實如今 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() } }) }
/** * 將一個異步函數包裝爲具備時序的異步函數 * 注: 該函數會按照調用順序依次返回結果,後面的調用的結果須要等待前面的,因此若是不關心過期的結果,請使用 {@link switchMap} 函數 * @param fn 一個普通的異步函數 * @returns 包裝後的函數 */ function mergeMap(fn) { // 當前執行的異步操做 id let id = 0 // 所執行的異步操做 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) // 實際上確實執行了 3 次,結果也確實爲 3 次調用參數之和 console.log(sum) })()
/** * 將一個異步函數包裝爲具備時序的異步函數 * 注: 該函數會丟棄過時的異步操做結果,這樣的話性能會稍稍提升(主要是響應比較快的結果會馬上生效而沒必要等待前面的響應結果) * @param fn 一個普通的異步函數 * @returns 包裝後的函數 */ function switchMap(fn) { // 當前執行的異步操做 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) // 實際上確實執行了 3 次,然而結果並非 3 次調用參數之和,由於前兩次的結果均被拋棄,實際上返回了最後一次發送請求的結果 console.log(sum) })()
/** * 將一個異步函數包裝爲具備時序的異步函數 * 注: 該函數會按照調用順序依次返回結果,後面的執行的調用(不是調用結果)須要等待前面的,此函數適用於異步函數的內裏執行也必須保證順序時使用,不然請使用 {@link mergeMap} 函數 * 注: 該函數其實至關於調用 {@code asyncLimiting(fn, {limit: 1})} 函數 * 例如即時保存文檔到服務器,固然要等待上一次的請求結束才能請求下一次,否則數據庫保存的數據就存在謬誤了 * @param fn 一個普通的異步函數 * @returns 包裝後的函數 */ function concatMap(fn) { // 當前執行的異步操做 id let id = 0 // 所執行的異步操做 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) // 實際上確實執行了 3 次,然而結果並非 3 次調用參數之和,由於前兩次的結果均被拋棄,實際上返回了最後一次發送請求的結果 console.log(sum) })()
雖然三個函數看似效果都差很少,但仍是有所不一樣的。
concatMap
, 是: 到下一步switchMap
, 是: mergeMap
思考一下第二種解決方式,本質上實際上是 限流 + 自動超時,首先實現這兩個函數。
下面來分別實現它們
具體實現思路可見: JavaScript 防抖和節流
/** * 函數節流 * 節流 (throttle) 讓一個函數不要執行的太頻繁,減小執行過快的調用,叫節流 * 相似於上面而又不一樣於上面的函數去抖, 包裝後函數在上一次操做執行過去了最小間隔時間後會直接執行, 不然會忽略該次操做 * 與上面函數去抖的明顯區別在連續操做時會按照最小間隔時間循環執行操做, 而非僅執行最後一次操做 * 注: 該函數第一次調用必定會執行,不須要擔憂第一次拿不到緩存值,後面的連續調用都會拿到上一次的緩存值 * 注: 返回函數結果的高階函數須要使用 {@link Proxy} 實現,以免原函數原型鏈上的信息丟失 * * @param {Number} delay 最小間隔時間,單位爲 ms * @param {Function} action 真正須要執行的操做 * @return {Function} 包裝後有節流功能的函數。該函數是異步的,與須要包裝的函數 {@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
函數實際上只是爲了不一種狀況,異步請求時間超過節流函數最小間隔時間致使結果返回順序錯亂。
/** * 爲異步函數添加自動超時功能 * @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) // 實際上確實執行了 3 次,結果也確實爲第一次次調用參數的 3 倍 console.log(sum) })()
起初吾輩由於好奇實現了這種方式,但原覺得會和 concatMap
相似的函數卻變成了如今這樣 -- 更像倒置的 switchMap
了。不過由此看來這種方式的可行性並不大,畢竟,沒人須要舊的數據。
其實第一種實現方式屬於 rxjs 早就已經走過的道路,目前被 Angular 大量採用(類比於 React 中的 Redux)。但 rxjs 實在太強大也太複雜了,對於吾輩而言,僅僅須要一隻香蕉,而不須要拿着香蕉的大猩猩,以及其所處的整個森林(此處本來是被人吐槽面向對象編程的隱含環境,這裏吾輩稍微藉此吐槽一下動不動就上庫的開發者)。
能夠看到吾輩在這裏大量使用了
Proxy
,那麼,緣由是什麼呢?這個疑問就留到下次再說吧!