Merpay Advent Calendar 2019 の2日目は @1000ch (id:hc0001) がお送りします。この記事は2019年11月16日に福岡で開催された Frontend Conference Fukuoka 2019 のセッション、HTML Optimization for Web Performance の書き起こし記事です。
なぜ HTML の最適化が重要なのか
先日公開された Chromium Blog: Moving towards a faster web をご覧になられたでしょうか。Google は Web ページのパフォーマンスの重要性を様々な形で啓蒙してきましたが、この記事では Chrome のもとになっている Chromium に、Web ページのパフォーマンスをブラウザ UI として表示する機能を追加し、ユーザーが Web ページを表示する際の体験を示唆するようになる、とあります。
この変更は、ユーザーと開発者の両方に大きな影響があり、賛否が分かれるところでしょう。何れにせよ、Google が Web ページのパフォーマンスを評価していく動きは、これからも強まっていくように思います。
とはいえ、Web ページのパフォーマンスが重要という話は今に始まったことではありません。基本的な概念や最適化手法についても、数年前に比べれば幅広く普及しているように思います。まずは、今回なぜ HTML という Web 技術の基本に立ち返ったのかお話します。
全てのサブリソースの起点になる
我々が毎日のように閲覧している Web ページは、アドレスバーに URL を入力したり、あるいはリンクをクリックしてナビゲーションが発生してからページが表示されるまでに、実に様々な処理が発生しています。
その中でも HTML は、ページロードにおいて最初にロードされるリソースであり、その他の CSS ファイルや JavaScript ファイル、画像ファイルといった様々なサブリソースをロードするよう記述されています。これらをロードする数やタイミングは HTML の記述に依存しており、ページロードのパフォーマンスを大きく左右します。
Web ページの性質に依存しない
昨今の Web ページは、静的なものから動的なものまで非常に多様です。しかし、どんなに単純あるいは複雑な Web ページでもコンテンツを構成する基本となるのは HTML であり、ページロードの仕組みと HTML の関係性、そしてその最適化戦略は、基本的には普遍と言えます。
(比較的)最適化を適用しやすい
Web ページのパフォーマンス最適化という言葉から、始めに何を思い浮かべるかは人それぞれかもしれません。React や Vue.js のような UI ライブラリを使っていれば、それらのランタイムに適した効率的な実装もあるでしょう。あるいは、Fastly のようなエッジコンピューティングサービスを利用して、エンドユーザーへのコンテンツ配信を最適化することも、今や珍しくありません。
もちろん HTML の改善も最適化のひとつですが、対象が HTML ということで他のものよりも実践しやすいのではないかと思います。
クリティカルレンダリングパスを理解する
先に述べたように、ナビゲーションが発生してからページが表示されるまでに、ブラウザは様々な処理を行っています。HTML から最終的にディスプレイに Web ページを描画するまでの処理にはステップがあり、処理が終わらないと次のステップに進むことができないものもいくつかあります。
HTML をロードする
アドレスバーに URL を入力したり、リンクをクリックすることでナビゲーションが発生すると、ブラウザは URL に対してリクエストし HTML をダウンロードします。HTML のダウンロードが完了すると、ブラウザは HTML を上から評価していき1、DOM (Document Object Model) と呼ばれるオブジェクトモデルに変換していきます。
HTML を完全にダウンロードするまでには、ブラウザからのリクエストがサーバに到達してから、その URL に応じた HTML をサーバのソフトウェアが用意しレスポンスするといった処理が含まれます。
静的な HTML ファイルのデータを返すだけであれば処理は少なそうですが、サーバーサイドレンダリングのような動的に HTML を生成している場合は、サーバのソフトウェア内部から更にリクエストを実行してデータを取得する、なども含みます。これらの処理が遅延すれば、HTML のロードは遅延します。また、いかにレスポンスが高速に行われても、ネットワーク帯域に遅延があれば、当然ですがダウンロードも遅くなるでしょう。
サブリソースをロードする
HTML を評価していく途中で、 <link>
要素や <script>
要素、<img>
要素といったようなサブリソースへの参照がある度にリクエストし、サブリソースのロードを繰り返します。中でも注目すべきは、CSS ファイルと JavaScript ファイルのロードです。これらは描画処理に与える影響が特に大きく、理解しておくべきです。
CSS は Web ページのスタイリングに必要なので、全ての CSS をロードするまでブラウザは描画処理を開始できません。ブラウザは <link rel=”stylesheet”>
で参照される CSS ファイルを非同期でダウンロードし、評価することで CSSOM (CSS Object Model) と呼ばれるオブジェクトモデルに変換していきます。
JavaScript は Web ページの初期描画に必要なリソースではないかもしれません。しかし JavaScript から DOM や CSSOM にアクセスしうるため、ブラウザは <script></script>
で参照される JavaScript ファイルを同期的にロードします。この間、ブラウザは HTML の評価等の処理を進めておくことができないため、JavaScript ファイルのロードの遅延は Web ページのロードに大きく影響することになります。
DOM と CSSOM を組み合わせてページを描画する
ブラウザは HTML と CSS の評価が完了すると、DOM と CSSOM を組み合わせてレンダーツリーと呼ばれるスタイル情報を持った DOM ノードのツリーを構築します。この情報を元に「それぞれの要素を、どのように配置し(レイアウト)、どのように描画する(ペイント)のか」を決定していきます。ブラウザは、このレンダーツリーが構築されるまでは描画処理を開始できません。DOM と CSSOM の構築をいかに早めるかが、クリティカルレンダリングパスを最適化するポイントのひとつです。
レンダーツリーが構築されると、レイアウト→ペイントの順に実行されます。例えばこの時、<img>
要素で参照する画像をダウンロードしている最中で、要素のサイズが決定されていない場合、ダウンロードに応じて <img>
要素の大きさが変化するため、レイアウトとペイントを繰り返すことになります。あるいは、何らかのトリガーで JavaScript が実行され DOM や CSSOM が変更されたとしても、同じようにレイアウトとペイントを再実行することになります。
パフォーマンス指標を理解する
次に、Web ページのパフォーマンスをどのように計測すれば良いのかを見ていきます。
ユーザー中心のパフォーマンス指標へ
load
イベントや DOMContentLoaded
イベントを利用したパフォーマンス計測は、今や適切とは言えません。これらはブラウザのライフサイクルイベントに過ぎず、実際のユーザー環境でどのようにページロードの進捗しているかを把握するには不十分です。
DOMContentLoaded
イベントは DOM の構築が完了すると発生しますが、CSSOM を含めたサブリソースのロード完了を待たずに発生するため、ページの描画状況との相関性は大きくありません。load
イベントはサブリソースのロードまで完了した時点で発生するため、ページの描画状況としては大半が完了しているでしょう。しかし、長大なコンテンツの場合にユーザーがページの最下部まで見る保証はなく、より重要であるファーストビューが高速に表示されたかはわかりません。
Speed Index
Speed Index はこれらの欠点を解決するために Google から提案された、ファーストビューが如何に早く表示されたかを表すパフォーマンス指標です。
同じコンテンツのページ A とページ B があったとして、どちらもロード開始から 12 秒時点でファーストビューの表示が 100% 完了するものとします。しかし、ページ A とページ B では経過時間に対するファーストビューの表示状況が異なり、ページ A はロード開始から 1 秒時点で 80% 表示されていて、かたやページ B はロード開始から 1 秒時点で 20% しか表示されていません。この時、ページ A の方がユーザー体験が良いのは明らかです。
この時ページ A とページ B の差は、ロード開始からの経過時間に対する描画進捗です。X 軸に経過時間、Y 軸に描画量をとると次のようなグラフになり、これを積分するとスコア化できます。この色付き部分を比較しても良いですが、仮に描画が 99% でずっと止まるようなケースでは色付き部分の面積が大きくなり続けてしまいます。なので、経過時間に対して描画されていない量(面積)を計算することで、スコアを有限にできます。
これが Speed Index です。ただ、お分かりの通り、概念が難解で計算が難しいという欠点があります。各種ツールを利用することで算出できますが、算出処理そのものにコストがかかったり、一元化されていないなどの事情もあるので、次以降に解説する指標を利用していくことをオススメします。
First Paint と First Contentful Paint
First Paint と First Contentful Paint は、いずれもブラウザがナビゲーションを行った後に、ページが如何に早く表示されはじめるかを評価する指標です。First Paint は何らかが表示されたタイミングを、First Contentful Paint はテキストや画像のような何らかのコンテンツが表示されたタイミングを指します。いずれもサブリソースのロード状況やクリティカルレンダリングパスの状態を示唆し、Web ページのパフォーマンスを評価するのに役立ちます。
Largest Contentful Paint
プロダクトや Web ページの性質に依存しにくく、かつユーザー体験にも影響する指標として標準化が進んでいるのが Largest Contentful Paint です。Largest Contentful Paint はファーストビューにおいて占める割合が大きいコンテンツが表示されるタイミングを指します。Web ページのコンテンツの何が重要であるかは、プロダクトの提供者とユーザーの両方に依存するため標準化が難しいですが、ユーザー体験により繋がるであろう指標として期待されています。
Long Tasks と Time to Interactive
Web ページが表示されているのに、ボタンをクリックしても無反応など、なかなかインタラクティブにならない…といった体験をしたことがある人も多いかと思います。レンダーツリーが構築されて描画が進んでいるものの、初期ロードで実行される JavaScript の処理がメインスレッドを長時間専有しているせいで、ユーザーアクションに対する応答処理を実行できていない状態であり、ユーザーにとっては大きなストレスです。このように、Web ページが完全にインタラクティブになるまでの時間を Time to Interactive として定義されています。以下の動画の 7:40 から、Time to Interactive が遅延する様子を確認できます。
Web Performance: Leveraging the Metrics that Most Affect User Experience (Google I/O ’17)
この指標を算出するためには、メインスレッドが idle 状態である必要があります。メインスレッドを長時間専有する処理は、ページロードにおいてもページ閲覧中においても問題ですが、50ミリ秒以上かかる処理を Long Tasks として定義し、それを検出する機能の標準化が進んでいます。現在 Time to Interactive は、GoogleChromeLabs/tti-polyfill というライブラリを使って算出できるようになっていますが、内部的にはこの Long Tasks を利用して検出しています。
Performance Observer
ここまでの標準化されているパフォーマンス指標は、Performance Observer を通じて取得できるようになっています。これを使って実際のユーザー環境でのパフォーマンスを計測し、何がパフォーマンス上の問題になっているかを把握するだけでなく、Google Analytics などのツールを使って集計することでプロダクトの KPI とパフォーマンスにどのような相関性があるかも計測できるでしょう2。
const observer = new PerformanceObserver(list => { const entries = list.getEntries(); // Largest Contentful Paint を Performance Metrics として送信する entries.filter(entry => entry.entryType === 'largest-contentful-paint').forEach(entry => { ga('send', 'event', { eventCategory: 'Performance Metrics', eventAction: entry.name, eventValue: Math.round(entry.startTime + entry.duration), nonInteraction: true }); }); // Long Task を Performance Metrics として送信する entries.filter(entry => entry.entryType === 'longtask').forEach(entry => { ga('send', 'event', { eventCategory: 'Performance Metrics', eventAction: 'longtask', eventValue: Math.round(entry.startTime + entry.duration), eventLabel: JSON.stringify(entry.attribution), }); }); }); // Largest Contentful Paint と Long Task を監視する observer.observe({ entryTypes: [ 'largest-contentful-paint', 'longtask' ] });
監査ツールを使ってパフォーマンスをチェックする
これらのパフォーマンス指標は Performance Observer やライブラリを通じて取得することも可能ですが、次に各種ツールを使うことで見やすくレポートティングしてくれたり、CI からの実行など継続的なモニタリングにも役立ちます。
Lighthouse
Lighthouse はオープンソースで開発されている Web ページの監査ツールです。パフォーマンスだけでなく、アクセシビリティやベストプラクティス、SEO、PWA などの項目に関するチェックもしてくれます。Chrome の DevTools にもバンドルされているので、使ったことがある人も多いでしょう。
Lighthouse は DevTools だけでなく、GoogleChrome/lighthouse をインストールすることでコマンドラインからも利用できる他、CI で継続的にチェックをするための GoogleChrome/lighthouse-ci というツールも開発されています。これを使うと、git のコミット単位でチェックを実行してくれるので「いつ、どの変更で、各種指標がどのように変化したか」を把握できます。
WebPageTest
WebPageTest もオープンソースで開発されている、パフォーマンスのテストツールです。公式のインスタンスは webpagetest.org にデプロイされているので、チェックしたい Web ページの URL を入力すれば、各種アクセス条件(回線速度、レイテンシ、アクセス元、ユーザーエージェント等)におけるパフォーマンステストを実行できます。
実行結果のサンプルを見てもらうとわかるように、DevTools の Network パネルでも表示されるようなウォーターフォールチャートなどをはじめとした詳細な解析結果を確認できます。また、Lighthouse のテストを WebPageTest から実行することも可能です。
WebPageTest は継続的にモニタリングするための機能を備えていませんが、SpeedCurve や Calibre を使うと、対象の URL に対して定期的にテストを実行できる他、各種パフォーマンス指標の推移を美しくビジュアライズしてくれます。
HTML を最適化する
計測できる準備が整ったところで、HTML の最適化を考えていきます。
全てのサブリソースを最小化する
前提として、全てのサブリソースは最小化(圧縮)されているべきです。これはロード元である HTML からはどうしようもないので、必ず実施してください。
CSS ファイルと JavaScript ファイルのロードを最適化する
まずは気をつけたいのが、クリティカルレンダリングパスに影響の大きい、CSS ファイルと JavaScript ファイルのロードです。
CSS ファイルは <link rel=”stylesheet”>
を使って参照すると思います。これは、できれば <head>
要素内で、少なくとも同期的に実行される <script>
要素よりは前に記述されているのが望ましいです。こうすることで、CSS ファイルのロードもとい CSSOM の構築を促せます。また、HTTP/2 で配信されている場合にはリクエスト数をさほど気にせずとも良いので、CSS ファイルを結合せず複数の <link rel=”stylesheet”>
でロードしてもパフォーマンスへの影響は大きくありません。
JavaScript ファイルのロードに関しては、そう単純ではありません。基本的には CSS ファイルや画像のロードを優先させてファーストビューにおける体験、つまり First Paint や First Contentful Paint、Largest Contentful Paint を向上させたいところです。しかし、Web ページのアーキテクチャによって JavaScript の果たす役割が大きく異なってきます。また <script>
要素には async
属性や defer
属性が存在し、Module Script をロードする type="module"
属性とも相まって、JavaScript ファイルのダウンロードと実行のタイミングを複雑化しています。
JavaScript ファイルのロードに関しては AddyOsmani.com – JavaScript Loading Priorities in Chrome にわかりやすくまとまっているので、こちらを参考にしてください。
サブリソースと Module Scripts をプリロードする
<link rel=”stylesheet”>
による CSS ファイルのロードや <script>
要素による JavaScript ファイルのロードだけでは表現しきれないサブリソースを優先度を指定したい時があります。例えば、CSS ファイル内で参照されている画像ファイルやフォントファイルが Web ページにとって重要であっても、その CSS ファイルがロードされるまではブラウザはそれらに対してリクエストすることができません。また、少しずつサポートと利用が広がってきている Module Script も、ファイル参照が深くなるとパフォーマンスが低下するという課題もあります3。
こうした背景を踏まえて、サブリソースや Module Script を優先的にロードするための Preload という仕様があります。これは <link rel=”preload”>
や <link rel=”modulepreload”>
を使って優先的にロードしたいリソースを指定しておくことで、ブラウザが優先的にリクエストしてくれるようになります。
<!-- CSS ファイルから参照されているフォントファイルをプリロードする --> <link rel="preload" href="font.woff2"> <!-- Module Script をプリロードする --> <link rel="modulepreload" href="component.js">
loading
属性を使って画像や iframe を遅延ロードする
ファーストビューに入っていない画像でも何もしなければブラウザはロードするので、他のサブリソースのロードが妨げられたり、そこまでスクロールして見るかどうかわからない画像までロードしてしまう、といった状況を防ぐために遅延ロードは以前から実践されてきました。しかし、遅延ロードを実現するためには、scroll
イベントを監視して、対象の <img>
要素が画面内に入ったかどうか判定して、画面内に入ったらリクエストするという処理を JavaScript で書く必要がありました。
しかし、この loading
属性の登場によって、<img>
要素に loading=lazy
を付与するだけで、その要素が画面内に入ったタイミングでロードしてくれるようになります。<img>
要素だけでなく、<iframe>
要素に対しても適用できるので、優先度が低く遅延ロードさせても良いコンテンツに対しては、積極的に使うようにしましょう。
Priority Hints でリソースの優先度を示唆する
Priority Hints は、リソースを参照する HTML 要素に対して importance
属性を付与することで、そのリソースの重要度を示唆する仕様です。これが指定されていると、ブラウザがどのリソースを優先的にロードすればいいのかがわかり、ページを構築する上での手がかりになります。
この importance
属性は、high
・low
・auto
を値に取り、何も指定しない場合は auto
になります。例えば、ファーストビューに入っている画像だが、画面に占める割合が非常に小さいので優先度を下げたい場合は、importance=”low”
をつけても良さそうです。
<!-- ファーストビューに入っている画像だけど、重要度が高くない --> <img src="in_viewport_but_not_important.svg" importance="low" alt="...">
Resouce Hints でリソースを投機的にロードする
Resource Hints は、<link>
要素を使って、ブラウザに対して次に必要となるリソースを示唆する仕様です。ブラウザはこの情報を利用して、示唆されたリソースを投機的にロードするようになります。先程説明したプリロードとよく似ていますが、こちらは投機的にロードし、現在実行されている他の処理を邪魔しないのが特徴です。
Resource Hints で定義されているのは以下の4つです。
<!-- DNS Prefetch: 指定したドメインの DNS 解決を投機的に行う --> <link rel="dns-prefetch" href="//example.com"> <!-- Preconnect: 指定したドメインへの TCP 接続を投機的に行う --> <link rel="preconnect" href="//example.com"> <!-- Prefetch: 指定したリソースのロードを投機的に行う --> <link rel="prefetch" href="lib.js" as="script"> <!-- Prerender: 指定した Web ページの描画を投機的に行う --> <link rel="prerender" href="next-page.html">
Resource Hints を適用すべきかどうかの判断は、現在のページでは必要ないが、次以降のアクションで必要になるリソースかどうかが、一つの判断軸です。ページ内の外部ドメインへのリンクがある場合、そのドメインへの Preconnect を実施しておけばリンクをクリックして発生するナビゲーションの時間を短縮できます。また、ページの遷移先が決定している場合(例えばログインページなど)は、次のページで必要になるリソースがわかっていますし、投機的にロードしておいてもそれが無駄になることが少ないので、有効でしょう。
まとめ
いかがでしたでしょうか。新しいものばかりではないので、目新しさを感じなかったという人もいるかも知れませんが、これを機会に HTML という視点から Web パフォーマンスを考えてみてください。
以上、Merpay Advent Calendar 2019 2日目の記事でした。明日は Merpay iOS Team の @masamichi さんです🙌
-
いくつかのモダンブラウザの HTML 構文解析器には、Preload Scanner と呼ばれる HTML から参照されているサブリソースを先読みするための機能が備わっていますが、ここでは割愛します。↩
-
パフォーマンスとユーザー体験の相関については、前職時代にWebパフォーマンスとプロダクトKPIの相関を可視化する話としてまとめています。↩
-
モジュールの数と深さがローディングにどのように影響するかについては、Chrome チームによる調査結果が公開されています。↩