跳到正文
DEV Community· alex feng·· 3 小時前AI 評分52

如何用 requestAnimationFrame 估算瀏覽器畫面更新頻率,避免過度解讀

Estimating Browser Frame Cadence with requestAnimationFrame—Without Overclaiming

AI 導讀

文章説明如何用 requestAnimationFrame 回調間隔估算瀏覽器觀察到的畫面更新節奏,並指出這不等於實體面板刷新率、像素轉換時間或輸入延遲的測量。範例取樣器收集約五秒的間隔,頁面隱藏時會作廢整次取樣並支援取消;分析建議一併查看中位數、MAD 和長間隔比例。文中的 60 個樣本下限及 1.65 倍乘數只是示例啟發式,程式碼亦不代表經過實驗室驗證。

正文

A number such as “144 Hz” looks authoritative. In a browser, the important question is: what produced that number?

A JavaScript animation callback, an operating-system display setting, and a physical panel measurement describe different parts of the rendering pipeline. Treating them as interchangeable produces misleading diagnostics.

Disclosure: this article is published for the DisplayCheck project and was generated with AI assistance. The examples below are educational code, not a claim of laboratory validation.

What requestAnimationFrame actually observes

The browser calls a requestAnimationFrame callback before a repaint. Its callback frequency generally follows the display refresh rate, but background tabs are commonly paused. See the MDN requestAnimationFrame documentation.

The callback timestamp lets us calculate intervals between animation opportunities:

16.67 ms → roughly 60 callbacks per second
 8.33 ms → roughly 120 callbacks per second
 6.94 ms → roughly 144 callbacks per second

Those are illustrative conversions, not measurements from a device. The formula is simply 1000 / intervalMs.

A stable 60-callback-per-second result does not prove that the connected panel is limited to 60 Hz. Browser scheduling, power management, workload, and the active display configuration can affect the observed cadence. It also tells us nothing directly about pixel transition time or input latency.

Collect intervals, not per-frame UI updates

Here is a small sampler that collects about five seconds of callback intervals. It rejects the entire run when the page becomes hidden, supports cancellation, and removes its listeners on completion.

function collectFrameIntervals({ durationMs = 5000, signal } = {}) {
  return new Promise((resolve, reject) => {
    if (!Number.isFinite(durationMs) || durationMs <= 0) {
      reject(new RangeError("durationMs must be positive"));
      return;
    }

    if (document.hidden || signal?.aborted) {
      reject(new Error("Start with a visible page and an active signal"));
      return;
    }

    const intervals = [];
    let frameId = null;
    let first;
    let previous;
    let settled = false;

    function finish(error) {
      if (settled) return;
      settled = true;
      if (frameId !== null) cancelAnimationFrame(frameId);
      document.removeEventListener("visibilitychange", onVisibility);
      signal?.removeEventListener("abort", onAbort);
      if (error) reject(error);
      else resolve(intervals);
    }

    function onVisibility() {
      if (document.hidden) finish(new Error("Page hidden: rerun the test"));
    }

    function onAbort() {
      finish(new Error("Sampling cancelled"));
    }

    function tick(timestamp) {
      if (document.hidden) {
        onVisibility();
        return;
      }
      if (first === undefined) first = timestamp;
      if (previous !== undefined) intervals.push(timestamp - previous);
      previous = timestamp;

      if (timestamp - first >= durationMs) finish();
      else frameId = requestAnimationFrame(tick);
    }

    document.addEventListener("visibilitychange", onVisibility);
    signal?.addEventListener("abort", onAbort, { once: true });
    frameId = requestAnimationFrame(tick);
  });
}

For a React component, start this from a visible button and keep the AbortController in a ref. Abort on unmount. Update React state when the run starts and finishes; do not call setState for every sample. If you need a live progress display, update it at a much lower frequency.

The Page Visibility API provides the visibility signal. Discarding a hidden run is clearer than silently combining samples from different scheduling conditions.

Use a median, then inspect the distribution

An arithmetic mean is easy to distort with a long pause. A median gives a useful central interval, but it must travel with information about the rest of the samples.

function median(values) {
  const sorted = [...values].sort((a, b) => a - b);
  if (!sorted.length) return NaN;
  const middle = Math.floor(sorted.length / 2);
  return sorted.length % 2
    ? sorted[middle]
    : (sorted[middle - 1] + sorted[middle]) / 2;
}

function summarizeCadence(intervals) {
  const valid = intervals.filter((ms) => Number.isFinite(ms) && ms > 0);
  if (valid.length < 60) return { status: "insufficient-data" };

  const medianMs = median(valid);
  const madMs = median(valid.map((ms) => Math.abs(ms - medianMs)));
  const longIntervals = valid.filter((ms) => ms > medianMs * 1.65).length;

  return {
    status: "sampled",
    observedHz: 1000 / medianMs,
    medianMs,
    madMs,
    longIntervalRatio: longIntervals / valid.length,
    sampleCount: valid.length,
  };
}

The 60-sample minimum and 1.65 multiplier are example heuristics. They are not browser standards or hardware pass/fail thresholds. This summary deliberately does not turn them into a certified “stable display” verdict.

Median absolute deviation (MAD) describes typical distance from the median. The long-interval ratio highlights pauses that the central value can hide. Neither metric identifies their cause.

For example, an illustrative distribution alternating between about 8.3 ms and 16.7 ms deserves a distribution view. A single median can land between the two clusters and suggest a cadence that was rarely observed. A histogram or percentile table is a useful next step before assigning confidence.

Be careful with filtering

The DisplayCheck analyzer uses a plausible interval range, a median/MAD-based filter, and a separate instability check. The design lesson is to retain evidence of rejected samples: filtering should not make an interrupted run appear healthy.

Every interval boundary implies a supported range. A 2 ms lower cutoff, for example, excludes intervals corresponding to more than 500 callbacks per second. That may be reasonable for a particular implementation, but it must not silently become a universal assumption about displays.

Likewise, a long callback interval is a browser timing observation. Calling it a “dropped panel frame” assigns a hardware cause the JavaScript code has not established.

A practical comparison workflow

  1. Select the intended refresh setting in the operating system.
  2. Keep the browser window on the display being checked.
  3. Keep the page visible and avoid changing windows during a run.
  4. Collect several runs under the same conditions.
  5. Compare the median and distribution, not only a rounded Hz number.
  6. Change one factor at a time when investigating inconsistent results.

You can try this workflow in the DisplayCheck refresh-rate test. It reports browser-observed cadence and explains the limitations. The broader DisplayCheck toolkit also includes visual pixel, uniformity, gradient, and motion checks without an account or download.

These checks serve different purposes. A motion pattern can help you compare visible trails under different monitor settings; it cannot produce a validated physical response-time value in milliseconds. A browser capability report cannot certify brightness or color-gamut coverage either.

A useful result label is “Browser-observed cadence: approximately 144 Hz.” Pair it with sample quality, a rerun option, and a plain explanation of the measurement boundary.

What sampling or visualization approach has helped you distinguish browser scheduling noise from a repeatable cadence change?

來源:DEV Community · dev.to