Project code: github.com/Haixiang612…

Preview link: yanhaixiang.com/my-promise-…

Reference the wheels: www.npmjs.com/package/pro…

Polling is a very common operation in the front end. However, for many people, setInterval is the first reaction to implement it. SetInterval is unstable as polling. Let’s write a promise-Poller wheel.

Starting from scratch

Starting with the setInterval method above, the lowest polling is as follows:

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

The first parameter is polling task, and the second parameter is time interval, so easy.

As mentioned earlier, setInterval is unstable. See why setTimeout() is more stable than setInterval(). Using setTimeout iteration calls to do polling will be more stable, so easy.

interface Options {
  taskFn: Function
  interval: number
}

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

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

  poll()
}
Copy the code

The input parameter is also sealed as an Options type to make it easier to extend.

We’re still not happy with this code. We can’t stand another callback in setTimeout. It’s ugly. Therefore, setTimeout can be wrapped as a delay function and poll can be called after the delay is complete.

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) // Replace setTimeout callback with delay
  }

  poll()
}
Copy the code

Is it much cleaner?

promisify

If the wheel is named with “Promise”, the promisePoller function must return a promise. So this step is going to take this function promisify.

First return a Promise.

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

  return new Promise((resolve, reject) = > { // Return a Promise
    const poll = () = > {
      const result = taskFn()

      delay(interval).then(poll)
    }

    poll()
  })
}
Copy the code

The question is: When is it a reject? When should resolve? Reject the whole poll, reject the whole poll, resolve the whole poll.

Look at the reject timing: the whole polling failure is generally a timeout is cool bai, so here to add a masterTimeout Options, said overall polling timeout, then add a setTimeout timer for the polling process.

interface Options {
  taskFn: Function
  interval: numbermasterTimeout? :number // The timeout length of the entire polling process
}

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

  let timeoutId

  return new Promise((resolve, reject) = > {
    if (masterTimeout) {
      timeoutId = setTimeout(() = > {
        reject('Master timeout') // The whole polling timed out
      }, masterTimeout)
    }

    const poll = () = > {
      taskFn()

      delay(interval).then(poll)
    }

    poll()
  })
}
Copy the code

Resolve: If the last polling task is executed, the whole polling task is successful. Well, we don’t know, we only know if the caller tells us, so add a shouldContinue callback to let the caller tell us whether the polling shouldContinue or not. If not, it will be the last time.

interface Options {
  taskFn: Function
  interval: number
  shouldContinue: (err: string | null, result: any) = > boolean // Whether to continue after the second pollingmasterTimeout? :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') // The whole polling process timed out
      }, masterTimeout)
    }

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

      if (shouldContinue(null, result)) { 
        delay(interval).then(poll)  // Continue polling
      } else {
        if(timeoutId ! = =null) {  // No polling is required, timeoutId is cleared
          clearTimeout(timeoutId)
        }
        
        resolve(result) // The last polling task completes and returns the result of the last taskFn
      }
    }

    poll()
  })
}
Copy the code

At this point, a Promisify post-Poller function is almost complete. Is there anything else to optimize? There are!

Poll the timeout of the task

You can also poll a single task with a masterTimeout, so add a taskTimeout field in Options.

No, wait! TaskFn should also support asynchronous functions. TaskFn should also support asynchronous functions. So, when taskFn is called, the result is promisify and then the promise is tested for timeout.

interface Options {
  taskFn: Function
  interval: number
  shouldContinue: (err: string | null, result: any) = > booleanmasterTimeout? :numbertaskTimeout? :number // Poll the task timeout
}

// Determine if the promise has timed out
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()) // set the result promisify

      if (taskTimeout) {
        taskPromise = timeout(taskPromise, taskTimeout) // Check whether the polling task has timed out
      }

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

        })
    }

    poll()
  })
}
Copy the code

There are three steps:

  1. willtaskFnThe results of the promisify
  2. addtimeoutFunction is used to determinetaskFnWhether or not to timeout (generally not for synchronous functions, because the result is returned immediately)
  3. judgetaskFnIf you timeout, reject directly, it goes totaskPromiseThe catch of

So what do you do after timeout reject? Of course, this is to tell the polling of the main process: Hey, this task has timed out, should I try again? So, here’s another retry feature.

retry

First, add a retries field to Options to indicate the number of retries.

interface Options {
  taskFn: Function 
  interval: number 
  shouldContinue: (err: string | null, result? :any) = > booleanmasterTimeout? :numbertaskTimeout? :numberretries? :number // Number of retries after the polling task failed
}
Copy the code

