First Paint (FP) をJavaScriptで検出する

はじめに

FirstPaintの時間を取得することは容易に実現できます。ですが、FirstPaintが発生したタイミングを取得するのは容易ではありません。ここでは、筆者が調査した結果を記します。ですが、まだ完全な方法を発見することはできていません。同じ疑問を持った人の助けになれることを期待しています。(できるのならば、あなたが発見した方法を教えて下さい)

FirstPaintの時間を取得する

// FP/FCP (First Paint / First Contentful Paint)
new PerformanceObserver(function(entryList) {
  for (const entry of entryList.getEntries()) {
    console.log(entry.name, entry.startTime, entry);
  }
}).observe({type:'paint', buffered:true});

// LCP (Largest Contentful Paint)
new PerformanceObserver(function(entryList) {
  for (const entry of entryList.getEntries()) {
    console.log(entry.entryType, entry.startTime, entry);
  }
}).observe({type:'largest-contentful-paint', buffered:true});

PerformanceObserverから取得する
 PerformanceObserver() - Web API | MDN
buffered:trueは、登録前に対象の通知が発生済みの場合、バッファーに予め保存されている通知で発火します。
 登録のタイミングに関わらずFP/FMP/LCPの発生時間を取得できます。

タイミングを取得する:requestAnimationFrame()

requestAnimationFrame(function() {
  console.log('before first paint');
  // ...

  requestAnimationFrame(function() {
    console.log('after first paint');
    // ...
  });
});

※上記コードを<head>内の<script>に直接記述(同期読み込み)する
requestAnimationFrame()は、次の描画処理の直前に呼び出されます
 Window.requestAnimationFrame() - Web API | MDN

理屈

この方法は、不完全です。類推や仮定含みます。

まず、<head>内の<script>同期読み込みのタイミングは、FirstPaintより前です。

そして、requestAnimationFrame()は、次の描画処理の直前に呼び出されます。なので、仮定として、1回目のrequestAnimationFrame()は、FirstPaint直前のタイミングです。2回目のrequestAnimationFrame()はFirstPaint直後のタイミングです。ただし、実際はこの仮定は正しくありません。または、正しくないように見えます。

上記の方法で、FirstPaintの時間とFirstPaint前後のタイミングを取得します。すると、FirstPaintの時間がFirstPaint前後の時間よりも更に遅い時間を取得します。これは、1回目のrequestAnimationFrame()後に描画しなかったことを意味するものと考えられます。このことから、2回目のrequestAnimationFrame()時にFirstPaintが発生済みであると言う仮定が崩れます。また、1回目のrequestAnimationFrame()直後にFirstPaint(画面上への表示)が発生しなかったこと意味します。

よって、この方法は正しくありません。ですが、次の方法よりもこのページを見に来たあなたの要望に答えるものであることを私は確信しています。

タイミングを取得する:PerformanceObserver

new PerformanceObserver(function(list) {
  console.log('after first paint');
  // ...
}).observe({type:'paint'});

※上記コードを<head>内の<script>に直接記述(同期読み込み)する
PerformanceObserver() - Web API | MDN

理屈

この方法は、特に不思議なことは何もありません。FirstPaintの時間を取得する通知用のコールバック関数は、間違いなくFirstPaint後に発生します。ただし、FirstPaint直後に発生することを保証するものではありません。

このページを見に来たあなたの要望に答えるものでないことは容易に想像できます。

※もしも、FP/FCPが別に通知される場合、間違ってFCPで発火する可能性があります。
 また、2回発火する可能性があります。

タイミングを取得する:performance.getEntriesByType('paint')

var func = function() {
  if (!performance.getEntriesByType('paint').length) {
    requestAnimationFrame(func);
  } else {
    console.log('after first paint');
    // ...
  }
};
requestAnimationFrame(func);

※上記コードを<head>内の<script>に直接記述(同期読み込み)する
performance.getEntriesByType() - Web API | MDN

理屈

performance.getEntriesByType('paint')が値を返すようになった時、FirstPaintが完了したと言う仮定です。

この仮定は、PerformanceObserverの方法とほとんど同じ結果を出力します。PerformanceObserverが通知される直前や直後のタイミングを取得できます。よって、ここではPerformanceObserverと同じとして扱います。

実測

requestAnimationFrame

上記の画像は、当サイトのトップページに対して行った計測結果です。画像からはわかりにくいですが、次のことが発生しています。

  1. Parse HTML処理
  2. Recalculate Style処理
  3. 1回目のrequestAnimationFrame()
  4. Layout処理
  5. Paint処理
  6. 2回目~n-1回目のrequestAnimationFrame()
    • n-1回目ではじめて!!performance.getEntriesByType('paint').lengthtrueを返す
  7. FirstPaintの発生
  8. n回目のrequestAnimationFrame()
  9. PerformanceObserverの通知

