一个健壮的前端轮询

阿里妹导读

本文讨论了在不使用websocket做服务端推送的情况下,如何写出一个健壮的前端轮询。文章提供了一些常见的前端轮询的应用场景以及可能遇到的问题,欢迎大家一起讨论。

一、前言

本文的前端轮询主要讨论的是定时异步任务,定时异步任务相比与定时同步任务需要考虑更多的因素。这里的异步任务一般包括发送网络请求及响应后的状态更新。从技术层面上,需要考虑到开启定时、发送请求、状态更新之间的逻辑顺序。此外,本文不讨论利用websocket做服务端推送,只考虑在仅前端变更的情况下做轮询(在某些时候,确实只能如此)。

二、应用场景

1.获取实时数据,例如数据大屏、实时股价。

2.监测进度,例如数据上传进度、下载进度。
3.监测后端处理状态,例如提交一批数据后,后端需要对数据进行分析,耗时不确定,前端需要获取分析结果,则此时需要前端轮询。
4.检测静态资源是否加载完成(一般来讲是定时同步任务),例如当函数a逻辑需要在静态资源A加载完成后才能执行,则需要在执行函数a之前,开启轮询来判断资源A是否加载完成。

三、实现方式

3.1. 使用setInterval

如果是定时同步任务没有问题,但对于轮询这样的定时异步任务需要注意响应时间和定时时间。如图3.1和3.2所示,当响应时间大于实时时间时,会存在多个未响应的请求,同时受到网络状况的影响,网络请求的响应顺序可能和请求顺序不一致,从而产生一些预期之外的情况。


const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)) 
async function timer(params) { 
  let {start,name} = params; 
  var now = new Date(); 
  var det = now - start; 
  await sleep(2000); // 模拟请求响应 
  now.setTime(det); 
  now.setHours(0); 
  document.getElementById("id_name").innerHTML = `${name} : ${now.toLocaleTimeString()}`; 
} 
// 组件加载时开始轮询 
addEventListener("load", (event) => { 
  timeout = setInterval(()=>timer({start,name}), 1000); 
}); 

3.2. 使用setTimeout

使用setTimeout可以保证轮询请求的唯一性,其代码如下。但考虑到代码健壮性以及更多具体的业务问题,需要进一步处理。


let timeout; 
const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)) 
async function timer(params) { 
  clearTimeout(timeout); 
  var now = new Date(); 
  var det = now - params.start; 
  await sleep(2000); // 模拟请求响应 
  now.setTime(det); 
  now.setHours(0); 
  document.getElementById("id_name").innerHTML=`${params.name} : ${now.toLocaleTimeString()}`; 
  timeout = setTimeout(()=>{timer(params)},1000); 
} 
addEventListener("load", (event) => {timer({start,name})}); 

四、可能会遇到的问题

1.同时有好几条轮询请求,或者发现数据刷新频率比理论值高

2.组件卸载或停止轮询后,仍然有轮询请求

3.更改了轮询请求的参数,但被旧参数的数据给覆盖了

如果你有遇到其他问题,欢迎一起交流探讨。

从业务层面上,需要注意的问题:

1.开始轮询的途径有哪些?

常见的途径有页面组件加载后自动开始、按钮强制开始、参数变更后重新开始。在图3.1-3.3中,均只考虑了页面加载后自动开始轮询的情况。

2.如果有多个开启轮询的途径,怎么保证轮询的唯一性?

3.当轮询参数变更时,怎么终止旧的轮询并开始新的轮询?

这也是为了保证轮询的唯一性,同时避免旧数据覆盖新数据。

4.结束轮询的条件是什么?

五、健壮的前端轮询

5.1. setInterval版