Then, in the catch, determine if retries is 0 (I really need to try again) and shouldContinue is true (I really need to try again). Retry only if both are 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) // Add to the Rejections error list
          
          if (--retriesRemain === 0| |! shouldContinue(error)) {// Determine whether to retry
            reject(rejections) // If you do not retry, the system fails
          } else {
            delay(interval).then(poll); / / try again
          }
        })
    }

    poll()
  })
}
Copy the code

It also added Rejections variables to hold multiple error messages. This is done because it is possible that 2 out of 10 tasks will fail, and both will eventually be returned, so you need an array of error messages.

Here’s another optimization point: We often see other pages fail to fetch data and show 1/3 fetch… Two-thirds of obtain… 3/3 obtain… Until really get failure, equivalent to a progress bar, so the user is also better. So, Options can provide a callback that is called every time retriesRemain is reduced.

interface Options {
  taskFn: Function 
  interval: number 
  shouldContinue: (err: string | null, result? :any) = > booleanprogressCallback? :(retriesRemain: number, error: Error) = > unknown // The remaining number of callbacksmasterTimeout? :numbertaskTimeout? :numberretries? :number // Number of retries after the polling task failed
}

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) // Add to the Rejections error list

          if (progressCallback) {
             progressCallback(retriesRemain, error) // The callback retrieves retriesRemain
          }
          
          if (--retriesRemain === 0| |! shouldContinue(error)) {// Determine whether to retry
            reject(rejections) // If you do not retry, the system fails
          } else {
            delay(interval).then(poll); / / try again
          }
        })
    }

    poll()
  })
}
Copy the code

Actively stop polling

ShouldContinue is already an effective way to control whether or not a process should abort, but it’s a bit passive to wait until the next poll has started. PromisePoller is more flexible if taskFn is actively stopped while it is executing.

TaskFn can be either synchronous or asynchronous. For synchronous functions, return false disables polling, reject(“CANCEL_TOKEN”) disables polling. Rewrite the function as follows:

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) { // End the synchronization task
        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) { // End the asynchronous task
            reject(rejections)
            polling = false
          }

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

          if (--retriesRemain === 0| |! shouldContinue(error)) { reject(rejections) }else if (polling) { // When you retry, you need to check that polling is true
            delay(interval).then(poll);
          }
        })
    }

    poll()
  })
}
Copy the code

This code checks whether taskFn returns false and CANCEL_TOKEN in a catch. If taskFn asks to abort polling, set polling to false and reject the entire process.

Here is another detail: to improve security, polling should be re-conducted only after checking whether polling is true in the retry area.

Polling strategy

At present, we design linear polling, an interval is done. To improve scalability, we provide two additional polling strategies: Linear-backoff and exponential-backoff, which increase the interval linearly and exponentially, respectively, rather than uniformly.

Set some default parameters for the policy:

export const strategies = {
  'fixed-interval': {
    defaults: {
      interval: 1000
    },
    getNextInterval: function(count: number, options: Options) {
      returnoptions.interval; }},'linear-backoff': {
    defaults: {
      start: 1000.increment: 1000
    },
    getNextInterval: function(count: number, options: Options) {
      returnoptions.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)); }}};Copy the code

Each policy has its own parameters and the getNextInterval method, which gets the next polling interval in real time during polling. Because of the start parameter, the Options parameter should also be changed.

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

interface Options {
  taskFn: Function
  shouldContinue: (err: Error | null, result? :any) = > boolean // Whether to continue after the second pollingprogressCallback? :(retriesRemain: number, error: Error) = > unknown // The remaining number of callbacksstrategy? : StrategyName// Polling policymasterTimeout? :numbertaskTimeout? :numberretries? :number
  / / fixed - interval strategyinterval? :number
  / / linear - backoff strategystart? :numberincrement? :number
  / / an exponential backoff strategymin? :numbermax? :number
}
Copy the code

In the poll function, this is easy. You just need to get the nextInterval before delay, and then delay(nextInterval).

const promisePoller = (options: Options) = > {
  const strategy = strategies[options.strategy] || strategies['fixed-interval'] // Obtains the current polling policy. By default, fixed-interval is used

  constmergedOptions = {... strategy.defaults, ... options}// Merge the initial parameters of the polling policy

  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) // Gets the interval for the next poll
            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) // Gets the interval for the next poll
            delay(nextInterval).then(poll);
          }
        })
    }

    poll()
  })
}
Copy the code

conclusion

This promisePoller mainly completes:

  1. Basic polling operations
  2. Return to the promise
  3. Provides active and passive abort polling methods
  4. Provides the retry function of polling tasks and provides the callback of retry progress
  5. Provides a variety of polling strategies: fixed-interval, Linear-backoff, and exponent-backoff

The above is the NPM package promise-Poller source code implementation.