こんにちは、羽山です。
今回はウェブシステムにおけるフロントエンドのパフォーマンスの話です。
皆様はウェブのパフォーマンスって気にしてますか?
おそらく大抵の方はSQLのチューニングやロジックの改良で、起点となるHTMLの生成速度を改善した経験はあるのではないでしょうか?
しかし、いくらプログラムをチューニングしても、思ったよりもページ表示の体感速度は改善しなかったりという経験はありませんか?
昨今のウェブサイトは大量のCSS/Javascriptファイルで構成されているページが大半です。例えばHTMLを100~300msくらいで生成して転送できても、ブラウザ側でページ表示が完全に完了するのは4~5秒かかるということも珍しくありません。
「なんか遅いけど沢山いろいろ読み込んでるから仕方ないな・・・」って諦めないでください。ブラウザがどうやってHTMLを解釈して、CSS/Javascriptを読み込むのかを理解すればチューニングのポイントが見えてくるはずです。
例えば以下のシンプルなHTMLがあります。
外部CSSの読み込みとインラインのJavascriptがあり、本文はfooのみですが転送速度に細工をしています。
HTMLはWebサーバから数msで転送されますが、CSSは転送に5秒かかるようにしています。(
また、インラインのJavascriptコードは実行されてから3秒後に本文をbarに変更します。
さて、リンクをクリックしてこのページを開いたとします。
fooまたはbarが画面に表示されるのはリンクをクリックしてから約何秒後になるでしょうか?また色は黒色でしょうか?それとも赤色でしょうか?
正解は、まずは真っ白な画面になり、5秒後に赤色でfooが表示され、その3秒後(合計8秒後)にbarに変化します。
答えを聞いてみると、なんとなく「ああ、そうなる・・・かな・・・。」と思えてきそうですが、おそらく正確に動きを予測できた方は少ないのではないでしょうか?
本稿ではなんでそうなるのか?をブラウザの動きから解説します。
少しボリュームは多めですが、読み終わった暁にはこのHTMLがなんでそういう動きになったのかスーーッと理解できるようになっているはずです。
検証環境にはChromeを利用してますが、本稿の内容はFirefox, Safariなどのモダンブラウザに対応しています。※Edge(41.16299.248.0で確認)はCSSの読み込み動作が若干異なります。
レンダリングをブロック
本稿ではブラウザが画面表示を待たされて、「前の画面から切り替わらない、または真っ白な画面が表示される」ことと定義します。
例えば、CSSからCSSOMが完全に構築完了するまでブラウザは画面表示を行わずに待つので、「レンダリングをブロック」します。
このHTMLは外部リソースの参照がないので、以下のようなシンプルなステップで画面表示されます。
また上記ステップはシリアルに実行されるのではなく、前のステップの実行中でも次のステップは実行されます。
ここでのチューニングポイントは以下の2点です。
b.はHTML生成をどうしても高速化できない場合に検討します。転送されたデータを逐次表示できるのでファーストビューだけでも表示できればUXは改善します。もしJavascriptやCSSなど外部リソースの読み込みがある場合は
ただ難点としてウェブアプリケーションフレームワークと相性が悪いので実装難易度は高くなります。
外部JavascriptファイルはJavascript(
この場合は画面にfooと表示されるのは約何秒後で、スクリプトが実行される(画面に表示される秒数)のは何秒後でしょうか?
答えは、画面表示は約5秒後でスクリプトが実行されるのも同じく約5秒後です。
これは正解した方が多いのではないでしょうか?
以下は、画面表示とファイルの転送状況を表した図です。HTMLの転送は数十msで終了するので便宜上0秒として扱います。
ブラウザがHTMLをパースしていてJavascriptが現れると、その直前までのDOM(Document Object Model)を構築完了した上で、DOM構築をブロックして該当のJavascriptを実行します。
しかしこのJavascriptは外部ファイルになっていて転送に5秒かかるので、
結果としてJavascriptファイルの転送遅延が、その後のすべての処理も遅延させてしまいます。
「Javascriptファイルの転送がそんなに遅延することある?」って声が聞こえてきそうですが、確かに一般的にはそれほど遅延は大きくありません。
しかし以下のケースではJavascriptの外部ファイルでも顕著な遅延が起こりえます。
2.は「指定のスクリプトタグを貼り付けるだけでOK」系の便利サービスを導入した場合などに見かけます。
例えばA/Bテストツールの場合はあえて
3.はTCP接続及びSSL/TLSのハンドシェイク問題です。
HTMLと同一ドメインにJavascriptファイルが配置されている場合は、すでに温まったTCP接続で高速にデータ転送が可能です。しかし別ドメインに配置されたリソースは以下の点で不利です。
fooは即時に表示され、Javascriptは約5秒後に実行されました。狙い通りパフォーマンスは改善されたようです。
現在はあまり言われませんが、昔はJavascriptを
Javascriptファイルの転送をどうしても高速化できないなら、せめて遅延の影響範囲を小さくするという考え方です。
上記のHTMLのようにインラインのJavascriptコードによって、外部Javascriptファイルを読み込むようにします。転送速度は前回同様5秒です。
この場合はfooが即時で表示されてJavascriptのコードは約5秒後に実行されます。与えられた状況下では最も理想的なパフォーマンスと言えます。
インラインのJavascriptコードの実行はDOM構築をブロックしますが、処理内容は
そして動的に追加された
前項の
これは
jQueryの
昔はパフォーマンスチューニングの目的でScript-injectがよく使われていました。
しかし、残念ながらこのScript-injectには大きな問題があります。
それを理解するためにはCSSのパフォーマンスへの影響を理解する必要があるので、次はCSSの話です。
ではfooと表示されるのは約何秒後でしょうか、そしてCSSに指定された赤文字になるのは何秒後でしょうか?
正解は共に約5秒後です。
CSSファイルの転送は5秒かかるものの、CSSスタイルの適用されていない黒文字でのfoo表示はされずに5秒後に赤文字でfooと表示されます。
ブラウザはHTMLからDOMを構築するように、CSSからはCSSOM(CSS Object Model)を構築します。
そしてDOMとCSSOMを利用して実際の画面をレンダリングします。
DOMの場合は構築途中でも途中までの情報を画面に逐次レンダリングしますが、CSSOMは
つまり、CSSOMの構築はレンダリングをブロックします。
一方で外部CSSファイルの読み込みはDOM構築をブロックしないので、
インラインのJavascriptで外部CSSを読み込むタグを追加しました。転送にかかる時間は同様に5秒としています。
これはどのような動きになるでしょうか?
正解は黒文字で即時に表示されて、CSSが読み込まれるタイミングの約5秒後に赤色に変化します。
Javascript同様に、CSS-injectすれば非同期化が可能だということがわかりました。
しかしCSSファイルが読み込まれるまでは、スタイルが適用されていない黒文字のfooが表示される点に注意が必要です。
今回のように色だけならさほど違和感はありませんが、昨今のウェブページはリッチな表現が多用されており、CSSが適用されていないと利用不可能なレベルでレイアウトが崩れるものが大半です。このようなスタイルが適用されていないページが一瞬表示される現象はFOUC(Flash of Unstyled Content)と呼ばれ、UXの著しい低下を招きます。
絶対に必要な軽量CSSと、遅れて読み込んでも構わない巨大CSSの2つに分離することが可能ならばUXは改善しますが、1ページ毎に最適化する作業は難易度が高いので、メンテナンスコストなど費用対効果を考える必要があります。
CSSの非同期化はCSS-inject以外にもmediaタグを利用する方法もあります。mediaタグはブラウザの環境に合わせて該当CSSを適用するかどうかの条件を記述できます。例えば
ただしそのままでは画面表示用に使われないので、以下のように
将来的には
正解は画面表示とJavascriptの実行時間は共に約5秒、CSSスタイルは5秒後に表示されるタイミングですでに適用されています。
HTMLを上からパースするとまずCSSファイルの読み込みになります。この読み込みには3秒かかりますが、CSSファイルの読み込みは前述の通りDOM構築をブロックしないのでブラウザはそのままDOM構築作業を継続します。ただしCSSOMは未構築なので読み込まれるまでの3秒間はレンダリングがブロックされます。
次にJavascriptファイルの読み込みになるので、DOM構築をブロックして指定のJavascriptファイルを読み込みます。
ここで5秒間停止してからJavascriptが実行されます、そのタイミングでは先程の3秒かかるCSSの読み込みは終了しているのでCSSOMの構築も完了しています。
結果、画面表示もJavascriptの実行も共にJavascriptの読み込み時間に合わされる形で5秒となりました。
その理由にはCSSOMとJavascriptによるブロックが大きく関わっています。
前項との違いはJavascriptの読み込みをScript-injectにした点ですが何が悪かったのでしょうか?
実はJavascriptのコード実行ではDOMと同様に直前までのCSSOMの構築を完了させた上でCSSOMをブロックします。
これはJavascriptがCSSOMを取得/変更する機能を持っていることが原因です。そのためブラウザはコード内でCSSOMを利用してかるかどうかに関わらずCSSOMを直前まで構築完了させた上でブロックする必要があるのです。
DOMは順次パースして構築しているのでブロックするだけですみますが、CSSOMは外部リソースからの読み込み待ちをしていることがあります。
今回の例では外部CSSファイルの転送には3秒間かかるので、その転送完了を待ってからインラインのJavascriptが実行されることになります。
そのためCSSが読み込まれてCSSOMが構築完了されたタイミング(3秒後)でScript-injectのコードが実行されて外部Javascriptファイルの読み込みが開始します。そしてさらにその5秒後(開始から8秒後)に外部Javascriptファイルの転送が完了して実行されたので、ここまで遅くなってしまったのです。
これがScript-injectの大きな問題点の1つです。
モダンブラウザはパフォーマンスの改善のためにいろいろな最適化をしてくれています。その一つに将来ダウンロードが必要になるリソースのプリロードがあります。
以下の2つのHTMLを見比べてみてください。
(A) 通常読み込み → Script-inject
(B) 通常読み込み → 通常読み込み
まずは(A)の実行速度です。
(A)は1つ目のJavascriptの実行時間が5秒で、2つ目が10秒になります。
これは、ここまでの知識を元にすれば自然と理解できると思います。1つ目がDOM構築を5秒ブロックして、2つ目はScript-injectなのでDOM構築はブロックしないですが、読み込み(実行)にはさらに5秒かかります。
では(B)を見てみましょう。
(B)は1つ目も2つ目もほぼ同時の5秒で実行されました。
これはここまでの知識では説明できません。予想する動きは
実はこれがプリロード機能です。プリロード機能はDOM構築がブロックされていても、その先のHTMLを事前にパースして、近い将来必要となるリソースを探して読み込んでくれます。
そのため、(B)は1つ目のJavascriptのダウンロードを待っている間に、まだDOM未構築の部分にある2つ目のJavascriptファイルも同時にダウンロードをしてくれます。
そのおかげで2つ目はほぼ待ち時間なしで実行が可能となりました。
一方で(A)はというと、プリロード機能ではJavascriptのコードを解釈した上での読み込みはできないので、Script-injectされて読み込まれる2つ目のJavascriptは事前に読み込まれませんでした。
こういった最適化の恩恵を受けられないことがScript-injectの2つ目の問題です。
多くの皆さんはすでにご存知だとは思いますが async 属性です。
Javascriptコードを非同期実行して構わないのなら、最も効果的な方法です。
すると、なんということでしょう!※某番組風で
画面表示はCSSOMの構築が完了する3秒後に行われました。
Javascriptのコード実行も犠牲にならず5秒後に実行されました。
では、どのように解釈されたか見ていきましょう。
ブラウザは上から順にDOM構築をします。まずは
次にasync付きの
つまりasync属性はScript-injectとほぼ同様の高速化の効果を得られます。さらにScript-injectの問題点だった読み込みのためのJavascriptコード実行が不要になり、プリロード機能の対象にもなるのでいいとこ取りができます。
残りはDOM構築をブロックする要素がないので、結果としてHTML文書の転送完了からほぼ即時でDOM構築が完了し、
与えられた条件においては最高のパフォーマンスと言えますが、CSSOM構築もCSS-injectを利用して非同期化すればさらに画面表示までの待ちは短縮されます。しかしそれにはFOUC問題も出てくるので、どこまでのレイアウト崩れを許容するかの判断が必要です。
パフォーマンスの観点ではJavascriptコードを非同期で実行可能なようにコーディングした上でasync属性をつけるのがベストな選択です。ただ、システムやUI/UXの要件でパフォーマンスを多少犠牲にしてでも動作タイミングを保証したい場合も出てきます。
async属性だけですべて解決するなら本稿のような知識は必要ないのですが、現実はいまだにScript-injectが必要なシーンもあります。同期実行しなければいけないシーンもあります。
そういった場合に表面的な知識ではなく、動作原理から深い知識を持っていれば、Script-injectを利用する代わりにパフォーマンス面で何を失うのか、どうすれば影響を最小限にできるのか?などを総合的にトレードオフを判断して、ベターな選択ができるようになります。
ある程度の知識でもさほど問題なく開発できるのがウェブの良いところですが、開発者としてレベルアップするには低レイヤの動きを理解することがとても重要です。
本稿がその一助になれば幸いです。
今回はウェブシステムにおけるフロントエンドのパフォーマンスの話です。
皆様はウェブのパフォーマンスって気にしてますか?
おそらく大抵の方はSQLのチューニングやロジックの改良で、起点となるHTMLの生成速度を改善した経験はあるのではないでしょうか?
しかし、いくらプログラムをチューニングしても、思ったよりもページ表示の体感速度は改善しなかったりという経験はありませんか?
昨今のウェブサイトは大量のCSS/Javascriptファイルで構成されているページが大半です。例えばHTMLを100~300msくらいで生成して転送できても、ブラウザ側でページ表示が完全に完了するのは4~5秒かかるということも珍しくありません。
「なんか遅いけど沢山いろいろ読み込んでるから仕方ないな・・・」って諦めないでください。ブラウザがどうやってHTMLを解釈して、CSS/Javascriptを読み込むのかを理解すればチューニングのポイントが見えてくるはずです。
例えば以下のシンプルなHTMLがあります。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="css/style.css?d=5000" rel="stylesheet">
<script type="text/javascript">
setTimeout(function() {document.body.innerHTML = "bar"}, 3000);
</script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
外部CSSの読み込みとインラインのJavascriptがあり、本文はfooのみですが転送速度に細工をしています。
HTMLはWebサーバから数msで転送されますが、CSSは転送に5秒かかるようにしています。(
d=5000
の指定で制御)また、インラインのJavascriptコードは実行されてから3秒後に本文をbarに変更します。
style.css
は以下の内容で、body全体のフォントを赤色にするだけのものです。/* style.css */
body {
color: red;
}
さて、リンクをクリックしてこのページを開いたとします。
fooまたはbarが画面に表示されるのはリンクをクリックしてから約何秒後になるでしょうか?また色は黒色でしょうか?それとも赤色でしょうか?
正解は、まずは真っ白な画面になり、5秒後に赤色でfooが表示され、その3秒後(合計8秒後)にbarに変化します。
答えを聞いてみると、なんとなく「ああ、そうなる・・・かな・・・。」と思えてきそうですが、おそらく正確に動きを予測できた方は少ないのではないでしょうか?
本稿ではなんでそうなるのか?をブラウザの動きから解説します。
少しボリュームは多めですが、読み終わった暁にはこのHTMLがなんでそういう動きになったのかスーーッと理解できるようになっているはずです。
検証環境にはChromeを利用してますが、本稿の内容はFirefox, Safariなどのモダンブラウザに対応しています。※Edge(41.16299.248.0で確認)はCSSの読み込み動作が若干異なります。
用語集
本稿には以下のような用語が出てきます。わかりにくいものについては本文中に実例で解説しています。DOM/DOM構築
Document Object Modelの略で、文字列のHTML文書をブラウザがパースして構造化したものをDOMと呼び、その構築作業をDOM構築と呼びます。DOM構築をブロック
DOM構築を途中で停止すること。例えばインラインで書かれたJavascriptはそのJavascriptが実行完了されるまでDOM構築が停止するため、「DOM構築をブロック」しています。CSSOM
CSS Object Modelの略で、HTMLに対するDOMにあたるものです。レンダリングをブロック
本稿ではブラウザが画面表示を待たされて、「前の画面から切り替わらない、または真っ白な画面が表示される」ことと定義します。
例えば、CSSからCSSOMが完全に構築完了するまでブラウザは画面表示を行わずに待つので、「レンダリングをブロック」します。
CSSOM構築と違い、DOM構築は構築済みの情報が逐次画面に表示されるため、本稿定義の「レンダリングをブロック」には該当しません。
シンプルなHTMLのみのパフォーマンス
まずは外部リソースもJavascript/CSSも一切含まないシンプルなHTMLを用意しました。<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
このHTMLは外部リソースの参照がないので、以下のようなシンプルなステップで画面表示されます。
- ウェブシステムがHTMLを生成
- ウェブサーバがブラウザにHTMLを転送
- ブラウザはHTMLをパースしてDOM構築
- ブラウザは画面表示
また上記ステップはシリアルに実行されるのではなく、前のステップの実行中でも次のステップは実行されます。
ここでのチューニングポイントは以下の2点です。
- ステップ(1)のHTML生成速度を改善する
- HTML全体を生成してから転送を開始するのではなく、逐次転送できるようにする
b.はHTML生成をどうしても高速化できない場合に検討します。転送されたデータを逐次表示できるのでファーストビューだけでも表示できればUXは改善します。もしJavascriptやCSSなど外部リソースの読み込みがある場合は
<head>
部分を先に転送できれば<body>
の転送と並列でそれらの転送を行える点でも有利です。ただ難点としてウェブアプリケーションフレームワークと相性が悪いので実装難易度は高くなります。
逐次転送による<head>内の外部リソース読み込みはHTTP/2のサーバープッシュに近い効果を期待できます。ローカルキャッシュの有無を判断した上で転送開始できる点ではサーバープッシュよりも優秀と言えるかもしれません。
HTMLと外部Javascript
では次はJavascriptの外部ファイルを含む場合です。<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="js/speedtest.js?d=5000" type="text/javascript"></script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
外部Javascriptファイルは
d=5000
としているので転送に5秒かかります。speedtest.js
)は、リンクのクリックなどナビゲーション開始を起点としてJavascriptのコードが実行された時点の経過秒数をperformance.now()
で表示しています。// speedtest.js
(function() {
const div = document.createElement("div");
div.style.color = 'red';
div.innerHTML = 'Executed at ' + (performance.now() / 1000).toFixed(3) + "sec";
if (document.body) {
document.body.appendChild(div);
}
else {
document.addEventListener("DOMContentLoaded", function() {
document.body.appendChild(div);
});
}
})();
この場合は画面にfooと表示されるのは約何秒後で、スクリプトが実行される(画面に表示される秒数)のは何秒後でしょうか?
答えは、画面表示は約5秒後でスクリプトが実行されるのも同じく約5秒後です。
これは正解した方が多いのではないでしょうか?
以下は、画面表示とファイルの転送状況を表した図です。HTMLの転送は数十msで終了するので便宜上0秒として扱います。
ブラウザがHTMLをパースしていてJavascriptが現れると、その直前までのDOM(Document Object Model)を構築完了した上で、DOM構築をブロックして該当のJavascriptを実行します。
しかしこのJavascriptは外部ファイルになっていて転送に5秒かかるので、
<head>
内でDOM構築がブロックされたまま5秒間待たされてしまいます。<script>
タグの後にくる<body>
はHTMLとしては転送済みですがDOM構築はされていないので画面にfooは表示されません。結果としてJavascriptファイルの転送遅延が、その後のすべての処理も遅延させてしまいます。
「Javascriptファイルの転送がそんなに遅延することある?」って声が聞こえてきそうですが、確かに一般的にはそれほど遅延は大きくありません。
しかし以下のケースではJavascriptの外部ファイルでも顕著な遅延が起こりえます。
- 海外からのアクセスやモバイル回線などでクライアントの回線状況が悪い
- Javascriptコードを動的に生成する仕組みを利用している
- 別ドメインかつhttpsプロトコルで転送する
2.は「指定のスクリプトタグを貼り付けるだけでOK」系の便利サービスを導入した場合などに見かけます。
例えばA/Bテストツールの場合はあえて
<head>
直後にすべての動きを制御する動的なJavascriptコードを生成するタグを入れることでDOM構築を冒頭でブロックして、意図しない画面が表示されることを防いでいるものがあります。しかしパフォーマンスの視点では最悪な選択です。外部サービスの便利さとパフォーマンスのトレードオフを検討する必要があります。3.はTCP接続及びSSL/TLSのハンドシェイク問題です。
HTMLと同一ドメインにJavascriptファイルが配置されている場合は、すでに温まったTCP接続で高速にデータ転送が可能です。しかし別ドメインに配置されたリソースは以下の点で不利です。
- 新たなTCP接続が必要
- TCP接続したばかりはSlow Startの影響で低速
- SSL/TLSのハンドシェイクが必要
外部ファイルを同一ドメインに配置するか、別ドメインに分離するかは議論の余地があります。
モダンブラウザはHTTP/1.1の場合、ドメインごとに6本程度のTCP接続をして6並列でデータ転送します。しかし外部ファイルが多いと6並列では足りません。そういったケースではTCPの新規接続オーバーヘッドを許容しても別ドメインにしたほうが並列数を稼げてスループットは上昇します。一方でHTTP/2では1本のTCP接続で同時に複数のデータ転送を行えるので、並列数を稼ぐためにドメインを分離する必要性はなくなります。
HTMLと外部Javascript(</body>閉じタグの直前に配置)
<script>
タグでDOM構築をブロックしてしまうなら、<script>
をfooの下(</body>
閉じタグの直前)に移動したらどうなるでしょうか?<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Improve Web Performance</title>
</head>
<body>
foo
<script src="js/speedtest.js?d=5000" type="text/javascript"></script>
</body>
</html>
fooは即時に表示され、Javascriptは約5秒後に実行されました。
現在はあまり言われませんが、昔はJavascriptを
</body>
の直前に置けと言われたのはこれが理由です。Javascriptファイルの転送をどうしても高速化できないなら、せめて遅延の影響範囲を小さくするという考え方です。
Script-injectによるJavascriptの高速化
次はScript-injectによる高速化です。<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script type="text/javascript">
(function() {
const script = document.createElement("script");
script.type = "text/javascript";
script.src = 'js/speedtest.js?d=5000';
document.head.appendChild(script);
})();
</script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
上記のHTMLのようにインラインのJavascriptコードによって、外部Javascriptファイルを読み込むようにします。転送速度は前回同様5秒です。
この場合はfooが即時で表示されてJavascriptのコードは約5秒後に実行されます。与えられた状況下では最も理想的なパフォーマンスと言えます。
インラインのJavascriptコードの実行はDOM構築をブロックしますが、処理内容は
<script>
タグを<head>に
追加しているだけなので、一瞬で実行が完了してDOM構築が再開されます。そして動的に追加された
<script>
タグ経由で読み込まれる外部のJavascriptファイルは非同期で読み込み/実行されるのでDOMもレンダリングもブロックしません。前項の
</body>
直前に<script>
を配置した場合とパフォーマンス的にほとんど違いはありませんが、細かい差異としてDOMContentLoaded
イベントの発火タイミングが</body>
直前パターンだと約5秒後なのに対して、Script-injectパターンではほぼ即時です。これは
</body>
直前パターンはDOM構築が<script>
タグでブロックされて5秒後にDOM構築が完了するのに対して、Script-injectパターンは即時にDOM構築が完了して外部Javascriptの読み込み/実行は非同期で行われるためです。jQueryの
$(document).ready
のようにDOMContentLoaded
イベントで実行が開始される仕組みはよく利用されるので、DOMContentLoaded
イベントをなるべく早く発火できるのは大きなメリットになります。その点でScript-injectは優秀です。昔はパフォーマンスチューニングの目的でScript-injectがよく使われていました。
しかし、残念ながらこのScript-injectには大きな問題があります。
それを理解するためにはCSSのパフォーマンスへの影響を理解する必要があるので、次はCSSの話です。
HTMLと外部CSSファイル
転送に5秒かかるCSSの外部ファイルを1つだけ含むHTMLを用意しました。<!DOCTYPE html>CSSは冒頭と同様にbody全体のフォントを赤にするだけのものです。
<html>
<head>
<meta charset="UTF-8">
<link href="css/style.css?d=5000" rel="stylesheet">
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
/* style.css */
body {
color: red;
}
ではfooと表示されるのは約何秒後でしょうか、そしてCSSに指定された赤文字になるのは何秒後でしょうか?
正解は共に約5秒後です。
CSSファイルの転送は5秒かかるものの、CSSスタイルの適用されていない黒文字でのfoo表示はされずに5秒後に赤文字でfooと表示されます。
ブラウザはHTMLからDOMを構築するように、CSSからはCSSOM(CSS Object Model)を構築します。
そしてDOMとCSSOMを利用して実際の画面をレンダリングします。
DOMの場合は構築途中でも途中までの情報を画面に逐次レンダリングしますが、CSSOMは
<head>
内で読み込まれたCSSからCSSOMを完全に構築完了するまでは真っ白な画面になります。つまり、CSSOMの構築はレンダリングをブロックします。
一方で外部CSSファイルの読み込みはDOM構築をブロックしないので、
<link>
タグがあってもそのままDOM構築は継続するため、DOMContentLoaded
イベントは即時に発火します。これは逆に言うとDOMContentLoaded
イベントのタイミングではCSSOMが構築完了しているかどうかわからないことを意味します。CSS-injectによるCSSファイルの読み込み
では次はJavascriptのScript-injectと同様に外部CSSファイルをCSS-injectで読み込んでみます。<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script type="text/javascript">
(function() {
const tag = document.createElement("link");
tag.href = 'css/style.css?d=5000';
tag.rel = 'stylesheet';
document.head.appendChild(tag);
})();
</script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
インラインのJavascriptで外部CSSを読み込むタグを追加しました。転送にかかる時間は同様に5秒としています。
これはどのような動きになるでしょうか?
正解は黒文字で即時に表示されて、CSSが読み込まれるタイミングの約5秒後に赤色に変化します。
Javascript同様に、CSS-injectすれば非同期化が可能だということがわかりました。
しかしCSSファイルが読み込まれるまでは、スタイルが適用されていない黒文字のfooが表示される点に注意が必要です。
今回のように色だけならさほど違和感はありませんが、昨今のウェブページはリッチな表現が多用されており、CSSが適用されていないと利用不可能なレベルでレイアウトが崩れるものが大半です。このようなスタイルが適用されていないページが一瞬表示される現象はFOUC(Flash of Unstyled Content)と呼ばれ、UXの著しい低下を招きます。
絶対に必要な軽量CSSと、遅れて読み込んでも構わない巨大CSSの2つに分離することが可能ならばUXは改善しますが、1ページ毎に最適化する作業は難易度が高いので、メンテナンスコストなど費用対効果を考える必要があります。
CSSの非同期化はCSS-inject以外にもmediaタグを利用する方法もあります。mediaタグはブラウザの環境に合わせて該当CSSを適用するかどうかの条件を記述できます。例えば
media="print"
とすると印刷用のCSSになるので、レンダリングをブロックしなくなります。ただしそのままでは画面表示用に使われないので、以下のように
onload
でmediaをallに変更します。<link href="css/style.css?d=5000" rel="stylesheet" media="print" onload="this.media='all'">しかしこの方法では、ブラウザが該当CSSをレンダリングに不要なリソースだと判断するので、ネットワークの読み込み優先順位を低く設定します。そのため外部リソースがたくさんある場合は完全なスタイルが適用されるまでの時間は逆に伸びる可能性もあります。
将来的には
rel="preload"
による非同期読み込みが主流になりそうですが、現時点ではブラウザのサポート状況がいまいちです。JavascriptとCSSの両方を含むHTML
では次はJavascriptとCSSを両方共読み込む場合のパフォーマンスです。ようやく現実に存在するHTMLに近づいてきました。昨今JavascriptやCSSの片方でも含まないページを探すのは至難の業です。<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="css/style.css?d=3000" rel="stylesheet">
<script src="js/speedtest.js?d=5000" type="text/javascript"></script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
読み込み3秒のCSSと読み込み5秒のJavascriptです。
では、画面表示、Javascriptの実行時間、CSSスタイルの適用(fooに対して赤文字の適用)はそれぞれどうなるでしょうか?正解は画面表示とJavascriptの実行時間は共に約5秒、CSSスタイルは5秒後に表示されるタイミングですでに適用されています。
HTMLを上からパースするとまずCSSファイルの読み込みになります。この読み込みには3秒かかりますが、CSSファイルの読み込みは前述の通りDOM構築をブロックしないのでブラウザはそのままDOM構築作業を継続します。ただしCSSOMは未構築なので読み込まれるまでの3秒間はレンダリングがブロックされます。
次にJavascriptファイルの読み込みになるので、DOM構築をブロックして指定のJavascriptファイルを読み込みます。
ここで5秒間停止してからJavascriptが実行されます、そのタイミングでは先程の3秒かかるCSSの読み込みは終了しているのでCSSOMの構築も完了しています。
結果、画面表示もJavascriptの実行も共にJavascriptの読み込み時間に合わされる形で5秒となりました。
Script-injectのJavascriptとCSSファイル
Javascriptの読み込みがネックになっているなら、Javascriptの読み込みをScript-injectを利用して非同期にしてみたらどうなるでしょう?<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="css/style.css?d=3000" rel="stylesheet">
<script type="text/javascript">
(function() {
const script = document.createElement("script");
script.type = "text/javascript";
script.src = 'js/speedtest.js?d=5000';
document.head.appendChild(script);
})();
</script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
ここまでの流れではScript-injectしたらJavascriptコードの読み込み/実行が非同期化して高速化していました。今回もそうなるでしょうか・・・?
残念ながら、この場合は画面表示は約3秒後と高速化するものの、Javascriptの実行は約8秒後と逆に遅くなってしまいます。
速くなるのかと思ってScript-injectを利用したのに期待と違う結果になってしまいました。残念ながら、この場合は画面表示は約3秒後と高速化するものの、Javascriptの実行は約8秒後と逆に遅くなってしまいます。
その理由にはCSSOMとJavascriptによるブロックが大きく関わっています。
前項との違いはJavascriptの読み込みをScript-injectにした点ですが何が悪かったのでしょうか?
実はJavascriptのコード実行ではDOMと同様に直前までのCSSOMの構築を完了させた上でCSSOMをブロックします。
これはJavascriptがCSSOMを取得/変更する機能を持っていることが原因です。そのためブラウザはコード内でCSSOMを利用してかるかどうかに関わらずCSSOMを直前まで構築完了させた上でブロックする必要があるのです。
ブラウザにとってはJavascriptコードは実行してみるまで何をするか分からないことが、効果的なチューニングを難しくしています。将来的にはDOM/CSSOMのブロック待ちになったら、サンドボックス環境で動かしてみてDOM/CSSOMを触ってなければブロックせずに実行してしまう、などの効率化がブラウザ側に実装されるかもしれません。
DOMは順次パースして構築しているのでブロックするだけですみますが、CSSOMは外部リソースからの読み込み待ちをしていることがあります。
今回の例では外部CSSファイルの転送には3秒間かかるので、その転送完了を待ってからインラインのJavascriptが実行されることになります。
そのためCSSが読み込まれてCSSOMが構築完了されたタイミング(3秒後)でScript-injectのコードが実行されて外部Javascriptファイルの読み込みが開始します。そしてさらにその5秒後(開始から8秒後)に外部Javascriptファイルの転送が完了して実行されたので、ここまで遅くなってしまったのです。
これがScript-injectの大きな問題点の1つです。
リソースのプリロード機能によるパフォーマンスへの影響
Script-injectにはもう一つ問題があり、それはJavascriptのコードが実行されないとブラウザが対象となるJavascriptファイルのダウンロードを開始できない点です。モダンブラウザはパフォーマンスの改善のためにいろいろな最適化をしてくれています。その一つに将来ダウンロードが必要になるリソースのプリロードがあります。
以下の2つのHTMLを見比べてみてください。
(A) 通常読み込み → Script-inject
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="js/speedtest.js?d=5000&no=1" type="text/javascript"></script>
<script type="text/javascript">
(function() {
const script = document.createElement("script");
script.type = "text/javascript";
script.src = 'js/speedtest.js?d=5000&no=2';
document.head.appendChild(script);
})();
</script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
(B) 通常読み込み → 通常読み込み
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="js/speedtest.js?d=5000&no=1" type="text/javascript"></script>
<script src="js/speedtest.js?d=5000&no=2" type="text/javascript"></script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
それぞれ2つのJavascriptファイルを読み込んでいますが、実体は同一ファイルなので意味のないパラメータ(
(A)は通常読み込みの後に片方のファイルをScript-injectで読み込み、(B)は両方とも通常読み込みをしています。no=1,2
)を付与することでブラウザに別々のファイルと認識させています。まずは(A)の実行速度です。
(A)は1つ目のJavascriptの実行時間が5秒で、2つ目が10秒になります。
これは、ここまでの知識を元にすれば自然と理解できると思います。1つ目がDOM構築を5秒ブロックして、2つ目はScript-injectなのでDOM構築はブロックしないですが、読み込み(実行)にはさらに5秒かかります。
では(B)を見てみましょう。
(B)は1つ目も2つ目もほぼ同時の5秒で実行されました。
これはここまでの知識では説明できません。予想する動きは
DOMContentLoaded
イベントのタイミングを除いて(A)と同じになりそうですが・・・。実はこれがプリロード機能です。プリロード機能はDOM構築がブロックされていても、その先のHTMLを事前にパースして、近い将来必要となるリソースを探して読み込んでくれます。
そのため、(B)は1つ目のJavascriptのダウンロードを待っている間に、まだDOM未構築の部分にある2つ目のJavascriptファイルも同時にダウンロードをしてくれます。
そのおかげで2つ目はほぼ待ち時間なしで実行が可能となりました。
一方で(A)はというと、プリロード機能ではJavascriptのコードを解釈した上での読み込みはできないので、Script-injectされて読み込まれる2つ目のJavascriptは事前に読み込まれませんでした。
こういった最適化の恩恵を受けられないことがScript-injectの2つ目の問題です。
Javascriptのasync属性
ここまでの議論はJavascriptの同期実行(プリロードあり)とScript-injectによる非同期化(プリロードなし)でトレードオフが発生するという話でした。 しかし実は副作用なくJavascriptを非同期化できる方法があります。多くの皆さんはすでにご存知だとは思いますが async 属性です。
Javascriptコードを非同期実行して構わないのなら、最も効果的な方法です。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="css/style.css?d=3000" rel="stylesheet">
<script src="js/speedtest.js?d=5000" type="text/javascript" async></script>
<title>Improve Web Performance</title>
</head>
<body>
foo
</body>
</html>
画面表示を3秒にしてJavascriptのコード実行を8秒にするか、画面表示を5秒でJavascriptのコード実行も5秒にするかという究極の選択を迫られた課題に上記のようにasyncを付けてみます。
すると、なんということでしょう!※某番組風で
画面表示はCSSOMの構築が完了する3秒後に行われました。
Javascriptのコード実行も犠牲にならず5秒後に実行されました。
では、どのように解釈されたか見ていきましょう。
ブラウザは上から順にDOM構築をします。まずは
<link>
タグでCSSが読み込まれますが、CSSの読み込みはDOM構築をブロックしないので、そのままDOM構築が継続します。ただしCSSOMが構築されるまでの3秒間はレンダリングがブロックされます。次にasync付きの
<script>
タグが出てきます。asyncを付与されたJavascriptの読み込みはDOM構築をブロックしなくなるので、ここもDOM構築を継続できるようになります。つまりasync属性はScript-injectとほぼ同様の高速化の効果を得られます。さらにScript-injectの問題点だった読み込みのためのJavascriptコード実行が不要になり、プリロード機能の対象にもなるのでいいとこ取りができます。
残りはDOM構築をブロックする要素がないので、結果としてHTML文書の転送完了からほぼ即時でDOM構築が完了し、
DOMContentLoaded
イベントもすぐに発火します。与えられた条件においては最高のパフォーマンスと言えますが、CSSOM構築もCSS-injectを利用して非同期化すればさらに画面表示までの待ちは短縮されます。しかしそれにはFOUC問題も出てくるので、どこまでのレイアウト崩れを許容するかの判断が必要です。
まとめ
情報量が膨大になってしまったので、Javascript/CSSの読み込み方/指定方法によるパフォーマンスへの影響を改めてまとめます。
Javascript
インライン
- 外部リソースの読み込みが不要で遅延が最小限
- HTML文書のサイズを肥大化させる
- ブラウザのローカルキャッシュが効かず毎回転送が必要
<script>
タグ以降のDOM構築をブロックする- 直前までのCSSOM構築を待つ ※CSSファイルの転送も含めて待機する
<script>
タグ以降のCSSOM構築をブロックするDOMContentLoaded
イベントの発火がJavascriptコードの実行終了まで待たされる
- DOM/CSSOMのブロック待ちの間にプリロード機能で読み込み可能
<script>
タグ以降のDOM構築をブロックする- 直前までのCSSOM構築を待つ ※CSSファイルの転送も含めて待機する
<script>
タグ以降のCSSOM構築をブロックするDOMContentLoaded
イベントの発火がJavascriptコードの実行終了まで待たされる
- DOM/CSSOMのブロック待ちの間にプリロード機能で読み込み可能
<script>
タグ以降のDOM構築をブロックするが、以降にタグは無い<script>
タグ以降のCSSOM構築をブロックするが、以降にタグは無い- 直前までのCSSOM構築を待つ ※CSSファイルの転送も含めて待機する
DOMContentLoaded
イベントの発火がJavascriptコードの実行終了まで待たされる
- 条件に合わせて動的に読み込みURLを変更したり柔軟な読み込みが可能
- コードの実行が非同期になりDOM/CSSOM構築をブロックしない
- Script-injectのためにJavascriptコード実行が必要
- DOM/CSSOMのブロック待ちの間にプリロード機能で読み込み不可能
- コードの実行が非同期なのでDOM/CSSOMの構築状態が予測できない
- DOM/CSSOMのブロック待ちの間にプリロード機能で読み込み可能
- コードの実行が非同期になりDOM/CSSOM構築をブロックしない
- コードの実行が非同期なのでDOM/CSSOMの構築状態が予測できない
CSS
インライン
- 外部リソースの読み込みが不要で遅延が最小限
- HTML文書のサイズを肥大化させる
- ブラウザのローカルキャッシュが効かず毎回転送が必要
<link>
タグ以降のDOM構築をブロックしない- レンダリングをブロックする ※CSSOMの構築対象になり構築完了しないとレンダリングが待たされる
- 条件に合わせて動的に読み込みURLを変更したり柔軟な読み込みが可能
- レンダリングをブロックしない
- CSS-injectのためにJavascriptコード実行が必要
- CSSOM構築が非同期のため、CSSが未適用の画面が一時的に表示される(FOUC)
パフォーマンスの観点ではJavascriptコードを非同期で実行可能なようにコーディングした上でasync属性をつけるのがベストな選択です。ただ、システムやUI/UXの要件でパフォーマンスを多少犠牲にしてでも動作タイミングを保証したい場合も出てきます。
async属性だけですべて解決するなら本稿のような知識は必要ないのですが、現実はいまだにScript-injectが必要なシーンもあります。同期実行しなければいけないシーンもあります。
そういった場合に表面的な知識ではなく、動作原理から深い知識を持っていれば、Script-injectを利用する代わりにパフォーマンス面で何を失うのか、どうすれば影響を最小限にできるのか?などを総合的にトレードオフを判断して、ベターな選択ができるようになります。
ある程度の知識でもさほど問題なく開発できるのがウェブの良いところですが、開発者としてレベルアップするには低レイヤの動きを理解することがとても重要です。
本稿がその一助になれば幸いです。