├── .gitignore ├── LICENSE ├── README.md ├── deploy.js ├── package.json ├── public └── index.html ├── src ├── index.ts └── lib │ ├── contants.ts │ ├── index.ts │ └── utils.ts ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .idea 107 | 108 | package-lock.json 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Haixiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 造一个 promise-poller 轮子 2 | 3 | ![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f9e816410ded421cb07ebdae647a3dc6~tplv-k3u1fbpfcp-zoom-1.image) 4 | 5 | > 项目代码:https://github.com/Haixiang6123/my-promise-poller 6 | > 7 | > 预览链接:[http://yanhaixiang.com/my-promise-poller/](http://yanhaixiang.com/my-promise-poller/) 8 | > 9 | > 参考轮子:https://www.npmjs.com/package/promise-poller 10 | 11 | 轮询,一个前端非常常见的操作,然而对于很多人来说第一反应竟然还是用 `setInterval` 来实现, `setInterval` 作为轮询是不稳定的。下面就带大家一起写一个 promise-poller 的轮子吧。 12 | 13 | ## 从零开始 14 | 15 | 先从上面说的 `setInterval` 的方法开始写起,一个最 Low 的轮询如下: 16 | 17 | ```ts 18 | const promisePoller = (taskFn: Function, interval: number) => { 19 | setInterval(() => { 20 | taskFn(); 21 | }, interval) 22 | } 23 | ``` 24 | 25 | 第一个参数为轮询任务,第二参数为时间间隔,so easy。 26 | 27 | 刚刚也说了,`setInterval` 是不稳定的,详见:[为什么setTimeout()比setInterval()稳定](https://blog.csdn.net/chiuwingyan/article/details/80322289)。用 `setTimeout` 迭代调用来做轮询会更稳定,面试题常见操作,so easy。 28 | 29 | ```ts 30 | interface Options { 31 | taskFn: Function 32 | interval: number 33 | } 34 | 35 | const promisePoller = (options: Options) => { 36 | const {taskFn, interval} = options 37 | 38 | const poll = () => { 39 | setTimeout(() => { 40 | taskFn() 41 | poll() 42 | }, interval) 43 | } 44 | 45 | poll() 46 | } 47 | ``` 48 | 49 | 上面还把入参封成 `Options` 类型,更容易扩展入参类型。 50 | 51 | 这样的代码我们还是不满意,受不了 `setTimeout` 里又一个回调,太丑了。因此,可以把 `setTimeout` 封装成一个 `delay` 函数,delay 完成再去调用 `poll` 就好了。 52 | 53 | ```ts 54 | export const delay = (interval: number) => new Promise(resolve => { 55 | setTimeout(resolve, interval) 56 | }) 57 | 58 | const promisePoller = (options: Options) => { 59 | const {taskFn, interval} = options 60 | 61 | const poll = () => { 62 | taskFn() 63 | 64 | delay(interval).then(poll) // 使用 delay 替换 setTimeout 的回调 65 | } 66 | 67 | poll() 68 | } 69 | ``` 70 | 71 | 是不是变干净多了? 72 | 73 | ## promisify 74 | 75 | 即然这个轮子的名字都带有 "promise",那 `promisePoller` 函数肯定要返回一个 Promise 呀。这一步就要把这个函数 promisify。 76 | 77 | 首先返回一个 Promise。 78 | 79 | ```ts 80 | const promisePoller = (options: Options) => { 81 | const {taskFn, interval, masterTimeout} = options 82 | 83 | return new Promise((resolve, reject) => { // 返回一个 Promise 84 | const poll = () => { 85 | const result = taskFn() 86 | 87 | delay(interval).then(poll) 88 | } 89 | 90 | poll() 91 | }) 92 | } 93 | ``` 94 | 95 | 那问题来了:什么时候该 reject ?什么时候该 resolve 呢?自然是整个轮询失败就 reject,整个轮询成功就 resolve 呗。 96 | 97 | 先看 reject 时机:整个轮询失败一般是 timeout 了就凉了呗,所以这里加个 `masterTimeout` 到 `Options` 中,表示整体轮询的超时时间,再为整个轮询过程加个 `setTimeout` 计时器。 98 | 99 | ```ts 100 | interface Options { 101 | taskFn: Function 102 | interval: number 103 | masterTimeout?: number // 整个轮询过程的 timeout 时长 104 | } 105 | 106 | const promisePoller = (options: Options) => { 107 | const {taskFn, interval, masterTimeout} = options 108 | 109 | let timeoutId 110 | 111 | return new Promise((resolve, reject) => { 112 | if (masterTimeout) { 113 | timeoutId = setTimeout(() => { 114 | reject('Master timeout') // 整个轮询超时了 115 | }, masterTimeout) 116 | } 117 | 118 | const poll = () => { 119 | taskFn() 120 | 121 | delay(interval).then(poll) 122 | } 123 | 124 | poll() 125 | }) 126 | } 127 | ``` 128 | 129 | 再看 resolve 时机:执行到最后一次轮询任务就说明整个轮询成功了嘛,那怎么才知道这是后一次的轮询任务呢?呃,我们并不能知道,只能通过调用方告诉我们才知道,所以加个 `shouldContinue` 的回调让调用方告诉我们当前是否应该继续轮询,如果不继续就是最后一次了嘛。 130 | 131 | ```ts 132 | interface Options { 133 | taskFn: Function 134 | interval: number 135 | shouldContinue: (err: string | null, result: any) => boolean // 当次轮询后是否需要继续 136 | masterTimeout?: number 137 | } 138 | 139 | const promisePoller = (options: Options) => { 140 | const {taskFn, interval, masterTimeout, shouldContinue} = options 141 | 142 | let timeoutId: null | number 143 | 144 | return new Promise((resolve, reject) => { 145 | if (masterTimeout) { 146 | timeoutId = window.setTimeout(() => { 147 | reject('Master timeout') // 整个轮询过程超时了 148 | }, masterTimeout) 149 | } 150 | 151 | const poll = () => { 152 | const result = taskFn() 153 | 154 | if (shouldContinue(null, result)) { 155 | delay(interval).then(poll) // 继续轮询 156 | } else { 157 | if (timeoutId !== null) { // 不需要轮询,有 timeoutId 则清除 158 | clearTimeout(timeoutId) 159 | } 160 | 161 | resolve(result) // 最后一个轮询任务了,结束并返回最后一次 taskFn 的结果 162 | } 163 | } 164 | 165 | poll() 166 | }) 167 | } 168 | ``` 169 | 170 | 至此,一个 Promisify 后的 poller 函数已经大体完成了。还有没有得优化呢?有! 171 | 172 | ## 轮询任务的 timeout 173 | 174 | 刚刚提到 `masterTimeout`,相对地,也应该有轮询单个任务的 `timeout`,所以,在 `Options` 里加个 `taskTimeout` 字段吧。 175 | 176 | 不对,等等!上面好像我们默认 `taskFn` 是同步的函数呀,timeout 一般针对异步函数设计的,这也提示了我们 `taskFn` 应该也要支持异步函数才行。所以,在调用 `taskFn` 的时候,要将其结果 promisify,然后对这个 promise 进行 timeout 的检测。 177 | 178 | ```ts 179 | interface Options { 180 | taskFn: Function 181 | interval: number 182 | shouldContinue: (err: string | null, result: any) => boolean 183 | masterTimeout?: number 184 | taskTimeout?: number // 轮询任务的 timeout 185 | } 186 | 187 | // 判断该 promise 是否超时了 188 | const timeout = (promise: Promise, interval: number) => { 189 | return new Promise((resolve, reject) => { 190 | const timeoutId = setTimeout(() => reject('Task timeout'), interval) 191 | 192 | promise.then(result => { 193 | clearTimeout(timeoutId) 194 | resolve(result) 195 | }) 196 | }) 197 | } 198 | 199 | const promisePoller = (options: Options) => { 200 | const {taskFn, interval, masterTimeout, taskTimeout, shouldContinue} = options 201 | 202 | let timeoutId: null | number 203 | 204 | return new Promise((resolve, reject) => { 205 | if (masterTimeout) { 206 | timeoutId = window.setTimeout(() => { 207 | reject('Master timeout') 208 | }, masterTimeout) 209 | } 210 | 211 | const poll = () => { 212 | let taskPromise = Promise.resolve(taskFn()) // 将结果 promisify 213 | 214 | if (taskTimeout) { 215 | taskPromise = timeout(taskPromise, taskTimeout) // 检查该轮询任务是否超时了 216 | } 217 | 218 | taskPromise 219 | .then(result => { 220 | if (shouldContinue(null, result)) { 221 | delay(interval).then(poll) 222 | } else { 223 | if (timeoutId !== null) { 224 | clearTimeout(timeoutId) 225 | } 226 | resolve(result) 227 | } 228 | }) 229 | .catch(error => { 230 | 231 | }) 232 | } 233 | 234 | poll() 235 | }) 236 | } 237 | ``` 238 | 239 | 上面一共完成了三步: 240 | 1. 将 `taskFn` 的结果 promisify 241 | 2. 添加 `timeout` 函数用于判断 `taskFn` 是否超时(对于同步函数其实一般来说不会 timeout,因为结果是马上返回的) 242 | 3. 判断 `taskFn` 是否超时,超时了直接 reject,会走到 `taskPromise` 的 catch 里 243 | 244 | 那如果真的超时了,`timeout` reject 了之后干啥呢?当然是告诉主流程的轮询说:哎,这个任务超时了,我要不要重试一下啊。因此,这里又要引入一个重试的功能了。 245 | 246 | ## 重试 247 | 248 | 首先,在 `Options` 加个 `retries` 的字段表示可重试的次数。 249 | 250 | ```ts 251 | interface Options { 252 | taskFn: Function 253 | interval: number 254 | shouldContinue: (err: string | null, result?: any) => boolean 255 | masterTimeout?: number 256 | taskTimeout?: number 257 | retries?: number // 轮询任务失败后重试次数 258 | } 259 | ``` 260 | 261 | 接着在 catch 里,判断 `retries` 是否为 0(重试次数还没用完) 和 `shouldContinue` 的值是否为 `true`(我真的要重试啊),以此来确定是否真的需要重试。只有两者都为 `true` 时才重试。 262 | 263 | ```ts 264 | const promisePoller = (options: Options) => { 265 | ... 266 | let rejections: Array = [] 267 | let retriesRemain = retries 268 | 269 | return new Promise((resolve, reject) => { 270 | ... 271 | const poll = () => { 272 | ... 273 | 274 | taskPromise 275 | .then(result => { 276 | ... 277 | }) 278 | .catch(error => { 279 | rejections.push(error) // 加入 rejections 错误列表 280 | 281 | if (--retriesRemain === 0 || !shouldContinue(error)) { // 判断是否需要重试 282 | reject(rejections) // 不重试,直接失败 283 | } else { 284 | delay(interval).then(poll); // 重试 285 | } 286 | }) 287 | } 288 | 289 | poll() 290 | }) 291 | } 292 | ``` 293 | 294 | 上面还添加 `rejections` 变量用于存放多个 error 信息。这样的设计是因为有可能 10 个任务里 2 个失败了,那最后就要把 2 个失败的信息都返回,因此需要一个数组存放错误信息。 295 | 296 | 这里还一个优化点:我们常常看到别人页面获取数据失败都会显示 1/3 获取... 2/3 获取... 3/3 获取... 直到真的获取失败,相当于有个进度条,这样对用户也比较好。所以,在 `Options` 可以提供一个回调,每次 `retriesRemain` 要减少时调用一下就好了。 297 | 298 | ```ts 299 | interface Options { 300 | taskFn: Function 301 | interval: number 302 | shouldContinue: (err: string | null, result?: any) => boolean 303 | progressCallback?: (retriesRemain: number, error: Error) => unknown // 剩余次数回调 304 | masterTimeout?: number 305 | taskTimeout?: number 306 | retries?: number // 轮询任务失败后重试次数 307 | } 308 | 309 | const promisePoller = (options: Options) => { 310 | ... 311 | let rejections: Array = [] 312 | let retriesRemain = retries 313 | 314 | return new Promise((resolve, reject) => { 315 | ... 316 | const poll = () => { 317 | ... 318 | 319 | taskPromise 320 | .then(result => { 321 | ... 322 | }) 323 | .catch(error => { 324 | rejections.push(error) // 加入 rejections 错误列表 325 | 326 | if (progressCallback) { 327 | progressCallback(retriesRemain, error) // 回调获取 retriesRemain 328 | } 329 | 330 | if (--retriesRemain === 0 || !shouldContinue(error)) { // 判断是否需要重试 331 | reject(rejections) // 不重试,直接失败 332 | } else { 333 | delay(interval).then(poll); // 重试 334 | } 335 | }) 336 | } 337 | 338 | poll() 339 | }) 340 | } 341 | ``` 342 | 343 | ## 主动停止轮询 344 | 345 | 虽然 `shouldContinue` 已经可以有效地控制流程是否要中止,但是每次都要等下一次轮询开始之后才会判断,这样未免有点被动。如果可以在 `taskFn` 执行的时候就主动停止,那 `promisePoller` 就更灵活了。 346 | 347 | 而 `taskFn` 有可能是同步函数或者异步函数,对于同步函数,我们规定 `return false` 就停止轮询,对于异步函数,规定 `reject("CANCEL_TOKEN")` 就停止轮询。函数改写如下: 348 | 349 | ```ts 350 | const CANCEL_TOKEN = 'CANCEL_TOKEN' 351 | 352 | const promisePoller = (options: Options) => { 353 | const {taskFn, masterTimeout, taskTimeout, progressCallback, shouldContinue, retries = 5} = mergedOptions 354 | 355 | let polling = true 356 | let timeoutId: null | number 357 | let rejections: Array = [] 358 | let retriesRemain = retries 359 | 360 | return new Promise((resolve, reject) => { 361 | if (masterTimeout) { 362 | timeoutId = window.setTimeout(() => { 363 | reject('Master timeout') 364 | polling = false 365 | }, masterTimeout) 366 | } 367 | 368 | const poll = () => { 369 | let taskResult = taskFn() 370 | 371 | if (taskResult === false) { // 结束同步任务 372 | taskResult = Promise.reject(taskResult) 373 | reject(rejections) 374 | polling = false 375 | } 376 | 377 | let taskPromise = Promise.resolve(taskResult) 378 | 379 | if (taskTimeout) { 380 | taskPromise = timeout(taskPromise, taskTimeout) 381 | } 382 | 383 | taskPromise 384 | .then(result => { 385 | ... 386 | }) 387 | .catch(error => { 388 | if (error === CANCEL_TOKEN) { // 结束异步任务 389 | reject(rejections) 390 | polling = false 391 | } 392 | 393 | rejections.push(error) 394 | 395 | if (progressCallback) { 396 | progressCallback(retriesRemain, error) 397 | } 398 | 399 | if (--retriesRemain === 0 || !shouldContinue(error)) { 400 | reject(rejections) 401 | } else if (polling) { // 再次重试时,需要检查 polling 是否为 true 402 | delay(interval).then(poll); 403 | } 404 | }) 405 | } 406 | 407 | poll() 408 | }) 409 | } 410 | ``` 411 | 412 | 上面代码判断了 `taskFn` 的返回值是否为 `false`,在 catch 里判断 error 是否为 `CANCEL_TOKEN`。如果 `taskFn` 主动要求中止轮询,那么设置 `polling` 为 `false` 并 reject 整个流程。 413 | 414 | 这里还有个细节是:为了提高安全性,在重试的那里要再检查一次 `polling` 是否为 `true` 才重新 `poll`。 415 | 416 | ## 轮询策略 417 | 418 | 目前我们设计的都是线性轮询的,一个 `interval` 搞定。为了提高扩展性,我们再提供另外 2 种轮询策略:linear-backoff 和 exponential-backoff,分别对 interval 的线性递增和指数递增,而非匀速不变。 419 | 420 | 先定好策略的一些默认参数: 421 | 422 | ```ts 423 | export const strategies = { 424 | 'fixed-interval': { 425 | defaults: { 426 | interval: 1000 427 | }, 428 | getNextInterval: function(count: number, options: Options) { 429 | return options.interval; 430 | } 431 | }, 432 | 433 | 'linear-backoff': { 434 | defaults: { 435 | start: 1000, 436 | increment: 1000 437 | }, 438 | getNextInterval: function(count: number, options: Options) { 439 | return options.start + options.increment * count; 440 | } 441 | }, 442 | 443 | 'exponential-backoff': { 444 | defaults: { 445 | min: 1000, 446 | max: 30000 447 | }, 448 | getNextInterval: function(count: number, options: Options) { 449 | return Math.min(options.max, Math.round(Math.random() * (Math.pow(2, count) * 1000 - options.min) + options.min)); 450 | } 451 | } 452 | }; 453 | ``` 454 | 455 | 每种策略都有自己的参数和 `getNextInterval` 的方法,前者为起始参数,后者在轮询的时候实时获取下一次轮询的时间间隔。因为有了起始参数,`Options` 的参数也要改动一下。 456 | 457 | ```ts 458 | type StrategyName = 'fixed-interval' | 'linear-backoff' | 'exponential-backoff' 459 | 460 | interface Options { 461 | taskFn: Function 462 | shouldContinue: (err: Error | null, result?: any) => boolean // 当次轮询后是否需要继续 463 | progressCallback?: (retriesRemain: number, error: Error) => unknown // 剩余次数回调 464 | strategy?: StrategyName // 轮询策略 465 | masterTimeout?: number 466 | taskTimeout?: number 467 | retries?: number 468 | // fixed-interval 策略 469 | interval?: number 470 | // linear-backoff 策略 471 | start?: number 472 | increment?: number 473 | // exponential-backoff 策略 474 | min?: number 475 | max?: number 476 | } 477 | ``` 478 | 479 | 在 `poll` 函数里就简单了,只需要在 `delay` 之前获取一下 nextInterval,然后 `delay(nextInterval)` 即可。 480 | 481 | ```ts 482 | const promisePoller = (options: Options) => { 483 | const strategy = strategies[options.strategy] || strategies['fixed-interval'] // 获取当前的轮询策略,默认使用 fixed-interval 484 | 485 | const mergedOptions = {...strategy.defaults, ...options} // 合并轮询策略的初始参数 486 | 487 | const {taskFn, masterTimeout, taskTimeout, progressCallback, shouldContinue, retries = 5} = mergedOptions 488 | 489 | let polling = true 490 | let timeoutId: null | number 491 | let rejections: Array = [] 492 | let retriesRemain = retries 493 | 494 | return new Promise((resolve, reject) => { 495 | if (masterTimeout) { 496 | timeoutId = window.setTimeout(() => { 497 | reject(new Error('Master timeout')) 498 | polling = false 499 | }, masterTimeout) 500 | } 501 | 502 | const poll = () => { 503 | let taskResult = taskFn() 504 | 505 | if (taskResult === false) { 506 | taskResult = Promise.reject(taskResult) 507 | reject(rejections) 508 | polling = false 509 | } 510 | 511 | let taskPromise = Promise.resolve(taskResult) 512 | 513 | if (taskTimeout) { 514 | taskPromise = timeout(taskPromise, taskTimeout) 515 | } 516 | 517 | taskPromise 518 | .then(result => { 519 | if (shouldContinue(null, result)) { 520 | const nextInterval = strategy.getNextInterval(retriesRemain, mergedOptions) // 获取下次轮询的时间间隔 521 | delay(nextInterval).then(poll) 522 | } else { 523 | if (timeoutId !== null) { 524 | clearTimeout(timeoutId) 525 | } 526 | resolve(result) 527 | } 528 | }) 529 | .catch((error: Error) => { 530 | if (error.message === CANCEL_TOKEN) { 531 | reject(rejections) 532 | polling = false 533 | } 534 | 535 | rejections.push(error) 536 | 537 | if (progressCallback) { 538 | progressCallback(retriesRemain, error) 539 | } 540 | 541 | if (--retriesRemain === 0 || !shouldContinue(error)) { 542 | reject(rejections) 543 | } else if (polling) { 544 | const nextInterval = strategy.getNextInterval(retriesRemain, options) // 获取下次轮询的时间间隔 545 | delay(nextInterval).then(poll); 546 | } 547 | }) 548 | } 549 | 550 | poll() 551 | }) 552 | } 553 | ``` 554 | 555 | ## 总结 556 | 557 | 这个 `promisePoller` 主要完成了: 558 | 559 | 1. 基础的轮询操作 560 | 2. 返回 promise 561 | 3. 提供主动和被动中止轮询的方法 562 | 4. 提供轮询任务重试的功能,并提供重试进度回调 563 | 5. 提供多种轮询策略:fixed-interval, linear-backoff, exponential-backoff 564 | 565 | 以上就是 npm 包 [promise-poller](https://www.npmjs.com/package/promise-poller) 的源码实现了。 566 | -------------------------------------------------------------------------------- /deploy.js: -------------------------------------------------------------------------------- 1 | const ghpages = require('gh-pages'); 2 | 3 | ghpages.publish('dist', function(err) { 4 | if (err) { 5 | console.error('Deploy failed! \n' + err) 6 | } 7 | 8 | console.log('Deployed successfully!') 9 | }); 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-static-webapp-template", 3 | "version": "1.0.0", 4 | "description": "", 5 | "private": true, 6 | "scripts": { 7 | "start": "webpack serve --open --config webpack.dev.js", 8 | "build": "webpack --config webpack.prod.js", 9 | "deploy": "npm run build && node deploy.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "devDependencies": { 13 | "compression-webpack-plugin": "^7.1.2", 14 | "css-loader": "^5.1.3", 15 | "gh-pages": "^3.1.0", 16 | "html-webpack-plugin": "^5.3.1", 17 | "style-loader": "^2.0.0", 18 | "terser-webpack-plugin": "^5.1.1", 19 | "ts-loader": "^8.0.18", 20 | "typescript": "^4.2.3", 21 | "webpack": "^5.27.1", 22 | "webpack-cli": "^4.5.0", 23 | "webpack-dev-server": "^3.11.2", 24 | "webpack-merge": "^5.7.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | my-promise-poller 6 | 7 | 8 |

