RACCOON TECH BLOG

株式会社ラクーンホールディングスのエンジニア/デザイナーから技術情報をはじめ、世の中のためになることや社内のことなどを発信してます。

フロントエンドのパフォーマンスを徹底解説!ブラウザの気持ちで理解するHTML/Javascript/CSSの話

こんにちは、羽山です。
今回はウェブシステムにおけるフロントエンドのパフォーマンスの話です。

皆様はウェブのパフォーマンスを気にしていますか?
おそらく大抵の方はSQLのチューニングやロジックの改良などをした経験があるのではないでしょうか?
しかしプログラムをチューニングしても、期待ほどはページ表示速度が改善しなかったことはありませんか?

昨今のウェブサイトは大量の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>

HTML本文はfooのみで、インラインのJavascriptと外部CSSの読み込みがあります。
インラインのJavascriptは実行されてから3秒後に本文をbarに変更する、というものです。
style.cssの中身は以下で、body全体のフォントを赤色にするだけのものです。

/* style.css */
body {
  color: red;
}

ここまでは何の変哲もないHTMLとCSS/Javascriptですが、今回は転送速度に細工をしています。
CSSのファイルに付いているd=5000というパラメータで遅延時間を制御していて、CSSの転送に5秒かかるようにしています。

さて、リンクをクリックしてこのページを開いたとします。
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の構築は完全に完了するまでブラウザは画面表示を行わずに待つので、「レンダリングをブロック」します。
一方でDOM構築は構築済みの情報が逐次画面に表示されるため、本稿定義の「レンダリングをブロック」には該当しません。
 

シンプルなHTMLのみのパフォーマンス

まず最初はフロントエンドのパフォーマンスという趣旨からは少し外れてしまいますが、外部リソースの影響を受けないHTML単体でのパフォーマンスを考えてみます。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Improve Web Performance</title>
</head>
<body>
  foo
</body>
</html>

このHTMLは外部リソースの参照がないので、以下のようなシンプルなステップで画面表示されます。

  1. (HTMLを動的に生成している場合は) ウェブシステムがHTMLを生成
  2. ウェブサーバがブラウザにHTMLを転送
  3. ブラウザがHTMLをパースしてDOM構築
  4. ブラウザが画面表示

※各ステップはシリアルに実行されるのではなく、前のステップの実行中でも次のステップは実行されます。

DOM構築をブロックする要素がないので最高のパフォーマンスでレンダリングされます。
この例ではチューニング可能なポイントはさほどありませんが、もしHTMLが動的に生成されるシステムの場合は以下の3点があります。

  1. ステップ(1)のHTML生成速度を改善する
  2. HTMLの生成方法を工夫して逐次転送できるようにする
  3. 遅い処理を非同期化して読み込む

a.は当たり前すぎるので解説を省きますが、b. c.を補足します。
b. c.はシステムの都合でHTML生成の高速化が難しい場合のアプローチとして検討することができます。

DOM構築はレンダリングをブロックしないので転送されたデータを逐次表示できます。b.は逐次転送することでファーストビューだけでも表示し、体感的な高速化を狙ったものです。HTML全体の生成が多少遅くてもUXへの影響は最小化されます。

c.は重い処理部分をHTML生成から切り出すことで高速化し、重い部分はJavascriptの非同期読み込みにすることで、こちらも体感的な高速化の効果をもたらします。

b. c.ともに当てはまることですが、<head>を先に転送することができれば、<head>内で読み込まれている外部リソースをいち早く読み込むことができるので外部リソースの読み込みが多いHTMLではさらに有利になります。
この方法はHTTP/2のサーバープッシュに近い効果を期待できます。さらにHTTP/2のサーバープッシュはローカルキャッシュの有無に関わらず転送してしまうことを考えると、逐次転送された<head>タグでの読み込みはローカルキャッシュの有無を判断した上で転送開始できる点でより効率的とみることもできます。

 

外部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>

speedtest.js?d=5000d=5000なので転送に5秒かかります。