如图5.1,对于setInterval的前端轮询实现主要需要考虑以下几个问题:
1.当一次定时执行时,此时可能有未响应的请求,可能需要跳过再次请求避免重复。
2.用户可能在任意时刻变更轮询的请求参数,这时即使有未响应的请求,也需要强制用新参数请求。
3.在2的情况发生后,会同时存在多个请求,当收到旧请求的响应时,需要跳过数据更新以避免旧数据覆盖。
4.在强制触发新的定时时,一定要保证旧的定时已经清除,否则可能出现存在过时请求和卸载后仍然在轮询的问题。

其具体实现可以参考如下代码:


let name = '参数1'; 
let start = new Date(); 
let component; 
let timeout; 
let waitingResponse; // 
let intervalCount; // 
const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)) 
async function timer(params,needWaiting=true) { 
  if(needWaiting && waitingResponse){ 
    return;//上一次请求未响应,跳过请求。特殊情况:强制请求 
  } 
  var now = new Date(); 
  var det = now - params.start;   
  waitingResponse = true; 
  const res = await sleep(2000)//Math.random()*10000%2); // 模拟请求响应,响应时间随机0-2s 
  waitingResponse = false; 
  // 已刷新,数据过时 
  let isRefresh = params.name!=name || params.start!=start;  
  // 满足结束条件 
  let isFinished = res?.isFinished;  
  if(!isRefresh){ 
    now.setTime(det); 
    now.setHours(0); 
    component.innerHTML = `${params.name} : ${now.toLocaleTimeString()}`; 
  } 
  if(isFinished){ 
    clearTimeout(timeout); 
  } 

} 
// 重启 
const restart = () => { 
  start = new Date(); 
  intervalCount=0; 
  clearTimeout(timeout); 
  timeout = setInterval(()=>timer({start,name},intervalCount++!==0),1000); 
} 
//参数变更 
const change = () => { 
  name= "参数"+parseInt(Math.random()*100); 
  start = new Date(); 
  intervalCount=0; 
  clearTimeout(timeout); 
  timeout = setInterval(()=>timer({start,name},intervalCount++!==0),1000); 
} 
//模拟组件卸载 
const unmount = () => { 
  component = null; 
  clearTimeout(timeout); 
} 
//模拟组件挂载 
const mount = () => { 
  component =document.getElementById("id_name"); 
  intervalCount=0; 
  //挂载时自动开始轮询 
  timeout = setInterval(()=>timer({start,name},intervalCount++!==0),1000); 
} 

5.2. setTimeout版



如图5.2,对于setTimeout的前端轮询实现主要需要考虑以下几个问题:
1.用户可能在任意时刻变更轮询的请求参数,这时即使有未响应的请求,也需要强制用新参数请求。
2.当1发生时,需要清除旧的定时,同时避免旧请求的响应继续触发定时(跳过)。
3.当1发生时,可能存在过时的响应,不应该使用过时数据更新状态。

其具体实现可以参考如下代码:


let name = '参数1'; 
let start = new Date(); 
let component; 
let timeout; 
const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)) 
async function timer(params) { 
  clearTimeout(timeout); 
  var now = new Date(); 
  var det = now - params.start;   
  const res = await sleep(2000)// 模拟请求响应 
  // 已刷新,数据过时 
  let isRefresh = params.name!=name || params.start!=start;  
  // 满足结束条件 
  let isFinished = res?.isFinished;  
  if(!isRefresh){ 
    now.setTime(det); 
    now.setHours(0); 
    component.innerHTML = `${params.name} : ${now.toLocaleTimeString()}`; 
  } 
  if(!isRefresh && !isFinished && component){ 
    timeout = setTimeout(()=>{timer(params)},1000); 
  } 
} 
// 重启 
const restart = () => { 
  start = new Date(); 
  timer({start,name});  
} 
//参数变更 
const change = () => { 
  name= "参数"+parseInt(Math.random()*100); 
  start = new Date(); 
  timer({start,name});  
} 
//模拟组件卸载 
const unmount = () => { 
  component = null; 
  clearTimeout(timeout); 
} 
//模拟组件挂载 
const mount = () => { 
  component =document.getElementById("id_name"); 
  timer({start,name});//挂载时自动开始轮询 
} 

