造一個 promise-poller 輪子

項目代碼:https://github.com/Haixiang6123/my-promise-poller

預覽鏈接:http://yanhaixiang.com/my-promise-poller/

參考輪子:https://www.npmjs.com/package/promise-poller

輪詢,一個前端非常常見的操作,然而對於很多人來說第一反應竟然還是用 setInterval 來實現, setInterval 作爲輪詢是不穩定的。下面就帶大家一起寫一個 promise-poller 的輪子吧。

從零開始

先從上面說的 setInterval 的方法開始寫起,一個最 Low 的輪詢如下:

const promisePoller = (taskFn: Function, interval: number) => {
  setInterval(() => {
    taskFn();
  }, interval)
}

第一個參數爲輪詢任務,第二參數爲時間間隔,so easy。

剛剛也說了,setInterval 是不穩定的,詳見:爲什麼setTimeout()比setInterval()穩定。用 setTimeout 迭代調用來做輪詢會更穩定,面試題常見操作,so easy。

interface Options {
  taskFn: Function
  interval: number
}

const promisePoller = (options: Options) => {
  const {taskFn, interval} = options

  const poll = () => {
    setTimeout(() => {
      taskFn()
      poll()
    }, interval)
  }

  poll()
}

上面還把入參封成 Options 類型,更容易擴展入參類型。

這樣的代碼我們還是不滿意,受不了 setTimeout 裏又一個回調,太醜了。因此,可以把 setTimeout 封裝成一個 delay 函數,delay 完成再去調用 poll 就好了。

export const delay = (interval: number) => new Promise(resolve => {
  setTimeout(resolve, interval)
})

const promisePoller = (options: Options) => {
  const {taskFn, interval} = options

  const poll = () => {
    taskFn()

    delay(interval).then(poll) // 使用 delay 替換 setTimeout 的回調
  }

  poll()
}

是不是變乾淨多了?

promisify

即然這個輪子的名字都帶有 "promise",那 promisePoller 函數肯定要返回一個 Promise 呀。這一步就要把這個函數 promisify。

首先返回一個 Promise。

const promisePoller = (options: Options) => {
  const {taskFn, interval, masterTimeout} = options

  return new Promise((resolve, reject) => { // 返回一個 Promise
    const poll = () => {
      const result = taskFn()

      delay(interval).then(poll)
    }

    poll()
  })
}

那問題來了:什麼時候該 reject ?什麼時候該 resolve 呢?自然是整個輪詢失敗就 reject,整個輪詢成功就 resolve 唄。

先看 reject 時機:整個輪詢失敗一般是 timeout 了就涼了唄,所以這裏加個 masterTimeoutOptions 中,表示整體輪詢的超時時間,再爲整個輪詢過程加個 setTimeout 計時器。

interface Options {
  taskFn: Function
  interval: number
  masterTimeout?: number // 整個輪詢過程的 timeout 時長
}

const promisePoller = (options: Options) => {
  const {taskFn, interval, masterTimeout} = options

  let timeoutId

  return new Promise((resolve, reject) => {
    if (masterTimeout) {
      timeoutId = setTimeout(() => {
        reject('Master timeout') // 整個輪詢超時了
      }, masterTimeout)
    }

    const poll = () => {
      taskFn()

      delay(interval).then(poll)
    }

    poll()
  })
}

再看 resolve 時機:執行到最後一次輪詢任務就說明整個輪詢成功了嘛,那怎麼才知道這是後一次的輪詢任務呢?呃,我們並不能知道,只能通過調用方告訴我們才知道,所以加個 shouldContinue 的回調讓調用方告訴我們當前是否應該繼續輪詢,如果不繼續就是最後一次了嘛。

interface Options {
  taskFn: Function
  interval: number
  shouldContinue: (err: string | null, result: any) => boolean // 當次輪詢後是否需要繼續
  masterTimeout?: number
}