Javascript(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秒間は真っ白な画面になり、その後fooと表示されます。スクリプトが実行される時間も同じく約5秒後です。
これは正解した方が多いのではないでしょうか?

以下は、画面表示とファイルの転送状況を表した図です。
js_performance_image1
(※HTMLの転送は数十msで終了するので便宜上0秒として扱います)

ブラウザがHTMLをパースしていてJavascriptが現れると、その直前までのDOM(Document Object Model)を構築完了した上で、DOM構築をブロックして該当のJavascriptを実行します。

しかしこのJavascriptは外部ファイルになっていて転送に5秒かかるので、<head>内でDOM構築がブロックされたまま5秒間待たされてしまいます。
<script>タグの後にくる<body>はHTMLとしては転送済みですがDOM構築されていないので画面にfooは表示されません。

結果としてJavascriptファイルの転送遅延が、その後のすべての処理も遅延させてしまいます。
「Javascriptファイルの転送がそんなに遅延することある?」と疑問を持つ方もいると思いますが、確かに一般的にはそれほど遅延は大きくありません。
しかし以下のケースではJavascriptの外部ファイルでも顕著な遅延が起こりえます。

  1. 海外からのアクセスやモバイル回線などでクライアントの回線状況が悪い
  2. 大量のJavascriptファイルを読み込んでいる
  3. Javascriptコードを動的に生成する仕組みを利用している
  4. 別ドメインかつhttpsプロトコルで転送する

1. 2.の説明は省略します。
3.は「指定のスクリプトタグを貼り付けるだけでOK」系の便利サービスを導入した場合などに見かけます。
例えばA/Bテストツールの場合はあえて<head>直後に動的なJavascriptコードを入れることでDOM構築を冒頭でブロックして、意図しない画面が表示されることを防いでいるものがあります。しかしパフォーマンスの視点では最悪な選択です。外部サービスの便利さとパフォーマンスのトレードオフを冷静に判断する必要があります。

4.はTCP接続及びSSL/TLSのハンドシェイク問題です。
HTMLと同一ドメインにJavascriptファイルが配置されている場合は、すでに温まったTCP接続で高速にデータ転送が可能です。しかし別ドメインに配置されたリソースは以下の点で不利です。

  • 新たなTCP接続が必要
  • TCP接続したばかりはSlow Startの影響で低速
  • SSL/TLSのハンドシェイクが必要
外部ファイルを同一ドメインに配置するか、別ドメインに分離するかは議論の余地があります。
モダンブラウザはHTTP/1.1の場合、ドメインごとに6本程度のTCP接続をして6並列でデータ転送します。しかし外部ファイルが多いと6並列では足りません。そういったケースではTCPの新規接続オーバーヘッドを許容しても別ドメインにしたほうが並列数を稼げてスループットは上昇します。
一方でHTTP/2では1本のTCP接続で同時に複数のデータ転送を行えるので、並列数を稼ぐためにドメインを分離する必要性はなくなります。

 

外部Javascriptの読み込みを</body>閉じタグの直前に配置する場合のパフォーマンス

<script>タグでDOM構築をブロックしてしまうなら、<script></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>

上記のHTMLを開くと想定通り画面にfooが即時に表示され、Javascriptは約5秒後に実行されます。
狙い通りパフォーマンスは改善できたようです。

js_performance_image2
現在はあまり言われませんが、昔はJavascriptを</body>の直前に置けと言われたのはこれが理由です。
Javascriptファイルの転送をどうしても高速化できないなら、せめて遅延の影響範囲を小さくするという考え方です。

しかし次項で出てきますが、この方法ではDOMContentLoadedイベントの発火は高速化されず、全ての読み込みが完了するまで待ってしまうというデメリットが残っています。

では、次の方法を試してみましょう。

Script-injectによるJavascriptの高速化

Script-injectという名称では聞き慣れない方もいるかもしれませんが、<script>タグを動的に生成するという古来からよく利用されている方法による高速化を試してみます。

<!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ファイルを読み込むようにします。読み込むJavascriptファイルの転送にかかる時間は前回同様5秒です。

この場合はfooが即時で表示されてJavascriptのコードは約5秒後に実行されます。与えられた状況下では最も理想的なパフォーマンスと言えます。

js_performance_image3

インラインの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の話です。

外部CSSファイルを含む場合のパフォーマンス

転送に5秒かかる外部CSSファイルを1つだけ含むHTMLを用意しました。

<!DOCTYPE html>
<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>

CSSは冒頭と同様にbody全体のフォントを赤にするだけのものです。

/* style.css */
body {
  color: red;
}

ではまた考えてみましょう。
fooと表示されるのは約何秒後でしょうか、そしてCSSに指定された赤文字になるのは何秒後でしょうか?

正解は・・・・。

共に約5秒後です。
約5秒間は真っ白な画面のままで、その後に赤色でfooと表示されます。

CSSファイルが読み込まれるまでの5秒間は黒色で表示されると考えた方もいるのではないでしょうか?
そうならない理由はCSSOMとレンダリングブロックの関係にあります。

js_performance_image4

ブラウザはHTMLからDOMを構築するように、CSSからはCSSOM(CSS Object Model)を構築します。
そしてDOMとCSSOMを利用して実際の画面をレンダリングします。

DOMの場合は構築途中でも途中までの情報を画面に逐次レンダリングしますが、CSSOMは<head>内で読み込まれたCSSからCSSOMを完全に構築完了するまでは画面を表示しません。
そのためCSSOMが構築される5秒間は真っ白な画面になったのです。
つまりCSSOM構築はレンダリングをブロックします。

一方で外部CSSファイルの読み込みはDOM構築をブロックしないので、<link>タグによるCSS読み込みがあってもそのまま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秒後に赤色に変化します。

js_performance_image5
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を含む場合のパフォーマンス

次は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秒後に表示されるタイミングですでに適用されています。

js_performance_image6
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を利用して非同期にしてみたらどうなるでしょう?
前項のHTMLを元に、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を利用したのに、そぐわない結果になってしまいました。
その理由にはCSSOMとJavascriptによるブロックが大きく関わっています。

js_performance_image7
前項との違いはJavascriptの読み込みをScript-injectにした点ですが何が悪かったのでしょうか?

実はJavascriptのコード実行には直前までのDOMとCSSOMの構築が完了している必要があります。
DOMは<script>タグに到達する時点で自動的に構築完了していますがCSSOMはそうではありません。外部CSSファイルの読み込みはDOM構築をブロックしないので、<script>タグを実行するタイミングでも外部CSSファイルの読み込みを待っている状態があり得ます。

DOMだけでなくCSSOMも構築完了している必要がある理由はJavascriptがCSSOMを取得/変更する機能を持っていることが原因です。そのためブラウザはコード内でCSSOMを利用してかるかどうかに関わらず直前までのCSSOMを構築完了させる必要があるのです。

ブラウザにとってはJavascriptコードは実行してみるまで何をするか分からないことが、効果的なチューニングを難しくしています。将来的には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ファイルを読み込んでいますが、実体は同一ファイルなので意味のないパラメータ(no=1,2)を付与することでブラウザに別々のファイルと認識させています。

(A)は通常読み込みの後にもう1ファイルをScript-injectで読み込み、(B)は両方とも通常読み込みをしています。

まずは(A)の実行速度です。
js_performance_image8
(A)は1つ目のJavascriptの実行時間が5秒で、2つ目が10秒になります。

これは、ここまでの知識を元にすれば自然と理解できると思います。1つ目がDOM構築を5秒ブロックして、2つ目はScript-injectなのでDOM構築はブロックしないですが、読み込み(実行)にはさらに5秒かかります。

では(B)を見てみましょう。

js_performance_image9
(B)は1つ目も2つ目もほぼ同時の5秒で実行されました。
これはここまでの知識では説明できません。(A)とほぼ同じ動きをしつつDOMContentLoadedイベントは約10秒後になると予想した方が多いのではないでしょうか?

実はこれがプリロード機能です。プリロード機能はDOM構築がブロックされていても、その先のHTMLを事前にパースして、近い将来必要となるリソースを探して読み込んでくれます。
そのため、(B)は1つ目のJavascriptのダウンロードを待っている間に、まだDOM未構築の部分にある2つ目のJavascriptファイルも事前にダウンロードをしてくれたのです。

そのおかげで2つ目はほぼ待ち時間なしで実行が可能となりました。

一方で(A)はというと、プリロード機能ではJavascriptのコードを解釈した上での読み込みはできないので、Script-injectされて読み込まれる2つ目のJavascriptは事前に読み込まれず、5秒待ってから読み込み開始となりました。

こういった最適化の恩恵を受けられないことが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を付けてみます。
js_performance_image10

すると、なんということでしょう!※某番組風で
画面表示は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もしくはmedia="print"などで非同期化すれば画面表示までの待ちは短縮されます。これはFOUC問題とのトレードオフを検討して、どこまでのレイアウト崩れを許容するかの判断をしながら必要に応じて実行することができます。

まとめ

情報量が膨大になってしまったので、Javascript/CSSの読み込み方/指定方法によるパフォーマンスへの影響を改めてまとめます。

Javascript

インライン

  • ? 外部リソースの読み込みが不要で遅延が最小限
  • ? HTML文書のサイズを肥大化させる
  • ? ブラウザのローカルキャッシュが効かず毎回転送が必要
  • ? <script>タグ以降のDOM構築をブロックする
  • ? 直前までのCSSOM構築を待つ ※CSSファイルの転送も含めて待機する
  • ? <script>タグ以降のCSSOM構築をブロックする
  • ? DOMContentLoadedイベントの発火がJavascriptコードの実行終了まで待たされる

外部ファイル(async属性なし)

  • ? DOM/CSSOMのブロック待ちの間にプリロード機能で読み込み可能
  • ? <script>タグ以降のDOM構築をブロックする
  • ? 直前までのCSSOM構築を待つ ※CSSファイルの転送も含めて待機する
  • ? <script>タグ以降のCSSOM構築をブロックする
  • ? DOMContentLoadedイベントの発火がJavascriptコードの実行終了まで待たされる

外部ファイル(</body>直前に配置、async属性なし)

  • ? DOM/CSSOMのブロック待ちの間にプリロード機能で読み込み可能
  • ? <script>タグ以降のDOM構築をブロックするが、以降にタグは無い
  • ? <script>タグ以降のCSSOM構築をブロックするが、以降にタグは無い
  • ? 直前までのCSSOM構築を待つ ※CSSファイルの転送も含めて待機する
  • ? DOMContentLoadedイベントの発火がJavascriptコードの実行終了まで待たされる

外部ファイル(Script-inject)

  • ? 条件に合わせて動的に読み込みURLを変更したり柔軟な読み込みが可能
  • ? コードの実行が非同期になりDOM/CSSOM構築をブロックしない
  • ? Script-injectのためにJavascriptコード実行が必要
  • ? DOM/CSSOMのブロック待ちの間にプリロード機能で読み込み不可能
  • ? コードの実行が非同期なのでDOM/CSSOMの構築状態が予測できない

外部ファイル(async属性あり)

  • ? DOM/CSSOMのブロック待ちの間にプリロード機能で読み込み可能
  • ? コードの実行が非同期になりDOM/CSSOM構築をブロックしない
  • ? コードの実行が非同期なのでDOM/CSSOMの構築状態が予測できない

CSS

インライン

  • ? 外部リソースの読み込みが不要で遅延が最小限
  • ? HTML文書のサイズを肥大化させる
  • ? ブラウザのローカルキャッシュが効かず毎回転送が必要

外部ファイル(通常)

  • ? <link>タグ以降のDOM構築をブロックしない
  • ? レンダリングをブロックする ※CSSOMの構築対象になり構築完了しないとレンダリングが待たされる

外部ファイル(CSS-inject)

  • ? 条件に合わせて動的に読み込みURLを変更したり柔軟な読み込みが可能
  • ? レンダリングをブロックしない
  • ? CSS-injectのためにJavascriptコード実行が必要
  • ? CSSOM構築が非同期のため、CSSが未適用の画面が一時的に表示される(FOUC)

パフォーマンスの観点ではJavascriptコードを非同期で実行可能なようにコーディングした上でasync属性をつけるのがベストな選択です。ただ、システムやUI/UXの要件でパフォーマンスを多少犠牲にしてでも動作タイミングを保証したい場合も出てきます。

async属性だけですべて解決するなら本稿のような知識は必要ないのですが、現実はいまだにScript-injectが必要なシーンもあります。同期実行しなければいけないシーンもあります。
そういった場合に表面的な知識ではなく、動作原理から深い知識を持っていれば、Script-injectを利用する代わりにパフォーマンス面で何を失うのか、どうすれば影響を最小限にできるのか?などを総合的にトレードオフを判断して、ベターな選択ができるようになります。

ある程度の知識でもさほど問題なく開発できるのがウェブの良いところですが、開発者としてレベルアップするには低レイヤの動きを理解することがとても重要です。
本稿がその一助になれば幸いです。

【続編】rel=”preload”を極めるために必要な2種類のプリロード機能

一緒にラクーンのサービスを作りませんか? 採用情報を詳しく見る

関連記事

運営会社:株式会社ラクーンホールディングス(c)2000 RACCOON HOLDINGS, Inc