轮询实现的几种方式及性能优化实践

UE丶家淼 前端 阅读 1,627
赞 28 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

最近在搞一个后台管理系统的实时订单监控模块,客户那边想要看到新订单进来的时候页面能立马有反应,不能靠手动刷新。一开始我寻思着上 WebSocket 吧,高大上,实时性也好。但问题是后端同学说他们那边现在没打算搞长连接,服务架构也不支持,而且上线时间紧,没法配合改太多东西。

轮询实现的几种方式及性能优化实践

那就只能退而求其次——轮询。虽然这玩意儿听起来有点老派,像是十年前的方案,但在这种对接受限、工期紧张的情况下,反而是最稳妥的路子。最后决定用短轮询,每隔几秒请求一次接口,看有没有新订单进来。

其实我也考虑过长轮询或者 SSE(Server-Sent Events),但考虑到兼容性和维护成本,加上团队里新人接手的可能性,干脆就用最简单的 setInterval + fetch 实现。毕竟不是所有项目都要追求技术先进,稳定交付才是第一位。

核心代码就这么几行

轮询本身的实现非常简单,真正花时间的是后续的各种边界处理和优化。下面是我最初写的版本:

const POLLING_INTERVAL = 5000; // 5秒轮询一次
let pollingId = null;

function startPolling() {
  const url = 'https://jztheme.com/api/orders/latest';
  
  pollingId = setInterval(async () => {
    try {
      const response = await fetch(url);
      const data = await response.json();
      
      if (response.ok) {
        handleNewOrders(data);
      } else {
        console.warn('轮询失败,状态码:', response.status);
      }
    } catch (error) {
      console.error('网络异常:', error);
    }
  }, POLLING_INTERVAL);
}

function stopPolling() {
  if (pollingId) {
    clearInterval(pollingId);
    pollingId = null;
  }
}

然后在页面加载时调用 startPolling(),离开页面时调用 stopPolling()。看上去没啥问题对吧?我也这么觉得,直到上了测试环境……

最大的坑:性能问题

刚上线测试,前端监控系统就开始报警了——内存占用持续上涨,几个小时后页面卡得几乎点不动。我以为是内存泄漏,排查半天发现并不是 DOM 没销毁或者事件没解绑,而是轮询过程中频繁创建异步请求,加上响应数据量不小,导致 GC 来不及回收。

更严重的是,用户可能开了多个标签页,每个都在疯狂轮询,服务器压力也上来了。运维大哥直接找上门:“你这个接口 QPS 快到 300 了,能不能悠着点?”

这里注意我踩过好几次坑:setInterval 并不会因为上一次请求还没结束就暂停下一次执行。也就是说,如果接口耗时超过 5 秒(比如网络差),下一个请求就会叠上来,形成“请求堆积”。这在弱网环境下特别致命。

改用递归轮询解决堆积问题

后来调整了方案,不再用 setInterval,而是用递归调用 setTimeout,确保前一个请求完成后再发起下一个。改完之后,QPS 直接降了一半,内存也不再持续增长。

let isPolling = false;

async function poll() {
  if (isPolling) return; // 防止并发执行

  isPolling = true;

  try {
    const response = await fetch('https://jztheme.com/api/orders/latest');
    const data = await response.json();

    if (response.ok) {
      handleNewOrders(data);
    }
  } catch (error) {
    console.error('轮询出错:', error);
  } finally {
    isPolling = false;
  }

  // 不管成功失败,都等 5 秒再继续
  setTimeout(poll, 5000);
}

function startPolling() {
  poll(); // 立即启动第一次轮询
}

function stopPolling() {
  // 这里其实有个遗憾:无法清除 setTimeout
  // 所以我们加了个 isPolling 标志位来控制流程
}

这样改完之后,至少不会再出现请求堆叠的问题。虽然 setTimeout 不能像 clearInterval 那样直接取消,但我们通过布尔锁控制了流程,也算折中方案。

不过这里还是留了个小问题:页面切换路由时,如果没及时调用 stopPollingsetTimeout 还在跑。后来我在组件卸载时加了个标记:

let shouldStop = false;

function startPolling() {
  shouldStop = false;
  poll();
}

function stopPolling() {
  shouldStop = true;
}

async function poll() {
  if (shouldStop || isPolling) return;

  // ... 请求逻辑

  if (!shouldStop) {
    setTimeout(poll, 5000);
  }
}

虽然不够优雅,但亲测有效。项目赶进度,有时候就得接受这种“不太干净”的写法。

节流策略试了又试

接着我又想,能不能根据页面是否可见来控制轮询频率?比如用户切到别的标签页去了,我就把间隔拉长到 30 秒一次,回来再恢复成 5 秒。这不仅能省资源,还能提升用户体验。

于是引入了 visibilitychange 事件:

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    // 页面不可见,降低轮询频率
    shouldStop = true;
    setTimeout(() => {
      if (document.visibilityState === 'hidden') {
        pollWithSlowRate(); // 慢速轮询
      } else {
        startPolling(); // 恢复正常轮询
      }
    }, 100);
  } else {
    shouldStop = false;
    startPolling();
  }
});

但这里有个细节要注意:不能直接在 visibilityChange 里调 stopPolling(),否则容易断掉太久。我折腾了半天才搞明白,最好是在状态变化后延迟一点判断当前是否仍处于隐藏状态,避免误判。

最终的解决方案

最后定稿的方案是这样的:

  • 使用递归 setTimeout 轮询,防止请求堆积
  • 添加 isPollingshouldStop 双标志位控制流程
  • 监听页面可见性,动态调整轮询间隔
  • 接口返回 304 或无新数据时,不做 UI 更新,减少渲染压力
  • 增加错误重试机制,连续失败 3 次后暂停轮询 1 分钟,避免雪崩

整体跑下来,现在平均 QPS 控制在 50 以下,用户反馈也没有卡顿问题。虽然不是最优解,但满足当前需求绰绰有余。

回顾与反思

回头看这次轮询的实现,其实技术本身很简单,真正难的是各种边界情况和线上表现。开始没想到一个定时器能引发这么多问题,但从中学到的东西比写十个 CRUD 组件还多。

有几个点我觉得还可以优化,但没来得及做:

  • 没有做节流去重,比如短时间内收到重复订单 ID 的情况
  • 轮询间隔还是固定值,其实可以做成自适应(根据负载或数据变化频率动态调整)
  • 移动端息屏后没有进一步降频,仍有电量消耗

不过目前这些问题都不影响核心功能,所以暂时搁置了。毕竟项目要上线,完美主义是要付出代价的。

另外提醒一点:轮询一定要配监控!我们后来加了日志上报,记录每次轮询的耗时、成功率、数据量,发现问题能第一时间定位。

以上是我的项目经验,希望对你有帮助

轮询看着土,但在实际项目中真的挺常用。尤其当你面对的是老旧系统、外部接口不支持推送、或者工期压死的情况,它就是最靠谱的选择。

这次从踩坑到稳住,花了差不多一周时间调细节,中间差点被自己写的代码气笑。但也正是这些琐碎的问题,才让项目变得更真实。

如果你也在用轮询,欢迎交流你们的处理方式。有没有更好的控制策略?怎么做的容错?说不定下次我能抄个更优解。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论