const promisePoller = (options: Options) => {
  const {taskFn, interval, masterTimeout, shouldContinue} = options

  let timeoutId: null | number

  return new Promise((resolve, reject) => {
    if (masterTimeout) {
      timeoutId = window.setTimeout(() => {
        reject('Master timeout') // 整個輪詢過程超時了
      }, masterTimeout)
    }

    const poll = () => {
      const result = taskFn()

      if (shouldContinue(null, result)) { 
        delay(interval).then(poll)  // 繼續輪詢
      } else {
        if (timeoutId !== null) {  // 不需要輪詢,有 timeoutId 則清除
          clearTimeout(timeoutId)
        }
        
        resolve(result) // 最後一個輪詢任務了,結束並返回最後一次 taskFn 的結果
      }
    }

    poll()
  })
}

至此,一個 Promisify 後的 poller 函數已經大體完成了。還有沒有得優化呢?有!

輪詢任務的 timeout

剛剛提到 masterTimeout,相對地,也應該有輪詢單個任務的 timeout,所以,在 Options 里加個 taskTimeout 字段吧。

不對,等等!上面好像我們默認 taskFn 是同步的函數呀,timeout 一般針對異步函數設計的,這也提示了我們 taskFn 應該也要支持異步函數纔行。所以,在調用 taskFn 的時候,要將其結果 promisify,然後對這個 promise 進行 timeout 的檢測。

interface Options {
  taskFn: Function
  interval: number
  shouldContinue: (err: string | null, result: any) => boolean
  masterTimeout?: number
  taskTimeout?: number // 輪詢任務的 timeout
}

// 判斷該 promise 是否超時了
const timeout = (promise: Promise<any>, interval: number) => {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => reject('Task timeout'), interval)

    promise.then(result => {
      clearTimeout(timeoutId)
      resolve(result)
    })
  })
}

const promisePoller = (options: Options) => {
  const {taskFn, interval, masterTimeout, taskTimeout, shouldContinue} = options

  let timeoutId: null | number

  return new Promise((resolve, reject) => {
    if (masterTimeout) {
      timeoutId = window.setTimeout(() => {
        reject('Master timeout')
      }, masterTimeout)
    }

    const poll = () => {
      let taskPromise = Promise.resolve(taskFn()) // 將結果 promisify

      if (taskTimeout) {
        taskPromise = timeout(taskPromise, taskTimeout) // 檢查該輪詢任務是否超時了
      }

      taskPromise
        .then(result => {
          if (shouldContinue(null, result)) {
            delay(interval).then(poll)
          } else {
            if (timeoutId !== null) {
              clearTimeout(timeoutId)
            }
            resolve(result)
          }
        })
        .catch(error => {

        })
    }

    poll()
  })
}

上面一共完成了三步:

  1. taskFn 的結果 promisify
  2. 添加 timeout 函數用於判斷 taskFn 是否超時(對於同步函數其實一般來說不會 timeout,因爲結果是馬上返回的)
  3. 判斷 taskFn 是否超時,超時了直接 reject,會走到 taskPromise 的 catch 裏

那如果真的超時了,timeout reject 了之後幹啥呢?當然是告訴主流程的輪詢說:哎,這個任務超時了,我要不要重試一下啊。因此,這裏又要引入一個重試的功能了。

重試

首先,在 Options 加個 retries 的字段表示可重試的次數。

interface Options {
  taskFn: Function 
  interval: number 
  shouldContinue: (err: string | null, result?: any) => boolean 
  masterTimeout?: number 
  taskTimeout?: number 
  retries?: number // 輪詢任務失敗後重試次數
}

接着在 catch 裏,判斷 retries 是否爲 0(重試次數還沒用完) 和 shouldContinue 的值是否爲 true(我真的要重試啊),以此來確定是否真的需要重試。只有兩者都爲 true 時才重試。

const promisePoller = (options: Options) => {
  ...
  let rejections: Array<Error | string> = []
  let retriesRemain = retries

  return new Promise((resolve, reject) => {
    ...
    const poll = () => {
      ...

      taskPromise
        .then(result => {
          ...
        })
        .catch(error => {
          rejections.push(error) // 加入 rejections 錯誤列表
          
          if (--retriesRemain === 0 || !shouldContinue(error)) { // 判斷是否需要重試
            reject(rejections) // 不重試,直接失敗
          } else {
            delay(interval).then(poll); // 重試
          }
        })
    }

    poll()
  })
}

