JavaScript 異步時序問題

JavaScript 異步時序問題

吾輩的博客原文:https://blog.rxliuli.com/p/de...

場景

死後我們必昇天堂,因爲活時我們已在地獄。

不知你是否遇到過,向後臺發送了多次異步請求,結果最後顯示的數據卻並不正確 -- 是舊的數據。

具體情況:

  1. 用戶觸發事件,發送了第 1 次請求
  2. 用戶觸發事件,發送了第 2 次請求
  3. 第 2 次請求成功,更新頁面上的數據
  4. 第 1 次請求成功,更新頁面上的數據

嗯?是不是感覺到異常了?這便是多次異步請求時會遇到的異步回調順序與調用順序不同的問題。

思考

  • 爲什麼會出現這種問題?
  • 出現這種問題怎麼解決?

爲什麼會出現這種問題?

JavaScript 隨處可見異步,但實際上並不是那麼好控制。用戶與 UI 交互,觸發事件及其對應的處理函數,函數執行異步操作(網絡請求),異步操作得到結果的時間(順序)是不確定的,所以響應到 UI 上的時間就不確定,如果觸發事件的頻率較高/異步操作的時間過長,就會造成前面的異步操作結果覆蓋後面的異步操作結果。

關鍵點

  • 異步操作得到結果的時間(順序)是不確定的
  • 如果觸發事件的頻率較高/異步操作的時間過長

出現這種問題怎麼解決?

既然關鍵點由兩個要素組成,那麼,只要破壞了任意一個即可。

  • 手動控制異步返回結果的順序
  • 降低觸發頻率並限制異步超時時間

手動控制返回結果的順序

根據對異步操作結果處理情況的不同也有三種不同的思路

  1. 後面異步操作得到結果後等待前面的異步操作返回結果
  2. 後面異步操作得到結果後放棄前面的異步操作返回結果
  3. 依次處理每一個異步操作,等待上一個異步操作完成之後再執行下一個

這裏先引入一個公共的 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()
    }
  })
}

1. 後面異步操作得到結果後等待前面的異步操作返回結果

/**
 * 將一個異步函數包裝爲具有時序的異步函數
 * 注: 該函數會按照調用順序依次返回結果,後面的調用的結果需要等待前面的,所以如果不關心過時的結果,請使用 {@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)
})()

2. 後面異步操作得到結果後放棄前面的異步操作返回結果

/**
 * 將一個異步函數包裝爲具有時序的異步函數
 * 注: 該函數會丟棄過期的異步操作結果,這樣的話性能會稍稍提高(主要是響應比較快的結果會立刻生效而不必等待前面的響應結果)
 * @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)
})()

3. 依次處理每一個異步操作,等待上一個異步操作完成之後再執行下一個

/**
 * 將一個異步函數包裝爲具有時序的異步函數
 * 注: 該函數會按照調用順序依次返回結果,後面的執行的調用(不是調用結果)需要等待前面的,此函數適用於異步函數的內裏執行也必須保證順序時使用,否則請使用 {@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)
})()

小結

雖然三個函數看似效果都差不多,但還是有所不同的。

  1. 是否允許異步操作併發?否: concatMap, 是: 到下一步
  2. 是否需要處理舊的的結果?否: 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,那麼,原因是什麼呢?這個疑問就留到下次再說吧!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章