浏览器切到后台后,AI 给我的代码就失效了:我拿 3 个模型测了 6 个定时器坑

前端有一类 Bug 特别烦。

你本地盯着页面测:

复制代码
正常。

测试同学一直看着页面测:

复制代码
也正常。

结果用户干了一件特别普通的事:

切到另一个标签页。

过几分钟再回来。

倒计时不准了。

轮询慢了。

Token 过期了。

WebSocket 断了。

甚至页面显示:

复制代码
还剩 02:17

实际上活动早就结束三分钟了。

最恶心的是:

你只要一直盯着这个页面,它就很难复现。

这几天我专门拿这类问题折腾了一下。

我准备了 6 段看起来都很正常的前端代码,分别扔给 ChatGPT、Claude、Gemini 做 Review。

没有什么特别刁钻的语法。

核心就一个问题:

如果用户把页面切到后台,这段代码还靠谱吗?

结果我越来越确定一件事:

浏览器里的定时器,最好从一开始就别把它当钟表。


先说一个前提:setTimeout(1000) 不是"一秒以后一定执行"

很多定时器 Bug 都是从这个误解开始的。

我们写:

javascript 复制代码
setTimeout(() => {
  console.log("1s");
}, 1000);

它表达的更接近:

至少等 1000ms,等浏览器有机会的时候再执行。

而不是:

1000ms 一到,CPU 必须准时来执行我的代码。

页面进入后台以后,浏览器为了省 CPU、电量和资源,可能会对 Timer 做节流。

不同浏览器、不同版本、页面状态,具体策略会有差别。

所以不要把业务正确性建立在:

javascript 复制代码
setInterval 每次一定准点执行

这个假设上。

下面 6 个坑基本都是这个问题的不同变种。


坑 1:用 count-- 写倒计时

这个应该是最常见的。

比如验证码:

ini 复制代码
let seconds = 60;

const timer = setInterval(() => {
  seconds--;

  render(seconds);

  if (seconds <= 0) {
    clearInterval(timer);
  }
}, 1000);

看起来没什么毛病。

每秒:

erlang 复制代码
60
59
58
57
...

但是这里真正计算时间的方式是:

javascript 复制代码
setInterval 执行了多少次

而不是:

复制代码
真实过去了多少时间

这两个不是一回事。


怎么复现?

不用整什么复杂环境。

打开页面。

启动 60 秒倒计时。

然后:

复制代码
切到其他标签页
↓
干点别的
↓
过一会回来

如果浏览器对后台 Timer 做了节流,你看到的剩余时间就可能和真实时间产生偏差。

即使没有后台节流,主线程繁忙也一样会漂。

比如:

javascript 复制代码
setInterval(() => {
  seconds--;
}, 1000);

// 某个任务把主线程卡住
const start = Date.now();

while (Date.now() - start < 5000) {
  // busy
}

真实世界已经过去 5 秒。

你的:

lua 复制代码
seconds--

可没有老老实实执行 5 次。


正确思路:不要数 Tick,直接算时间差

记录:

复制代码
什么时候结束

然后每一次渲染都重新计算:

ini 复制代码
const endAt = Date.now() + 60_000;

const timer = setInterval(() => {
  const remaining = Math.max(
    0,
    endAt - Date.now()
  );

  render(Math.ceil(remaining / 1000));

  if (remaining <= 0) {
    clearInterval(timer);
  }
}, 250);

注意这里:

复制代码
250

不是为了让倒计时"更准"。

真正保证正确的是:

javascript 复制代码
endAt - Date.now()

Timer 只是负责:

什么时候刷新一次 UI。

哪怕中间停了 8 秒。

回来以后重新:

javascript 复制代码
endAt - Date.now()

马上就能校准。

这个思想特别重要:

时间应该来自 Clock,而不是 Callback 次数。


坑 2:轮询写成一个永不停的 setInterval

再看一个很常见的:

ini 复制代码
setInterval(async () => {
  const data = await fetch("/api/status")
    .then(res => res.json());

  updateStatus(data);
}, 5000);

需求:

每 5 秒更新一次任务状态。

非常合理。

但这里其实有两个问题。


第一个:5 秒不是 SLA

页面到了后台以后:

复制代码
5 秒

并不意味着回调一定每 5 秒执行一次。

如果这是一个:

复制代码
任务完成状态
支付状态
扫码登录
导出进度

你不能假设:

我每 5 秒 Poll,所以用户最多 5 秒能看到变化。

页面后台放一会,这个前提就可能没了。


第二个更常见:请求本身可能超过 5 秒

假设:

css 复制代码
请求 A:8 秒

但你的定时器:

复制代码
5 秒

第二个 Tick 到了。

请求 B 又发了。

再过 5 秒:

请求 C。

很容易变成:

vbscript 复制代码
Timer
  ↓
Request A ────────────────
       Request B ────────────────
            Request C ────────────────

请求开始重叠。

很多 AI 第一眼 Review 这段代码,只会讨论:

浏览器后台节流。

但真实项目里:

请求重叠反而经常更先出问题。


我更喜欢"完成以后再安排下一次"

例如:

ini 复制代码
let stopped = false;

async function poll() {
  if (stopped) return;

  try {
    const response = await fetch("/api/status");
    const data = await response.json();

    updateStatus(data);
  } finally {
    if (!stopped) {
      setTimeout(poll, 5000);
    }
  }
}

poll();

function stopPolling() {
  stopped = true;
}

这样至少保证:

css 复制代码
Request A 完成
↓
等 5 秒
↓
Request B

不会无限重叠。


页面重新可见时,再主动同步一次

这个也很好用。

浏览器提供:

javascript 复制代码
document.visibilityState

以及:

复制代码
visibilitychange

所以对于状态类页面,可以:

ini 复制代码
document.addEventListener(
  "visibilitychange",
  () => {
    if (document.visibilityState === "visible") {
      refreshStatus();
    }
  }
);

用户切回来。

不要傻等:

下一次 Poll 什么时候来?

直接同步。


坑 3:Token 刷新完全依赖 setTimeout

这个我觉得比倒计时严重多了。

比如登录以后后端给:

ini 复制代码
expires_in = 3600

前端准备提前一分钟刷新:

ini 复制代码
const refreshIn = 3600 - 60;

setTimeout(() => {
  refreshToken();
}, refreshIn * 1000);

看起来挺标准。

问题是:

业务把 Token 是否有效,绑定到了一个定时器必须准时执行。


用户如果把页面放后台很久呢?

比如:

makefile 复制代码
12:00 登录

Token 13:00 过期

计划:
12:59 refresh

用户 12:10 就把标签页扔后台了。

13:20 再回来。

你的业务代码不应该还抱着:

我不是设了 12:59 刷新吗?

这种心态。

事实只有一个:

复制代码
现在已经 13:20。

Token 是否还能用,应该重新判断。


更稳的做法是记录绝对过期时间

例如:

ini 复制代码
const expiresAt =
  Date.now() + expiresIn * 1000;

判断:

javascript 复制代码
function shouldRefreshToken() {
  return Date.now() >= expiresAt - 60_000;
}

然后可以在几个节点检查:

复制代码
定时器触发
页面重新可见
发重要请求之前
网络恢复以后

例如:

dart 复制代码
document.addEventListener(
  "visibilitychange",
  async () => {
    if (
      document.visibilityState === "visible" &&
      shouldRefreshToken()
    ) {
      await refreshToken();
    }
  }
);

Timer 还是可以有。

但是角色变了:

以前:

ini 复制代码
Timer = 唯一真相

现在:

ini 复制代码
expiresAt = 真相

Timer = 一个提前执行的机会

完全不一样。


这里还有一个并发坑

假设页面恢复以后。

同时三个接口发现:

复制代码
Token 快过期了

然后:

scss 复制代码
refreshToken();
refreshToken();
refreshToken();

三个刷新请求一起出去。

如果你的 Refresh Token 有:

复制代码
rotation

甚至可能自己把自己搞失效。

所以我通常还会加一个:

ini 复制代码
let refreshPromise = null;

function refreshTokenOnce() {
  if (refreshPromise) {
    return refreshPromise;
  }

  refreshPromise = doRefreshToken()
    .finally(() => {
      refreshPromise = null;
    });

  return refreshPromise;
}

这个问题就已经从:

定时器准不准

一路走到了:

并发刷新是否安全。

这才是实际工程里麻烦的地方。


坑 4:用一个 30 分钟 setTimeout 做 Session 超时

比如后台管理系统。

需求:

用户 30 分钟无操作,自动退出。

于是:

scss 复制代码
let logoutTimer;

function resetLogoutTimer() {
  clearTimeout(logoutTimer);

  logoutTimer = setTimeout(() => {
    logout();
  }, 30 * 60 * 1000);
}

每次:

arduino 复制代码
mousemove
keydown
click

就重新计时。

这个版本在一直盯着页面的时候挺正常。

但它其实又犯了同样的问题:

用回调什么时候执行,代表真实经过了多少时间。


更稳的是记录最后操作时间

例如:

ini 复制代码
let lastActiveAt = Date.now();

function markActive() {
  lastActiveAt = Date.now();
}

真正判断 Session:

ini 复制代码
const idleMs = Date.now() - lastActiveAt;

if (idleMs >= 30 * 60 * 1000) {
  logout();
}

Timer 可以继续存在:

scss 复制代码
setInterval(checkIdle, 30_000);

但是当页面:

scss 复制代码
focus
visibilitychange → visible

的时候,也应该重新检查。

ini 复制代码
document.addEventListener(
  "visibilitychange",
  () => {
    if (document.visibilityState === "visible") {
      checkIdle();
    }
  }
);

这样:

用户离开一小时。

切回来。

马上:

bash 复制代码
logout

而不是期待后台那个 Timer 曾经准时帮你执行过。


坑 5:WebSocket 心跳也不能太相信客户端 Timer

这个场景很多实时页面都有。

例如:

javascript 复制代码
setInterval(() => {
  socket.send(
    JSON.stringify({
      type: "ping",
      ts: Date.now()
    })
  );
}, 10_000);

然后:

scss 复制代码
setTimeout(() => {
  if (!receivedPong) {
    socket.close();
  }
}, 5000);

一直盯着页面:

没什么。

一旦页面后台、设备省电、浏览器暂停部分任务:

心跳本身可能产生延迟。

这时候如果你的判断逻辑特别死:

ini 复制代码
5 秒没有 Pong
=
服务器挂了

就可能出现误判。


心跳检查应该看"经过了多久"

比如保存:

ini 复制代码
let lastPongAt = Date.now();

收到:

ini 复制代码
socket.addEventListener("message", event => {
  const message = JSON.parse(event.data);

  if (message.type === "pong") {
    lastPongAt = Date.now();
  }
});

检查时:

scss 复制代码
function checkConnection() {
  const elapsed =
    Date.now() - lastPongAt;

  if (elapsed > HEARTBEAT_TIMEOUT) {
    reconnect();
  }
}

而不是依赖:

复制代码
第 N 个 Timer 有没有准确执行

而且 WebSocket 是否存活,最终还得结合:

复制代码
连接状态
服务器策略
重连机制
网络状态
页面生命周期

不能只看客户端一个 setInterval


坑 6:拿 requestAnimationFrame 做业务计时

这个也是我见过不少的。

比如:

ini 复制代码
let seconds = 60;

function tick() {
  seconds -= 1 / 60;

  render(seconds);

  requestAnimationFrame(tick);
}

requestAnimationFrame(tick);

开发者想的是:

60fps,所以每次减 1/60 秒。

这个逻辑本身就不稳。

实际刷新率可能:

复制代码
60Hz
120Hz
144Hz

丢帧也很正常。

更关键的是:

页面不可见时,requestAnimationFrame 通常会暂停或大幅减少调用。

因为页面都不画了。

浏览器为什么还要让你 60fps 更新一个用户看不见的 UI?


rAF 适合的是"渲染"

比如动画:

scss 复制代码
function renderFrame(now) {
  draw(now);

  requestAnimationFrame(renderFrame);
}

但真正经过了多少时间,应该来自:

sql 复制代码
timestamp

例如:

ini 复制代码
const startAt = performance.now();

function tick(now) {
  const elapsed =
    now - startAt;

  render(elapsed);

  requestAnimationFrame(tick);
}

如果是跨长时间、甚至涉及服务端时间的业务倒计时,我会更倾向基于明确的截止时间来计算。

一句话:

requestAnimationFrame 决定什么时候画,不应该决定时间过去了多少。


我把这 6 段代码丢给 AI 时,用的不是"帮我 Review"

我现在发现:

复制代码
帮我 Review 这段代码

真的太宽了。

模型很容易开始:

复制代码
变量命名
类型抽取
异常处理
代码风格

说了一大堆。

但我真正想测的是:

它有没有浏览器运行时意识。

所以我给三个模型的 Prompt 是这种:

markdown 复制代码
下面是一段浏览器端 JavaScript / TypeScript。

正常前台使用时,它看起来可以工作。

现在不要讨论:
- 命名
- 代码格式
- 设计模式
- 类型抽象
- 一般性的"可以优化"

只考虑一种情况:

用户把当前 Tab 切到后台 10 分钟,
然后重新切回来。

请检查:

1. 哪些行为依赖 Timer 准时执行;
2. 浏览器后台节流后会发生什么;
3. 有没有把"回调次数"错误地当成"真实时间";
4. 页面恢复以后状态是否会自动校准;
5. 有没有异步请求重叠或竞态;
6. 有没有错误的超时、掉线或 Token 过期判断。

对于每个问题,请给:
- 触发条件
- 错误结果
- 最小复现步骤
- 修改思路

如果无法构造实际错误,就不要列出来。

这个 Prompt 比:

复制代码
看看有什么问题

好用很多。

因为它直接把环境变量固定了:

Tab 在后台待 10 分钟。

模型必须沿着这个场景往下推。


第二轮我还会故意换模型

第一轮 Review 完以后,我现在一般不让同一个模型:

复制代码
自己检查自己的答案

而是把:

diff 复制代码
代码
+
第一轮 Review

给另一个模型。

我现在做这种横向测试时一般直接在 chathao.com 里切一下模型,第一轮正常 Review,第二轮只负责找漏项;自己已经有各家官方账号的话开两个标签页也是一样的。

第二轮我会这么问:

markdown 复制代码
下面是另一个 AI 对这段代码的分析。

不要重新从头写一份 Review。

只做两件事:

1. 找出第一份 Review 里的误报;
2. 找出它遗漏的后台 Tab / Timer 问题。

每个结论都必须给出可复现的执行时序。

重点检查:

- Timer throttling
- visibilitychange
- 请求竞态
- Token 过期
- WebSocket 心跳
- requestAnimationFrame
- 页面恢复后的状态校准

最后只输出:
CONFIRMED
REJECT
MISSED
三类结果。

这类问题特别适合双模型 Review。

因为第一个模型一旦形成:

这里只是 setInterval 精度问题。

后面很容易一直顺着这个思路看。

换一个干净上下文,有时反而能抓到:

请求重叠。
Token refresh 并发。
超时并没有真正 Abort。

这些更工程化的问题。


其实 6 个坑最后都是同一个原则

把它们放在一起:

场景 容易写错的思路 更靠谱的状态
倒计时 seconds-- endAt - Date.now()
Polling 每 N 秒一定执行 完成后再调度 + 恢复时刷新
Token Timer 到点就刷新 expiresAt + 恢复时重新判断
Session Timer 到点 logout Date.now() - lastActiveAt
WebSocket Tick 次数判断存活 最后一次真实活动时间
rAF 帧数代表时间 timestamp / deadline

核心就一句:

不要用"定时器执行了几次"表示"真实世界过去了多久"。

Timer 是调度工具。

不是时钟。


再送一个我现在会直接放进前端 Review 的检查表

碰到代码里有:

javascript 复制代码
setTimeout
setInterval
requestAnimationFrame
轮询
倒计时
Token
心跳
Session
缓存过期

我会顺手问这 8 个问题:

markdown 复制代码
1. 页面后台 10 分钟以后会怎样?

2. Timer 晚执行 30 秒,业务结果还正确吗?

3. 主线程卡住以后能自动校准吗?

4. 页面重新 visible 时有没有重新同步状态?

5. 请求时间超过轮询间隔,会不会重叠?

6. 超时以后,真正的异步任务停止了吗?

7. 两个刷新 / 重连同时发生,会不会产生竞态?

8. 这里记录的是"真实时间",还是只记录了"执行次数"?

这 8 条如果都想清楚。

浏览器定时器相关的问题,基本能少一大批。


最后

以前我看到:

scss 复制代码
setInterval(fn, 1000);

脑子里的理解是:

每秒执行一次。

现在会自动翻译成:

浏览器方便的时候,大概隔一段时间让我再执行。

如果业务要求的是:

复制代码
时间必须正确

那就自己保存:

sql 复制代码
deadline
timestamp
lastActiveAt
expiresAt
lastPongAt

真正需要展示 UI 的时候,再根据这些事实重新计算。

不要试图靠:

复制代码
回调执行了 37 次

推断:

复制代码
世界一定过去了 37 秒

这个问题平时特别隐蔽。

因为开发的时候我们一直盯着页面。

测试的时候也一直盯着页面。

偏偏用户不会。

用户可能:

复制代码
开着页面
↓
切微信
↓
看文档
↓
锁屏
↓
吃个饭
↓
再回来

所以前端有些 Bug 真不能只测:

页面一直在我眼前的时候能不能工作。

还得测一句:

我不看它的时候,它还能不能工作。

这可能才是浏览器 Timer 最应该补的一条测试用例。

相关推荐
蓝星空20001 小时前
GPT Image 2.5 生图模型已上线,支持 Flare 与 Sunburst 型号选择
前端·gpt·aigc·image2·imagen
何何____1 小时前
js常见继承方法详解
前端·javascript
parade岁月1 小时前
Tailwind CSS 加入 Shopify,正在用 Tailwind 的项目要不要调整?
前端
梨想橙汁1 小时前
Vue2 快速入门:环境搭建、模板语法、指令系统全解
前端·vue.js
King of fraud1 小时前
HTML 页面的 CSS 选择器详解
前端·css·html
lichenyang4531 小时前
ASCF 元服务如何区分“后台恢复”与“携参拉起”:后台标记 + 参数指纹方案
前端
前端 贾公子1 小时前
Milvus使用指南 (下)
java·服务器·前端
用户2462035827812 小时前
TypeScript类型建模与前后端错误码协议:手机号绑定场景的工程实践
前端
风骏时光牛马2 小时前
云原生架构设计:弹性底座驱动业务持续迭代
前端