上面還添加 rejections 變量用於存放多個 error 信息。這樣的設計是因爲有可能 10 個任務裏 2 個失敗了,那最後就要把 2 個失敗的信息都返回,因此需要一個數組存放錯誤信息。

這裏還一個優化點:我們常常看到別人頁面獲取數據失敗都會顯示 1/3 獲取... 2/3 獲取... 3/3 獲取... 直到真的獲取失敗,相當於有個進度條,這樣對用戶也比較好。所以,在 Options 可以提供一個回調,每次 retriesRemain 要減少時調用一下就好了。

interface Options {
  taskFn: Function 
  interval: number 
  shouldContinue: (err: string | null, result?: any) => boolean 
  progressCallback?: (retriesRemain: number, error: Error) => unknown // 剩餘次數回調
  masterTimeout?: number 
  taskTimeout?: number 
  retries?: number // 輪詢任務失敗後重試次數
}

const promisePoller = (options: Options) => {
  ...
  let rejections: Array<Error | string> = []
  let retriesRemain = retries

  return new Promise((resolve, reject) => {
    ...
    const poll = () => {
      ...

      taskPromise
        .then(result => {
          ...
        })
        .catch(error => {
          rejections.push(error) // 加入 rejections 錯誤列表

          if (progressCallback) {
             progressCallback(retriesRemain, error) // 回調獲取 retriesRemain
          }
          
          if (--retriesRemain === 0 || !shouldContinue(error)) { // 判斷是否需要重試
            reject(rejections) // 不重試,直接失敗
          } else {
            delay(interval).then(poll); // 重試
          }
        })
    }

    poll()
  })
}

主動停止輪詢

雖然 shouldContinue 已經可以有效地控制流程是否要中止,但是每次都要等下一次輪詢開始之後纔會判斷,這樣未免有點被動。如果可以在 taskFn 執行的時候就主動停止,那 promisePoller 就更靈活了。

taskFn 有可能是同步函數或者異步函數,對於同步函數,我們規定 return false 就停止輪詢,對於異步函數,規定 reject("CANCEL_TOKEN") 就停止輪詢。函數改寫如下:

const CANCEL_TOKEN = 'CANCEL_TOKEN'

const promisePoller = (options: Options) => {
  const {taskFn, masterTimeout, taskTimeout, progressCallback, shouldContinue, retries = 5} = mergedOptions

  let polling = true
  let timeoutId: null | number
  let rejections: Array<Error | string> = []
  let retriesRemain = retries

  return new Promise((resolve, reject) => {
    if (masterTimeout) {
      timeoutId = window.setTimeout(() => {
        reject('Master timeout')
        polling = false
      }, masterTimeout)
    }

    const poll = () => {
      let taskResult = taskFn()

      if (taskResult === false) { // 結束同步任務
        taskResult = Promise.reject(taskResult)
        reject(rejections)
        polling = false
      }

      let taskPromise = Promise.resolve(taskResult)

      if (taskTimeout) {
        taskPromise = timeout(taskPromise, taskTimeout)
      }

      taskPromise
        .then(result => {
          ...
        })
        .catch(error => {
          if (error === CANCEL_TOKEN) { // 結束異步任務
            reject(rejections)
            polling = false
          }

          rejections.push(error)
          
          if (progressCallback) {
             progressCallback(retriesRemain, error)
          }

          if (--retriesRemain === 0 || !shouldContinue(error)) {
            reject(rejections)
          } else if (polling) { // 再次重試時,需要檢查 polling 是否爲 true
            delay(interval).then(poll);
          }
        })
    }

    poll()
  })
}

上面代碼判斷了 taskFn 的返回值是否爲 false,在 catch 裏判斷 error 是否爲 CANCEL_TOKEN。如果 taskFn 主動要求中止輪詢,那麼設置 pollingfalse 並 reject 整個流程。

這裏還有個細節是:爲了提高安全性,在重試的那裏要再檢查一次 polling 是否爲 true 才重新 poll

輪詢策略

目前我們設計的都是線性輪詢的,一個 interval 搞定。爲了提高擴展性,我們再提供另外 2 種輪詢策略:linear-backoff 和 exponential-backoff,分別對 interval 的線性遞增和指數遞增,而非勻速不變。