5.3. 工具化及使用demo

本小节根据setTimeout版简单实现了一个前端轮询的工具asyncPooling,并提供了一个在React函数组件中的使用demo。(类实现的小工具🔧比之前的函数版更好用,之前的已经去掉了)


import React, { useState, useEffect, useCallback } from "react"; 
import ReactDOM from "react-dom"; 
const mountNode = document.getElementById("root"); 
import { Button } from '@alifd/next'; 

class asyncPooling { 
  /** 
   *  
   * @param {*} interval 轮询的间隔时间 
   * @param {*} func 轮询的请求函数 
   * @param {*} callback 请求响应数据的处理函数 
   * /** callback的参数 
   *  @param params, 原请求参数 
   *  @param res,请求的响应数据 
   *  @param isRefresh, 有新的轮询在运行,响应数据可能已过时 
   *  */ 
   */ 
  constructor(interval,func,callback){ 
    this.interval = interval; 
    this.func = func; 
    this.callback = callback; 
    this.params = {}; 
  } 
  run(params){ 
    this.isFinished = false; 
    this.params = {...params}; //每次run时params设同一个引用,当再次run时可用来判断isRefresh。即可区分不同run,很方便 
    this.runTurn(this.params); 
  } 
  stop(){ 
    this.isFinished = true; 
  } 
  destroy() {  
    clearTimeout(this.timeout); 
  } 
  async runTurn(params){ 
    clearTimeout(this.timeout); 
    const res = await this.func(params); 
    let isRefresh = params!==this.params; 
    this.callback(params,res,isRefresh); 
    if(!isRefresh && !this.isFinished){ 
      this.timeout = setTimeout(()=>this.runTurn(params),this.interval); 
    } 
  } 
  setCallBack(callback){ 
    // 由于函数组件的闭包陷阱,需要重新设置callback以保证在调用该方法时能拿到最新的state 
    this.callback = callback; 
  } 
} 
function Demo(props) { 
  const [name, setName] = useState("参数1"); 
  const [start, setStart] = useState(new Date()); 
  const [data, setData] = useState(); 
  const [polling, setPolling] = useState(); 

  const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)); 

  const updateDate = useCallback((params, res,isRefresh) => { 
    // let isRefresh = params.name != name || params.start != start; 
    let isFinished = res?.isFinished; 
    if(isFinished){ 
      polling.stop(); 
    } 
    if (!isRefresh) { 
      var now = new Date(); 
      var det = now - params.start; 
      now.setTime(det); 
      now.setHours(0); 
      setData(now.toLocaleTimeString()); 
    }  
  },[polling]); 
  // 由于函数组件的闭包陷阱,需要重新设置callback以保证在调用该方法时能拿到最新的state 
  polling && polling.setCallBack(updateDate); 
  useEffect(() => { 
    let p = new asyncPooling(1000,(params) => sleep(2000),updateDate); 
    setPolling(p); 
    p.run({ start, name }); 
    return () => (polling || p).destroy(); 
  }, []) 
  // 重启 
  const restart = () => { 
    let s = new Date(); 
    setStart(s); 
    polling.run({ start: s, name }); 
  } 
  //参数变更 
  const change = () => { 
    let n = "参数" + parseInt(Math.random() * 100); 
    let s = new Date(); 
    setName(n); 
    setStart(s); 
    polling.run({ start: s, name: n }); 
  } 
  return <div><div>Demo</div> 
    <div>{name}:{data}</div> 
    <button onclick="{restart}">重启</button> 
    <button onclick="{change}">参数变更</button> 
  </div> 
} 

ReactDOM.render(<demo></demo>, mountNode); 



六、结语

本文讨论了在不使用websocket做服务端推送的情况下,如何写出一个健壮的前端轮询。本文提供了一些常见的前端轮询的应用场景(第2节)以及可能遇到的问题(第4节),非常欢迎大家加入讨论、提供意见,丰富这些内容。

2