几天前我在调试一个列表滚动卡顿的问题,顺手把开发者工具的性能面板拖出来看了几眼。就在那堆密密麻麻的任务之间,我注意到主线程上的空隙几乎像被尺子量过一样,每隔十六七毫秒就出现一次。这个间隔我太熟了:60Hz屏幕的一帧。也就是在那会儿,我冒出一个念头——既然事件循环的每一步都踩在屏幕刷新节奏上,那我是不是可以用纯JavaScript写一个demo,直接把设备的刷新率给测出来?于是就有了这篇文章里的这个小项目。
这个demo本质上做的事情很简单:监听requestAnimationFrame的回调时间,统计相邻两帧之间的时间差,再从这些时间差里反推出当前设备的刷新率。它顺带还会跑一个实时fps计数器、画一张帧间隔直方图,甚至能用setTimeout跟你开个玩笑,让你看清“事件循环的空闲度”和“屏幕刷新率”到底是不是一回事。适合觉得自己事件循环学得差不多、想找个好玩角度加深理解的开发者,也适合单纯想知道自己笔记本或手机浏览器实际跑在多少Hz的普通用户。
1. 事件循环里的“节拍器”:为什么刷新率能和事件循环挂钩
1.1 浏览器绘制一帧的完整流程
要理解这个demo的测量逻辑,得先把事件循环里“渲染帧”的位置讲清楚。现代浏览器在管理页面时,主线程的事件循环大致是这样的:从宏任务队列里取出一个任务执行,比如处理输入事件、执行定时器回调、跑完一段脚本;任务执行完后,清空微任务队列,比如Promise的then回调;接下来浏览器会判断“这一帧是否需要渲染”,如果需要,就依次执行requestAnimationFrame回调、样式计算、布局、绘制,最后把合成好的画面交给显卡。
关键在于最后这个渲染步骤并不是随意触发的,它通常会被浏览器调度到和显示器刷新节奏对齐的位置。显示器每刷新一次,浏览器就获得一次机会去生成新帧;如果页面没有变化也没有动画,它可以跳过渲染;但如果页面上有rAF驱动的动画,浏览器就尽量保证每个刷新周期都执行一次rAF回调。
这个机制很像教室里的打铃制度。显示器是那个打铃的人,每隔固定时间响一次;浏览器是班主任,听到铃响后宣布“大家站起来”(执行rAF回调),然后迅速整理队列(布局和绘制)。如果班主任有事耽误了,那这一帧可能就错过了铃声,学生只能在下一轮铃声响起时再站起来。
1.2 常见刷新率和帧间隔对照表
我们需要测量的,就是“两个铃声之间的实际时间”。屏幕刷新率用赫兹(Hz)表示,60Hz就是一秒刷新60次,换算成单帧时间就是1000 / 60 ≈ 16.67ms。下面是一张常用对照表:
| 屏幕刷新率 | 单帧间隔 | 一秒钟内的帧数 |
|---|---|---|
| 60Hz | 16.67ms | 60帧 |
| 90Hz | 11.11ms | 90帧 |
| 120Hz | 8.33ms | 120帧 |
| 144Hz | 6.94ms | 144帧 |
注意很多屏幕并不是整数的60、120,比如某些笔记本面板是59.94Hz,帧间隔会落在16.68ms附近;还有的屏幕会在不同刷新率之间自动切换。所以检测的时候不能拿着标准值去死板比对,而是要容忍一小段误差区间,我后面会讲到具体怎么处理。
1.3 为什么选rAF而不是setTimeout
测量刷新率的第一步是选对探针。setTimeout虽然也是事件循环的一部分,但它走的是宏任务队列,浏览器并不保证定时器回调跟显示器刷新对齐。你就算设置setTimeout(fn, 0),它也只是说“尽快插入下一个宏任务”,具体晚多少取决于当前主线程忙不忙。
但requestAnimationFrame不一样。浏览器在调度rAF时,会尽量把它放在渲染之前,让回调里的改动在下一帧画面上体现出来。rAF回调收到的时间戳,就是浏览器认为“这一帧应当开始”的时刻。把连续两个rAF回调的时间戳相减,得到的就是浏览器实际执行渲染帧的节奏,这正是我们要测的东西。
这也是为什么说“事件循环demo”而不是“动画demo”——整个测量过程完全是在事件循环的渲染步骤上做文章,所有数据都来自浏览器自身的调度信号。
2. 测刷新率的核心算法:记录帧间隔,找出主频,输出置信度
2.1 rAF时间戳的含义
requestAnimationFrame的回调函数会收到一个DOMHighResTimeStamp类型的参数,很多同学习惯叫它now。这个时间戳和performance.now()同源,但语义上更特殊:它表示的是这一帧的开始时间,也就是浏览器决定开始渲染这一帧的时刻。
在大多数情况下,主线程忙碌程度不高时,回调实际执行时间几乎等于这个帧开始时间;如果主线程忙到炸,回调会迟到,但入参时间戳依然保留着“应该开始渲染”的时刻。因此,我们优先用回调入参去计算相邻帧间隔,而不是在回调里去调performance.now()重新取当前时间,那样会把主线程的排队延迟也算进去,鬼知道中间隔了多久。
2.2 采集样本:收集相邻rAF回调的间隔
测量逻辑第一步是连续采样。我习惯采样240帧左右,也就是大约4秒的数据量,太少容易受偶然掉帧影响,太多会让页面看起来像卡住了一样。下面是核心采集函数:
async function collectFrameIntervals(frameCount = 240) { const samples = []; await new Promise(resolve => { let lastStartTime = -1; let frames = 0; function tick(startTime) { if (lastStartTime >= 0) { const gap = startTime - lastStartTime; const busyDelay = performance.now() - startTime; // 过滤掉明显不合理的间隔 if (gap > 2 && gap < 100) { samples.push({ gap, // 记录主线程迟到时间,后面用于剔除“被阻塞的假帧” busy: busyDelay }); } } lastStartTime = startTime; frames++; if (frames >= frameCount) resolve(); else requestAnimationFrame(tick); } requestAnimationFrame(tick); }); return samples; }有几个过滤条件需要解释一下。间隔小于2ms的数据基本可以扔掉,正常情况下浏览器不可能在2ms内完成一整套渲染流程,出现这种数多半是同帧重复回调、时间戳抖动,或者别的怪异情况。间隔大于100ms的样本同样没有意义,那往往来自用户切走标签页、系统休眠、或者长时间卡死,混进统计里会把结果彻底带偏。
busyDelay是我顺手记的一个诊断字段。它等于当前真实时间和rAF入参时间戳的差,用来判断这一帧回调是不是“迟到”。如果页面主线程被长任务霸占,rAF回调只能在长任务结束后才执行,这个差值就会很大。后面我专门讲它怎么用。
2.3 为什么取众数而不是平均
拿到几百个帧间隔之后,最自然的想法是算平均值。但平均值恰恰是个坑。拿一台60Hz屏幕来说,正常情况下帧间隔稳定在16.7ms左右。可一旦浏览器出现一次掉帧,就会产生一个33.4ms的间隔;如果掉两帧,就是50.1ms。假设我们采到10个样本,其中8个是16.7ms,2个是33.4ms,那么平均数是:
(8 × 16.7 + 2 × 33.4) / 10 = 20.0ms
换算出来居然只有50Hz,和真实的60Hz差了整整10Hz。平均值对离群值极其敏感,而掉帧在浏览器里又太常见了。
正确做法是找“众数”,也就是出现次数最多的那个间隔。还是上面那组数据,16.7ms出现了8次,众数就是16.7ms,对应60Hz,完美还原真实刷新率。这背后的原理其实很简单:一个稳定在60Hz的屏幕,最频繁出现的间隔必然是一帧的标准间隔;偶尔的掉帧只不过是在整数倍位置多出一些零星样本,不会影响主峰的位置。
2.4 量化和主频判定代码
拿到众数之后,还得把它映射到具体的刷新率。这里可以直接用候选目标值去投票。我准备了一份候选表,包含144Hz、120Hz、90Hz、60Hz这四档最常见刷新率,然后对每个样本做最近邻匹配,落在某个候选值附近的就给它投一票:
function estimateRefreshRate(gaps) { const targets = [ { hz: 144, interval: 1000 / 144 }, { hz: 120, interval: 1000 / 120 }, { hz: 90, interval: 1000 / 90 }, { hz: 60, interval: 1000 / 60 } ]; const votes = {}; for (const gap of gaps) { for (const t of targets) { if (Math.abs(gap - t.interval) < 1.6) { votes[t.hz] = (votes[t.hz] || 0) + 1; break; } } } let best = null; for (const [hz, count] of Object.entries(votes)) { if (!best || count > best.count) { best = { hz: Number(hz), count }; } } return best ? { hz: best.hz, confidence: best.count / gaps.length } : { hz: 0, confidence: 0 }; }容差1.6ms是我实测后的折中值。60Hz和90Hz之间差着5.5ms,90Hz和120Hz之间差着2.8ms,1.6ms的容差完全能分开;但144Hz和120Hz只差1.4ms,用这个容差就会出现重叠,有可能把部分样本投错票。所以这个demo只会输出一个相对可信的整数结果,真到144Hz和120Hz纠缠不清的情况,我建议用直方图肉眼看主峰位置,或者多采几百帧让数据更干净。
3. 实测会翻车:后台标签页、省电模式、可变刷新率和系统抢占
3.1 第一个坑:标签页一隐身,节奏就没了
按理说,代码没问题就能跑出结果,但我第一次真机测试就翻车了。当时我在电脑上开着三四个标签页,测试页面只在其中一个标签页里。采样中途我点了一下另一个标签页看网页,回来一看结果,居然测出个15Hz。
原因很简单:标签页一旦进入后台,浏览器为了省资源,会直接暂停或大幅度限制rAF回调。有些浏览器在页面不可见时干脆完全不调用rAF,有些则是降到1帧每秒意思一下。无论哪种情况,采样数据里混入了超长间隔,众数直接失效。
解决办法有两个。一是在开始采样之前检查document.visibilityState,必须等于'visible'才能动手;二是采样过程中监听visibilitychange事件,用户一切走就立即中止,回到页面后再重采。我最后写了个自动重试机制,这样即使测试中切了标签页,回来时也会自动重新测量,不至于显示一个离谱的数字。
3.2 第二个坑:省电模式和自适应刷新率,直方图会出现双峰
第二个坑来自设备本身的省电策略。我在一台标称120Hz的笔记本上测试,插电时测出来是120Hz,拔掉电源再测,结果变成了60Hz。不是因为代码错了,是系统在电池模式下把刷新率降到了60Hz以延长续航。这种情况下,测出来的60Hz其实是设备在当前状态下的真实表现,但容易让人误以为屏幕本身只有60Hz。
更复杂的自适应刷新率屏幕也差不多。所谓自适应,就是屏幕可以在一个范围内动态调整刷新率,比如显示静态图片时降到几十Hz,滚动或播放动画时升到最高档。这类屏幕的直方图往往会出现“双峰结构”——8.3ms和16.7ms各自攒起一个小高峰,看起来像60Hz和120Hz在打架。
处理思路是不要只看单一峰值,而是同时报告“最短主簇间隔”和“当前主簇间隔”。最短主簇间隔才能反映设备想跑的最高速率。如果你的屏幕测出双峰,看到的第一反应不应该是代码坏了,而应该是“这块屏支持可变刷新率”。
3.3 第三个坑:主线程被占满时,帧是真的会迟到
刷新率测量还有一个非常隐蔽的干扰因素:主线程繁忙。如果你的页面里跑着很多重型动画,或者其他标签页在后台疯狂占CPU,rAF回调本身可能被推迟。前面tick函数里我记录的busyDelay字段就是用来处理这个场景的。
假设弹出一个200ms的同步长任务,在这200ms里所有rAF回调都不会执行。等任务结束,浏览器会立刻补一次rAF,但这次回调的入参时间戳还是“它原本应该出现”的时刻,而回调真正执行时已经隔了200多毫秒。如果不管这个问题,这组数据里就会出现一个巨大的异常间隔,又会把平均值捣乱。
我采用的方案是过滤掉busyDelay超过6ms的样本。超过这个阈值,说明这一帧已经实际错过了窗口期,不值得参与主频统计。这里的6ms是根据浏览器帧调度习惯拍的一个经验值,不是科学常数。笔记本配置差一点、后台占用高一点,可能需要调整阈值,否则过滤太严反而样本不够。
3.4 第四个坑:窗口跑到哪块屏,结果就是哪块屏的刷新率
最后这个坑和代码无关,但坑过不少人。如果你外接了显示器,浏览器窗口落在哪块屏幕上,rAF的节奏就跟哪块屏幕走。比如主屏是60Hz,外接屏是120Hz,把浏览器窗口从主屏拖到外接屏,同一段代码测出来的结果会从60Hz变成120Hz。
所以想测哪块屏,就把窗口拖到哪块屏,再重新跑一遍。别边测边拖,采样中切换显示器会导致帧间隔剧烈抖动,出来一个谁都不认识的结果。
4. 把demo做成一个可以玩的“设备体检”页面
4.1 页面布局和交互设计
光有一堆数字不够直观,我把它做成了一个小页面,包含三个模块。第一块是大号刷新率数字和状态徽标,几秒自动刷新一次;第二块是帧间隔直方图,用canvas画出来,每个毫秒桶的样本数量一目了然;第三块是一个跑圈小球,用rAF驱动做往返运动,方便眼睛直接感受当前帧率是否顺滑。
页面的核心逻辑是每3秒自动重测一轮。自适应刷新率屏幕在静止时可能会降频,自动重测能捕捉到屏幕频率切换的过程。直方图只画最近的采样结果,不保留历史,这样每一轮都像是给屏幕做了一次即时体检。
4.2 完整代码实现
下面是一份完整的可运行页面,保存成HTML文件直接打开就行:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Event Loop Refresh Rate Meter</title> <style> body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: #f4f5f7; color: #222; margin: 0; padding: 24px; display: grid; gap: 16px; } .card { background: #fff; border-radius: 14px; box-shadow: 0 4px 14px rgba(0,0,0,.06); padding: 20px 24px; } .big-number { font-size: 56px; font-weight: 800; letter-spacing: -1px; } .badge { display: inline-block; margin-left: 8px; padding: 2px 10px; border-radius: 99px; background: #e6f4ff; font-size: 13px; font-weight: 600; color: #0b57d0; } canvas { width: 100%; height: 180px; display: block; } .runway { position: relative; height: 80px; border-radius: 12px; background: #eee; overflow: hidden; } .ball { position: absolute; top: 24px; left: 12px; width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #4361ee, #9d4edd); box-shadow: 0 2px 6px rgba(0,0,0,.18); } .hint { color: #666; font-size: 14px; line-height: 1.6; } </style> </head> <body> <div class="card"> <div> <span class="big-number" id="hz">--</span> <span class="badge" id="state">采样中</span> </div> <div class="hint" id="detail">请保持页面在前台,不要切换标签页。</div> </div> <div class="card"> <b>帧间隔直方图(ms)</b> <canvas id="hist" width="600" height="180"></canvas> </div> <div class="card"> <b>跑圈动画(观察流畅度)</b> <div class="runway"><div class="ball" id="ball"></div></div> <div class="hint">如果看到小球一顿一顿,说明当前帧率不太高;丝滑移动说明至少在高刷新率区间。</div> </div> <script> const hzEl = document.getElementById('hz'); const stateEl = document.getElementById('state'); const detailEl = document.getElementById('detail'); const ball = document.getElementById('ball'); const canvas = document.getElementById('hist'); const ctx = canvas.getContext('2d'); function drawHistogram(gaps) { if (!gaps.length) return; const maxGap = Math.min(50, Math.max(...gaps)); ctx.clearRect(0, 0, canvas.width, canvas.height); const bins = new Array(Math.ceil(maxGap)).fill(0); for (const g of gaps) { if (g >= 0 && g < maxGap) bins[Math.floor(g)]++; } const maxCount = Math.max(...bins); const barW = canvas.width / bins.length; ctx.fillStyle = '#0b57d0'; for (let i = 0; i < bins.length; i++) { const h = (bins[i] / maxCount) * (canvas.height - 20); ctx.fillRect(i * barW, canvas.height - h, barW - 1, h); } ctx.fillStyle = '#666'; ctx.font = '11px monospace'; ctx.fillText('0ms', 4, canvas.height - 6); ctx.fillText(maxGap.toFixed(0) + 'ms', canvas.width - 46, canvas.height - 6); } function estimateRefreshRate(gaps) { const targets = [ { hz: 144, interval: 1000 / 144 }, { hz: 120, interval: 1000 / 120 }, { hz: 90, interval: 1000 / 90 }, { hz: 60, interval: 1000 / 60 } ]; const votes = {}; for (const gap of gaps) { for (const t of targets) { if (Math.abs(gap - t.interval) < 1.6) { votes[t.hz] = (votes[t.hz] || 0) + 1; break; } } } let best = null; for (const [hz, count] of Object.entries(votes)) { if (!best || count > best.count) best = { hz: Number(hz), count }; } return best ? { hz: best.hz, confidence: best.count / gaps.length } : { hz: 0, confidence: 0 }; } async function measure() { const stateEl = document.getElementById('state'); const detailEl = document.getElementById('detail'); if (document.visibilityState !== 'visible') { stateEl.textContent = '不可见'; detailEl.textContent = '标签页隐藏了,切回前台后3秒内会自动重测。'; return; } const samples = []; await new Promise(resolve => { let last = -1; let frames = 0; function tick(now) { if (last >= 0) { const gap = now - last; const busy = performance.now() - now; if (gap > 2 && gap < 100) samples.push({ gap, busy }); } last = now; frames++; if (frames >= 240) resolve(); else requestAnimationFrame(tick); } requestAnimationFrame(tick); }); const cleanGaps = samples.filter(s => s.busy < 6).map(s => s.gap); if (!cleanGaps.length) { stateEl.textContent = '无数据'; detailEl.textContent = '采样失败,可能是页面被持续阻塞。'; return; } const { hz, confidence } = estimateRefreshRate(cleanGaps); hzEl.textContent = hz || '--'; stateEl.textContent = confidence > 0.35 ? '可信' : '混杂'; detailEl.textContent = `采样 ${cleanGaps.length} 个有效帧间隔,主频 ${hz}Hz,置信度 ${(confidence * 100).toFixed(0)}%`; drawHistogram(cleanGaps); } let start = 0; function loop(t) { if (!start) start = t; const elapsed = (t - start) / 1000; const period = 2; const phase = (elapsed % period) / period; const x = 12 + phase * (document.querySelector('.runway').clientWidth - 56); ball.style.transform = `translateX(${x}px)`; requestAnimationFrame(loop); } requestAnimationFrame(loop); measure(); setInterval(measure, 3000); </script> </body> </html>4.3 页面逻辑的执行流程
页面的核心逻辑藏在measure()函数里。第一步检查页面是否可见,不可见就直接放弃这一轮;第二步采样240帧,把有效间隔和忙碌标记一起存进数组;第三步用busy < 6过滤掉那些主线程迟到的样本;第四步调用estimateRefreshRate算出主频和置信度;最后把结果绘制到页面上。
为什么我会把置信度阈值设在0.35?因为自适应刷新率屏幕会把票分散到多个档位,即使真实主频是120Hz,它的票占比也可能只有40%到50%。如果阈值设太高,很多自适应屏幕会被误判成“混杂”,设成0.35既能覆盖这种情况,又不至于让只有零星几票的结果冒充可信。
4.4 如何手动核验测量结果
想确认页面测得准不准,有两个不怎么费事的办法。一是打开开发者工具的性能面板,录制一小段时间线,然后看“渲染”或“绘制”相关任务的间距。如果这些任务在时间轴上的间距均匀分布在16.7ms左右,那和页面上测出来的60Hz就是吻合的。二是打开操作系统自带的显示设置页,在高级显示信息里找到当前刷新率,和页面结果做一次交叉对比。
顺带提一句,如果你的浏览器支持requestVideoFrameCallback,也可以把它当作第二个探针。它会在视频帧实际呈现时触发,比rAF更接近“真正显示出去”的时刻,但受片源帧率的限制,直接用视频帧间隔不够准确,更适合用来观察视频播放有没有掉帧。
5. 顺带实验:用 setTimeout 测到的“假刷新率”
5.1 一个反直觉的小脚本
标题里的“顺便”,主要体现在这个重量轻但很有意思的实验上。把事件循环玩明白之后,我不禁好奇:如果用setTimeout(0)去高频计数,会不会也得到一个跟屏幕刷新率接近的数字?于是写了下面这段脚本:
let count = 0; const startTime = performance.now(); function loop() { count++; if (performance.now() - startTime < 1000) { setTimeout(loop, 0); } else { console.log('1秒内执行了', count, '次定时器回调'); } } setTimeout(loop, 0);这个脚本让setTimeout(0)循环触发,跑一秒后打印次数。我原以为会得到一个几百上千的大数字,结果在多数浏览器里,它只有240到250次左右。换算下来,每次回调间隔大约是4ms。
5.2 这4ms是哪来的
答案在浏览器的嵌套定时器节流规则里。当页面里的定时器处于“嵌套”状态,也就是前一个定时器回调里又创建了下一个定时器时,浏览器会强制把最小间隔限制到约4ms,防止脚本用setTimeout循环把页面拖垮。因为我们的脚本是嵌套调用,所以每秒最多跑250次左右。
4ms这个数字和屏幕刷新率没有半毛钱关系。60Hz屏幕的帧间隔是16.7ms,120Hz是8.3ms,它们都不是4ms。所以如果你用这类方法去测“刷新率”,得到的只是一个由事件循环调度规则决定的假结果。它能说明事件循环至少还有多快的调度余量,但说明不了屏幕实际在跑多少Hz。
5.3 把主线程堵死,看看会怎样
再做一个更直观的破坏性实验。在页面运行时,往主线程里塞一个同步长任务:
const blockUntil = performance.now() + 200; while (performance.now() < blockUntil) {}这段代码会让主线程空闲200ms。期间rAF回调全部排队等待,setTimeout回调也全部堆积,动画直接卡住。等长任务结束后,所有积压的回调会集中冒出来,帧间隔直方图会出现一个巨大的异常尖峰,跑圈小球则表现为明显停顿。
这个实验解释了一件事:无论是rAF还是setTimeout,它们都依赖主线程这个“单车道”。一旦车道被堵,所有节拍器都会一起晚点。所以测量刷新率之前,最好把页面放到干净环境里,关掉其他重型动画,别拿一个正在直播或滚动新闻的页面当测试床,否则数据里会掺进去大量不属于屏幕节奏的噪声。
最后分享一点实操体会
这个demo我后来一直留着,每次拿到一台新设备就点开测一下。测的次数多了,总结出两条经验:第一,如果直方图上出现两个明显高峰,别急着怀疑代码出了问题,那大概率是可调刷新率屏幕在省电和满血模式之间自动切换,取较短的那个峰才是设备想跑的最快速度;第二,测量时别开着视频网站或有动画的页面,我实际遇到过开着直播页面时怎么测都只有30Hz,关掉后才看到真实的120Hz,差异全来自主线程被抢。
如果你也打算在自己的设备上试,建议把页面存成本地文件,放在空白标签页里运行。几秒出一个结果,还能顺便看看那个跑圈小球丝滑不丝滑。这种把浏览器机制变成小实验的玩法,确实比单纯背八股文有意思得多。