前端轮询的实现和优化策略

原文标题:一个健壮的前端轮询

原文作者:阿里云开发者

冷月清谈:

文章详细讨论了在不采用websocket进行服务端推送的场景下,如何实现一个健壮的前端轮询系统。主要内容包括:前端轮询的常见应用场景,如实时数据获取、进度监控和后端处理状态监测等;两种主要的实现方式—使用setInterval和setTimeout,并分析了各自的优缺点;面临的常见问题,如请求频率控制、轮询的唯一性保证和参数变更问题;以及探讨了如何解决这些问题,包括强制使用新参数的请求以及避免旧数据覆盖新数据。最后,文章还介绍了一个在React函数组件中使用setTimeout实现前端轮询的示例,提供了一个实际的编码实现参考。

怜星夜思:

1、在前端轮询中,setInterval与setTimeout的选择有什么技术考量?
2、如何处理前端轮询中的网络延迟和请求覆盖问题?
3、前端轮询的效率和性能如何优化?

原文内容

阿里妹导读


本文讨论了在不使用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 />, mountNode);



六、结语

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

能用AI写的代码,不允许程序员手写?!你怎么看?


以Copilot、通义灵码等为代表的AI智能编码助手成为越来越多开发者的必备工具,补全/续写代码、写单元测试、debug的功能不在话下,本期我们来聊聊你在使用AI编码助手过程中的感受和评价:


1.你认为 AI 编码助手真的能提效吗?
2.个别公司要求能用AI写代码,不允许程序员手写,如果要手写,必须注释说明AI写不了这段代码的原因,你怎么看?

3.你最常用和喜欢通义灵码编码助手哪些功能?分享一些你在使用过程中发现的小技巧。

👇欢迎点击”阅读原文“发表你的看法

使用setInterval时,由于它是按照指定的时间间隔不断执行,可能会在某些请求还未响应时就发起新的请求,导致请求堆积。而setTimeout在每次请求完成后,才设置下一个请求,这样可以避免未响应的请求堆积的问题,是一个比较安全的选择。

主要是看业务需求和实际场景。如果对实时性要求很高,不太在意多出的几个请求,那就用setInterval。如果你的服务器不那么能抗压,或者说要精确控制请求的间隔,setTimeout会更合适,因为它可以保证前一个请求结束后才开始下一个。

效率和性能的优化可以从减少不必要的轮询和合理设置轮询间隔做起。例如,只在页面活跃时进行轮询,页面隐藏或最小化时停止轮询。此外,根据服务器的实时负载动态调整轮询频率,可以进一步提高性能。

网络延迟这玩意儿,挺让人头疼的。我之前做项目时,用的是取消上一次未完成的请求的策略,比如用axios的cancel token。至于请求覆盖,你得保证你的状态更新逻辑得当,不要让旧的响应覆盖了新的状态。

为了处理这类问题,采用合适的前端状态管理是关键。例如,可以利用Redux或者MobX来控制数据流,通过action或者effect来维护请求状态,避免因网络延迟导致的状态不一致问题。同时,在设计API时,返回的数据应包含时间戳或其他标识符,让前端能够判断数据的时效性。

哈哈,想象一下,setInterval就像是每隔几秒就自动扔一个球出去,不管前面扔的球落地了没落地。setTimeout更像是扔了一个球之后,要等这个球落地了,你才能扔下一个。技术上讲,setTimeout让你的系统压力可能会小一些。

从代码层面优化,可以使用Web Workers来处理轮询,这样可以避免阻塞主线程导致的界面卡顿。另外,确保在不需要轮询时能够正确地清除定时器,避免内存泄漏。同时,应用层面的策略,如合理缓存处理也非常重要,可以减少对后端服务的请求压力。

可以设置一个标志位来监测请求是否完成。在发出新的请求前,检查上一个请求是否已经结束,如果还没有结束就跳过这一次的请求,或者取消前一个还未完成的请求。对于请求覆盖问题,确保每次请求都携带一个唯一的标识符,并在响应时检查标识符,排除掉过时的数据。

其实我觉得,前端轮询有时候挺烧服务器的,特别是用户特别多的时候。一个小小的优化建议,根据用户的交互行为来调整轮询频率,比如用户很久没操作了,可以适当地降低轮询频率。