項目代碼:https://github.com/Haixiang6123/my-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 了就涼了唄,所以這裏加個 masterTimeout
到 Options
中,表示整體輪詢的超時時間,再爲整個輪詢過程加個 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()
})
}
上面一共完成了三步:
- 將
taskFn
的結果 promisify - 添加
timeout
函數用於判斷taskFn
是否超時(對於同步函數其實一般來說不會 timeout,因爲結果是馬上返回的) - 判斷
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
主動要求中止輪詢,那麼設置 polling
爲 false
並 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
主要完成了:
- 基礎的輪詢操作
- 返回 promise
- 提供主動和被動中止輪詢的方法
- 提供輪詢任務重試的功能,並提供重試進度回調
- 提供多種輪詢策略:fixed-interval, linear-backoff, exponential-backoff
以上就是 npm 包 promise-poller 的源碼實現了。