tl;dr
- First Paint はウェブページの何らかが描画されたタイミングのこと。これが遅いということは、初回の真っ白な状態が長いということ。
- First Paint は Chrome Developer Tool の Audit で簡単に確認できる。
- HTML は描画の起点になるので、ロード時間はそのまま First Paint Timing の遅延になる。
- CSS は描画に必須なので ロード時間はそのまま First Paint Timing の遅延になる。
- JavaScript は同期で読み込むと、ロード時間がそのまま First Paint Timing の遅延になる。
- JavaScript は非同期で読み込むと、ロード時間が First Paint Timing の遅延にならない。
- 詳しくは一番下に検証結果を載せたので参照
First Paint と Critical Rendering Path
First Paint は標準化が進んでいる Paint Timing API の指標の一つ。ウェブページの何らかが描画されたタイミングのことを表す。ユーザー体験を良くするためのパフォーマンス指標として活用できる。
First Paint を速くするということは、Critical Rendering Path {CRP} を最適化するということに通じる。CRP は HTML、CSS、JavaScript のバイトの受信から、これらをピクセルとしてレンダリングするために必要な処理
のことを表す。具体的には、次のステップに分解できる。
- HTML マークアップから DOM ツリーを構築する
- CSS マークアップから CSSOM ツリーを構築する
- DOM ツリーと CSSOM ツリーを組み合わせてレンダリングツリーを構築する
- レンダリングツリーを使って画面にピクセルをレンダリングする(= ペイントする)
First Paint Timing の確認方法
First Paint を正確に測定するには後述の Paint Timing API を使うのが良いが、簡単にどの程度かを把握するには、Chrome Developer Tool の Audit を使うと良い。First Meaningful Paint はユーザーにとって意味がある表示が行われたタイミングを指す指標で、First Paint のあとに来る。Performance の Metrics をみれば 3s かかっていてその間真っ白なので First Paint も速くはないな、程度はこれだけでわかる。
Critical Rendering Path と HTML, CSS, JavaScript
CRP の各ステップから、HTML と CSS のロードが遅ければ First Paint が遅くなるのは自明である。ただし、 CSS を非同期読み込みさせた場合は CRP をブロックしない。CSS の遅延ロードは JavaScript で動的に link タグを挿入することで実現できる。これを行う場合は、CSS が適用されていない HTML がレンダリングされるわけなので、注意が必要。
JavaScript はデフォルトの同期読み込みでは CRP のステップ 1 をブロックするので、First Paint に直接影響を与える。ただし、非同期で読み込んだ場合は CRP をブロックしない。これは async, defer キーワードを使うか動的に script タグを挿入することで実現できる。初期表示に影響がないアナリティクス用途の JavaScript で使うと良い。
JavaScript の読み込む方法はそれぞれ挙動が違うだけでなく、イベントの発火タイミングも違うので整理しておく。
- 同期読み込み
- head に script タグを置く場合。JavaScript のロードが終わってから First Paint が始まる。DOMContentLoaded は JavaScript のロード完了を待ってから発火する。
- body に script タグを置く場合。JavaScript のロードが終わる前にタグより上だけ描画される形で First Paint が始まる。タグより下はロード完了後に描画される。DomContentLoaded は JavaScript のロード完了を待ってから発火する。
- 非同期読み込み
- async は load イベント前までに読み込まれることが保証される。つまり DOMContentLoaded は JavaScript のロード完了を待たないで発火する。複数ある場合の順序は不定。
- defer は DOMContentLoaded イベント前までに読み込まれることが保証される。つまり DOMContentLoaded は JavaScript のロード完了を待ってから発火する。複数ある場合の順序は指定できる。
上記挙動から、ほかの JavaScript が依存しているコードの非同期読み込みには、defer を使うと良い。それ以外は async を使うと良い。
DOMContentLoaded イベント
First Paint に直接関係ないので余談として。
JavaScript の同期読み込みは CRP のステップ1 をブロックすることを、上記で書いた。実際はこれに加えて CRP のステップ1 の CSS ロードへの依存
も発生させる。この結果、DOMContentLoaded は CRP のステップ1 と CSS ロードの両方が完了してから発火するようになる。
前提として DOMContentLoaded イベントは CRP のステップ 1 完了時点で発火する。そして、HTML ツリーの構築と CSS ツリーの構築はお互いに依存関係がなく並行して進められる。そのため、CSS のロードに時間がかかっても、基本的には DOMContentLoaded イベントの発火には影響を与えない。
CRP のステップ1 のCSS ロードへの依存
とは、HTML ツリー構築前に CSS が必要になることを表す。これは、HTML ツリー構築には JavaScript のロードが必要で、JavaScript のロードには CSS が必要になるためである。JavaScript の実行は HTML と CSS を書き換える可能性があるので、CSS のロードが必要という理屈である。
つまり、DOMContentLoaded を速く発火させたい場合は JavaScript のロード時間に関係なく非同期で読み込ませないといけない。
検証
検証方法は次の通り。
- CRP パフォーマンスは Navigation Timing API を使って測定する
- interactive は DOM 構築にかかった時間を表す
- contentLoaded は interactive から DOM 構築後 defer スクリプトを実行して DOMContentLoaded イベントを発火させるまでにかかった時間を表す
- complete は contentLoaded からサブリソースを含めたロードが完了して load イベントを発火させる準備ができるまでにかかった時間を表す
- Paint パフォーマンスは Paint Timing API を使って測定する
- first-paint は何らかが表示されるまでにかかった時間を表す
- first-contentful-paint は何らかのコンテンツが表示されるまでにかかった時間を表す
遅延がない場合
これがベースになる。
interactive: 57ms
contentLoaded: 1ms
complete: 32ms
first-paint: 125ms
first-contentful-paint: 125ms
CSS に 5s 遅延があってパーサーブロック JS がある場合
First Paint と DOMContentLoaded は 5s 遅くなる。
interactive: 5013ms
contentLoaded: 6ms
complete: 105ms
first-paint: 5066ms
first-contentful-paint: 5066ms
CSS に 5s 遅延があってパーサーブロック JS がない場合
First Paint は 5s 遅いままだが、DOMContentLoaded は速くなった。
interactive: 155ms
contentLoaded: 3ms
complete: 4851ms
first-paint: 5050ms
first-contentful-paint: 5050ms
JavaScript に 5s 遅延がある場合
First Paint と DOMContentLoaded は 5s 遅くなる。
interactive: 5010ms
contentLoaded: 8ms
complete: 92ms
first-paint: 5061ms
first-contentful-paint: 5061ms
async JavaScript に 5s 遅延がある場合
First Paint も DomContentLoaded も速い。
interactive: 152ms
contentLoaded: 5ms
complete: 4857ms
first-paint: 230ms
first-contentful-paint: 230ms
defer JavaScript に 5s 遅延がある場合
First Paint は速いが、 DomContentLoaded は 5s 遅い。
interactive: 164ms
contentLoaded: 4858ms
complete: 75ms
first-paint: 210ms
first-contentful-paint: 210ms
body 直下に JavaScript をおいて 5s 遅延がある場合
First Paint は速いが、DomContentLoaded は 5s 遅い。
interactive: 5007ms
contentLoaded: 3ms
complete: 295ms
first-paint: 220ms
first-contentful-paint: 220ms
検証に使ったコード
css は https://necolas.github.io/normalize.css/8.0.0/normalize.css
package main
import (
"flag"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"time"
)
var (
jsloadTime = flag.Duration("js_load_time", 0, "JavaScript ファイルの配信遅延時間を表す")
cssloadTime = flag.Duration("css_load_time", 0, "CSS ファイルの配信遅延時間を表す")
)
func main() {
flag.Parse()
http.HandleFunc("/", handler)
http.Handle("/static/", http.StripPrefix("/static/", http.HandlerFunc(static)))
log.Fatal(http.ListenAndServe(":38080", nil))
}
func handler(w http.ResponseWriter, r *http.Request) {
f, err := os.Open("index.html")
if err != nil {
log.Fatal(err.Error())
}
body, err := ioutil.ReadAll(f)
if err != nil {
log.Fatal(err.Error())
}
w.Write([]byte(body))
}
func static(w http.ResponseWriter, r *http.Request) {
name := r.URL.Path
if name == "timing.js" {
time.Sleep(*jsloadTime)
} else if strings.HasSuffix(name, ".css") {
time.Sleep(*cssloadTime)
}
f, err := os.Open("static/" + name)
if err != nil {
log.Fatal(err.Error())
}
body, err := ioutil.ReadAll(f)
if err != nil {
log.Fatal(err.Error())
}
w.Write([]byte(body))
}
<!-- 検証の都度、微調整している -->
<html>
<head>
<title>Critical Path: Measure</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="static/normalize.css" rel="stylesheet">
<script src="static/timing.js"></script>
<script src="static/paint.js"></script>
</head>
<body onload="measureCRP()">
<div class="content">
<h2>Content</h2>
<p>First Hello <span>web performance</span> students!</p>
<p>Second Hello <span>web performance</span> students!</p>
</div>
<div class="crp">
<h2>Navigation Timing</h2>
</div>
<div class="paint">
<h2>Paint Timing</h2>
</div>
</body>
</html>
function measureCRP() {
var t = window.performance.timing,
domLoading = t.domLoading,
domInteractive = t.domInteractive,
domContentLoaded = t.domContentLoadedEventEnd,
domComplete = t.domComplete;
appendCRP(`interactive: ${domInteractive - domLoading}ms`);
appendCRP(`contentLoaded: ${domContentLoaded - domInteractive}ms`);
appendCRP(`complete: ${domComplete - domContentLoaded}ms`);
}
function appendCRP(text) {
appendP(document.querySelector('.crp'), text)
}
function appendP(parentDom, text) {
var stats = document.createElement('p');
stats.textContent = text;
parentDom.appendChild(stats);
}
var observer = new PerformanceObserver(function(list) {
var perfEntries = list.getEntries();
for (var i = 0; i < perfEntries.length; i++) {
var metricName = perfEntries[i].name;
var time = Math.round(perfEntries[i].startTime + perfEntries[i].duration);
appendPaint(`${metricName}: ${time}ms`);
}
});
observer.observe({entryTypes: ["paint"]});
function appendPaint(text) {
appendP(document.querySelector('.paint'), text)
}
function appendP(parentDom, text) {
var stats = document.createElement('p');
stats.textContent = text;
parentDom.appendChild(stats);
}