このことから、Layout+Paint処理を挟む形で1回目と2回目のrequestAnimationFrame()が発生していることが伺えます。これは、不正確ではあるものの、FirstPaintに近いタイミングを取得できているものと考えます。

※リロード毎に結果は、異なりますがほとんど同じ結果になります

テストコード

(function() {
  'use strict';

  var logs = [];
  var isPaint, isLoad;

  var fp = function() {
    logs.push(['animetion-frame', performance.now(), !!performance.getEntriesByType('paint').length]);
    if (isPaint && isLoad) {
      setTimeout(function() {
        logs.forEach(function(log) {
          console.log.apply(this, log);
        });
      }, 100);
    } else {
      requestAnimationFrame(fp);
    }
  }
  requestAnimationFrame(fp);

  new PerformanceObserver((list) => {
    logs.push(['PerformanceObserver', performance.now()]);
    for (const entry of list.getEntries()) {
      logs.push([entry.name || entry.entryType, entry.startTime, entry.duration]);
    }
    isPaint = true;
  }).observe({entryTypes:['paint', 'largest-contentful-paint']});

  window.addEventListener('DOMContentLoaded', function() {
    logs.push(['DOMContentLoaded', performance.now()]);
  });
  window.addEventListener('load', function() {
    logs.push(['load', performance.now()]);
    isLoad = true;
  });
})();

※上記コードを<head>内の<script>に直接記述(同期読み込み)する

出力結果

DOMContentLoaded 149.12999999069143
animetion-frame 304.374999992433 false
load 452.4949999904493
animetion-frame 458.31999999063555 false
animetion-frame 486.1499999969965 false
animetion-frame 502.424999998766 true
PerformanceObserver 508.4399999905145
largest-contentful-paint 461.725 0
first-paint 461.72500000102445 0
first-contentful-paint 461.72500000102445 0
animetion-frame 512.2449999907985 true

※Chromeのプライベートウィンドウ・CPU 6x slowdownの結果
※いろいろあって、画像とは別で取得した結果

その他サイトの結果

https://www.google.com/
animetion-frame 188.95500000508036 false
animetion-frame 214.13000000757165 true
DOMContentLoaded 216.8450000026496
PerformanceObserver 217.8950000088662
first-paint 195.3300000022864 0
largest-contentful-paint 195.334 0
first-contentful-paint 195.33499999670312 0
animetion-frame 223.31500001018867 true
animetion-frame 256.4450000063516 true
animetion-frame 261.20999999693595 true
animetion-frame 281.7300000024261 true
PerformanceObserver 283.53000000061
largest-contentful-paint 252.64 0
animetion-frame 286.3050000014482 true
animetion-frame 342.22500000032596 true
load 342.8950000088662
animetion-frame 384.3849999975646 true


https://github.co.jp/
animetion-frame 116.95999999938067 false
animetion-frame 182.34499999380205 false
animetion-frame 194.27499998710118 true
PerformanceObserver 197.0149999979185
largest-contentful-paint 163.925 0
first-paint 163.92500000074506 0
first-contentful-paint 163.92500000074506 0
DOMContentLoaded 212.27499999804422
load 212.70999999251217


https://m.yahoo.co.jp/
animetion-frame 280.48000000126194 false
animetion-frame 301.1100000003353 true
PerformanceObserver 310.2249999938067
first-paint 284.6750000026077 0
animetion-frame 312.56000000576023 true
PerformanceObserver 315.4250000079628
first-contentful-paint 307.9049999942072 0
largest-contentful-paint 307.91 0
animetion-frame 335.5500000034226 true
animetion-frame 356.40499999863096 true
animetion-frame 407.83500000543427 true
animetion-frame 414.6349999937229 true
animetion-frame 420.07000000739936 true
PerformanceObserver 421.5799999947194
largest-contentful-paint 345.774 0
animetion-frame 428.2249999960186 true
DOMContentLoaded 439.81999999959953
animetion-frame 652.4700000009034 true
animetion-frame 760.9550000051968 true
animetion-frame 823.4050000028219 true
animetion-frame 857.8799999959301 true
animetion-frame 873.5400000005029 true
PerformanceObserver 924.1500000061933
largest-contentful-paint 828.28 0
animetion-frame 928.1199999968521 true
... 22個省略
animetion-frame 1345.6200000073295 true
load 1351.9050000031712
animetion-frame 1362.5949999986915 true

※上記のテストコードをUserScriptの@run-at document-startで実行した結果