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 ツリーを構築する
1. CSS マークアップから CSSOM ツリーを構築する
1. DOM ツリーと CSSOM ツリーを組み合わせてレンダリングツリーを構築する
1. レンダリングツリーを使って画面にピクセルをレンダリングする（= ペイントする）

# First Paint Timing の確認方法

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/24932/438f86cf-9408-c34c-92b4-39d01c0db100.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

```go
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
<!-- 検証の都度、微調整している -->
<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>
```

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

```js
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](https://developers.google.com/web/fundamentals/performance/critical-rendering-path/)