前端有一类 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 最应该补的一条测试用例。