先定好策略的一些默認參數:

export const strategies = {
  'fixed-interval': {
    defaults: {
      interval: 1000
    },
    getNextInterval: function(count: number, options: Options) {
      return options.interval;
    }
  },

  'linear-backoff': {
    defaults: {
      start: 1000,
      increment: 1000
    },
    getNextInterval: function(count: number, options: Options) {
      return options.start + options.increment * count;
    }
  },

  'exponential-backoff': {
    defaults: {
      min: 1000,
      max: 30000
    },
    getNextInterval: function(count: number, options: Options) {
      return Math.min(options.max, Math.round(Math.random() * (Math.pow(2, count) * 1000 - options.min) + options.min));
    }
  }
};

每種策略都有自己的參數和 getNextInterval 的方法,前者爲起始參數,後者在輪詢的時候實時獲取下一次輪詢的時間間隔。因爲有了起始參數,Options 的參數也要改動一下。

type StrategyName = 'fixed-interval' | 'linear-backoff' | 'exponential-backoff'

interface Options {
  taskFn: Function
  shouldContinue: (err: Error | null, result?: any) => boolean // 當次輪詢後是否需要繼續
  progressCallback?: (retriesRemain: number, error: Error) => unknown // 剩餘次數回調
  strategy?: StrategyName // 輪詢策略
  masterTimeout?: number
  taskTimeout?: number
  retries?: number
  // fixed-interval 策略
  interval?: number
  // linear-backoff 策略
  start?: number
  increment?: number
  // exponential-backoff 策略
  min?: number
  max?: number
}

poll 函數裏就簡單了,只需要在 delay 之前獲取一下 nextInterval,然後 delay(nextInterval) 即可。

const promisePoller = (options: Options) => {
  const strategy = strategies[options.strategy] || strategies['fixed-interval'] // 獲取當前的輪詢策略,默認使用 fixed-interval

  const mergedOptions = {...strategy.defaults, ...options} // 合併輪詢策略的初始參數

  const {taskFn, masterTimeout, taskTimeout, progressCallback, shouldContinue, retries = 5} = mergedOptions

  let polling = true
  let timeoutId: null | number
  let rejections: Array<Error | string> = []
  let retriesRemain = retries

  return new Promise((resolve, reject) => {
    if (masterTimeout) {
      timeoutId = window.setTimeout(() => {
        reject(new Error('Master timeout'))
        polling = false
      }, masterTimeout)
    }

    const poll = () => {
      let taskResult = taskFn()

      if (taskResult === false) {
        taskResult = Promise.reject(taskResult)
        reject(rejections)
        polling = false
      }

      let taskPromise = Promise.resolve(taskResult)

      if (taskTimeout) {
        taskPromise = timeout(taskPromise, taskTimeout)
      }

      taskPromise
        .then(result => {
          if (shouldContinue(null, result)) {
            const nextInterval = strategy.getNextInterval(retriesRemain, mergedOptions) // 獲取下次輪詢的時間間隔
            delay(nextInterval).then(poll)
          } else {
            if (timeoutId !== null) {
              clearTimeout(timeoutId)
            }
            resolve(result)
          }
        })
        .catch((error: Error) => {
          if (error.message === CANCEL_TOKEN) {
            reject(rejections)
            polling = false
          }

          rejections.push(error)

          if (progressCallback) {
             progressCallback(retriesRemain, error)
          }

          if (--retriesRemain === 0 || !shouldContinue(error)) {
            reject(rejections)
          } else if (polling) {
            const nextInterval = strategy.getNextInterval(retriesRemain, options) // 獲取下次輪詢的時間間隔
            delay(nextInterval).then(poll);
          }
        })
    }

    poll()
  })
}

總結

這個 promisePoller 主要完成了:

  1. 基礎的輪詢操作
  2. 返回 promise
  3. 提供主動和被動中止輪詢的方法
  4. 提供輪詢任務重試的功能,並提供重試進度回調
  5. 提供多種輪詢策略:fixed-interval, linear-backoff, exponential-backoff

以上就是 npm 包 promise-poller 的源碼實現了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章