HTML
CSS
JavaScript
Go

HTML, CSS, JavaScript それぞれのロード時間が First Paint に与える影響を検証して整理する

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 のバイトの受信から、これらをピクセルとしてレンダリングするために必要な処理のことを表す。具体的には、次のステップに分解できる。

  1. HTML マークアップから DOM ツリーを構築する
  2. CSS マークアップから CSSOM ツリーを構築する
  3. DOM ツリーと CSSOM ツリーを組み合わせてレンダリングツリーを構築する
  4. レンダリングツリーを使って画面にピクセルをレンダリングする(= ペイントする)

First Paint Timing の確認方法

First Paint を正確に測定するには後述の Paint Timing API を使うのが良いが、簡単にどの程度かを把握するには、Chrome Developer Tool の Audit を使うと良い。First Meaningful Paint はユーザーにとって意味がある表示が行われたタイミングを指す指標で、First Paint のあとに来る。Performance の Metrics をみれば 3s かかっていてその間真っ白なので First Paint も速くはないな、程度はこれだけでわかる。

image.png

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);
}

参考

クリティカル レンダリング パス  |  Web  |  Google Developers