my-promise-poller

9 | 10 |
11 | 12 | 13 |
14 | 15 |
16 |

17 | fixed-interval counter: 18 | 0 19 |

20 |

21 | linear-backoff counter: 22 | 0 23 |

24 |

25 | exponential-backoff counter: 26 | 0 27 |

28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import promisePoller from './lib/index' 2 | import {CANCEL_TOKEN} from "./lib/utils" 3 | 4 | const $start = document.querySelector('#start') 5 | const $asyncStop = document.querySelector('#async-stop') 6 | 7 | const $fixedCounter = document.querySelector('#fixed-counter') 8 | const $linearCounter = document.querySelector('#linear-counter') 9 | const $exponentialCounter = document.querySelector('#exponential-counter') 10 | 11 | let fixedCounter = 0 12 | let linearCounter = 0 13 | let exponentialCounter = 0 14 | 15 | let stop = false 16 | 17 | const limit = 99999 18 | 19 | $start.onclick = async () => { 20 | promisePoller({ 21 | strategy: 'fixed-interval', 22 | interval: 100, 23 | taskFn: async () => { 24 | if (stop) { 25 | throw new Error(CANCEL_TOKEN) 26 | } 27 | 28 | fixedCounter += 1 29 | $fixedCounter.innerText = fixedCounter.toString() 30 | }, 31 | shouldContinue: () => fixedCounter < limit, 32 | }) 33 | promisePoller({ 34 | strategy: 'linear-backoff', 35 | start: 100, 36 | increment: 100, 37 | taskFn : async () => { 38 | if (stop) { 39 | throw new Error(CANCEL_TOKEN) 40 | } 41 | 42 | linearCounter += 1 43 | $linearCounter.innerText = linearCounter.toString() 44 | }, 45 | shouldContinue: () => linearCounter < limit 46 | }) 47 | promisePoller({ 48 | strategy: 'exponential-backoff', 49 | min: 100, 50 | max: 3000, 51 | taskFn : async () => { 52 | if (stop) { 53 | throw new Error(CANCEL_TOKEN) 54 | } 55 | 56 | exponentialCounter += 1 57 | $exponentialCounter.innerText = exponentialCounter.toString() 58 | }, 59 | shouldContinue: () => linearCounter < limit 60 | }) 61 | } 62 | 63 | $asyncStop.onclick = () => stop = true 64 | -------------------------------------------------------------------------------- /src/lib/contants.ts: -------------------------------------------------------------------------------- 1 | type StrategyName = 'fixed-interval' | 'linear-backoff' | 'exponential-backoff' 2 | 3 | export interface Options { 4 | taskFn: Function // 轮询任务 5 | strategy?: StrategyName // 轮询策略 6 | masterTimeout?: number // 整个轮询过程的 timeout 7 | shouldContinue: (err: Error | null, result?: any) => boolean // 当次轮询后是否需要继续 8 | taskTimeout?: number // 轮询任务的 timeout 9 | progressCallback?: (retriesRemain: number, error: Error) => unknown // 剩余次数回调 10 | retries?: number //轮询任务失败后重试次数 11 | // fixed-interval 策略 12 | interval?: number // 轮询周期 13 | // linear-backoff 策略 14 | start?: number 15 | increment?: number 16 | // exponential-backoff 策略 17 | min?: number 18 | max?: number 19 | } 20 | 21 | export const strategies = { 22 | 'fixed-interval': { 23 | defaults: { 24 | interval: 1000 25 | }, 26 | getNextInterval: function(count: number, options: Options) { 27 | return options.interval; 28 | } 29 | }, 30 | 31 | 'linear-backoff': { 32 | defaults: { 33 | start: 1000, 34 | increment: 1000 35 | }, 36 | getNextInterval: function(count: number, options: Options) { 37 | return options.start + options.increment * count; 38 | } 39 | }, 40 | 41 | 'exponential-backoff': { 42 | defaults: { 43 | min: 1000, 44 | max: 30000 45 | }, 46 | getNextInterval: function(count: number, options: Options) { 47 | return Math.min(options.max, Math.round(Math.random() * (Math.pow(2, count) * 1000 - options.min) + options.min)); 48 | } 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import {CANCEL_TOKEN, delay, timeout} from './utils' 2 | import {Options, strategies} from './contants' 3 | 4 | const promisePoller = (options: Options) => { 5 | const strategy = strategies[options.strategy] || strategies['fixed-interval'] 6 | 7 | const mergedOptions = {...strategy.defaults, ...options} 8 | 9 | const {taskFn, masterTimeout, taskTimeout, progressCallback, shouldContinue, retries = 5} = mergedOptions 10 | 11 | let polling = true 12 | let timeoutId: null | number 13 | let rejections: Array = [] 14 | let retriesRemain = retries 15 | 16 | return new Promise((resolve, reject) => { 17 | // 整个轮询过程超时 18 | if (masterTimeout) { 19 | timeoutId = window.setTimeout(() => { 20 | reject(new Error('Master timeout')) 21 | polling = false 22 | }, masterTimeout) 23 | } 24 | 25 | // 轮询函数 26 | const poll = () => { 27 | let taskResult = taskFn() 28 | 29 | // 同步结束任务 30 | if (taskResult === false) { 31 | taskResult = Promise.reject(taskResult) 32 | reject(rejections) 33 | polling = false 34 | } 35 | 36 | let taskPromise = Promise.resolve(taskResult) 37 | 38 | if (taskTimeout) { 39 | taskPromise = timeout(taskPromise, taskTimeout) 40 | } 41 | 42 | taskPromise 43 | .then(result => { 44 | if (shouldContinue(null, result)) { 45 | const nextInterval = strategy.getNextInterval(retriesRemain, mergedOptions) 46 | // 继续轮询 47 | delay(nextInterval).then(poll) 48 | } else { 49 | // 不需要轮询,有 timeoutId 则清除 50 | if (timeoutId !== null) { 51 | clearTimeout(timeoutId) 52 | } 53 | // 结束并返回最后一次 taskFn 的结果 54 | resolve(result) 55 | } 56 | }) 57 | .catch((error: Error) => { 58 | // 异步结束任务 59 | if (error.message === CANCEL_TOKEN) { 60 | reject(rejections) 61 | polling = false 62 | } 63 | 64 | rejections.push(error) 65 | 66 | // 回调获取 retriesRemain 67 | if (progressCallback) { 68 | progressCallback(retriesRemain, error) 69 | } 70 | 71 | if (--retriesRemain === 0 || !shouldContinue(error)) { 72 | // 不需要轮询 73 | reject(rejections) 74 | } else if (polling) { 75 | const nextInterval = strategy.getNextInterval(retriesRemain, options) 76 | // 重试轮询 77 | delay(nextInterval).then(poll); 78 | } 79 | }) 80 | } 81 | 82 | // 第一次轮询 83 | poll() 84 | }) 85 | } 86 | 87 | export default promisePoller 88 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export const CANCEL_TOKEN = 'CANCEL_TOKEN' 2 | 3 | export const timeout = (promise: Promise, interval: number) => { 4 | return new Promise((resolve, reject) => { 5 | const timeoutId = setTimeout(() => reject(new Error('Task timeout')), interval) 6 | 7 | promise.then(result => { 8 | clearTimeout(timeoutId) 9 | resolve(result) 10 | }) 11 | }) 12 | } 13 | 14 | export const delay = (interval: number) => new Promise(resolve => { 15 | setTimeout(resolve, interval) 16 | }) 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "es6", 7 | "target": "es5", 8 | "jsx": "react", 9 | "allowJs": true 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | target: 'web', 6 | entry: { 7 | index: './src/index.ts', 8 | }, 9 | output: { 10 | filename: '[name].[contenthash].js', 11 | path: path.resolve(__dirname, 'dist'), 12 | clean: true 13 | }, 14 | devtool: "source-map", 15 | devServer: { 16 | contentBase: '../dist', 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.tsx?$/, 22 | use: 'ts-loader', 23 | exclude: /node_modules/, 24 | }, 25 | { 26 | test: /\.css$/i, 27 | use: ['style-loader', 'css-loader'], 28 | }, 29 | { 30 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 31 | type: 'asset/resource', 32 | }, 33 | { 34 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 35 | type: 'asset/resource', 36 | }, 37 | ], 38 | }, 39 | resolve: { 40 | extensions: ['.tsx', '.ts', '.js'], 41 | }, 42 | plugins: [ 43 | new HtmlWebpackPlugin({ 44 | template: 'public/index.html' 45 | }), 46 | ], 47 | optimization: { 48 | splitChunks: { 49 | chunks: "all", 50 | }, 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require("webpack-merge"); 2 | const common = require("./webpack.common"); 3 | 4 | module.exports = merge(common, { 5 | mode: "development", 6 | devtool: "inline-source-map", 7 | devServer: { 8 | contentBase: "./dist", 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require("webpack-merge"); 2 | const CompressionWebpackPlugin = require("compression-webpack-plugin"); 3 | const TerserPlugin = require("terser-webpack-plugin"); 4 | const common = require("./webpack.common"); 5 | 6 | module.exports = merge(common, { 7 | mode: "production", 8 | optimization: { 9 | minimize: true, 10 | minimizer: [new TerserPlugin()], 11 | }, 12 | plugins: [new CompressionWebpackPlugin()], 13 | }); 14 | --------------------------------------------------------------------------------