読書メモ:Webフロントエンド ハイパフォーマンス チューニング

読書メモ:Webフロントエンド ハイパフォーマンス チューニング

積読状態にあった本。読書メモ。

Web サイト、Web アプリケーションをより高速にチューニングするための解説書です。リッチな Web サイト、Web アプリケーションの増加はとどまるところを知らず、これらの高速化の需要はますます高まってきています。本書では高速化という課題に対し、きちんと対処できる知識と実力を身に付けます。基礎となるブラウザのレンダリングから、個別の問題に対する対応例、今後を見据えた設計の基礎などその場しのぎではない本質的な高速化を学びます。


ブラウザ内のコンポーネント #

ブラウザは、通常いくつかのソフトウェアコンポーネントによって構成されています。チューニングするために知っておきたい2つの重要なコンポーネントが以下です。

  • レンダリングエンジン:ブラウザの内部で利用される HTML の描画エンジンを指します。純粋にウェブページのレンダリングのみを担当するソフトウェアコンポーネントです。レンダリングエンジンは、HTML や画像ファイルや CSS や JavaScript などの各種リソースを読み取って、それを画面上の実際のピクセルとして描画します。
  • JavaScript エンジン:その名の通り JavaScript の実行環境を提供するソフトウェアコンポーネントです。ブラウザ内部で利用されます。

どちらもブラウザそのものではないので、別の名前を持ちます。

ブラウザ レンダリングエンジン JavaScript エンジン
Google Chrome Blink V8
Safari WebKit Nitro
Mozilla Firefox Gecko SpiderMonkey

コラム:WebView(ウェブビュー) #

WebView とは、ウェブページを表示できるソフトウェアコンポーネントです。主にモバイルアプリ上で、ウェブページをアプリ内で表示するビューとして提供されています。アプリ開発者は WebView を呼び出すことで、自身の開発しているアプリ内にレンダリングエンジンを簡単に組み込めます。オンラインコンテンツをアプリ内で表示する、あるいはアプリ内のコンテンツを再インストール(アップデート)することなしに動的に更新できるようにするために用いられます。

アプリの一部として WebView を組み込み利用することもできます。ハイブリッドアプリ開発環境の Cordova では WebView をアプリのランタイム内に組み込んでアプリ全体の開発にウェブ技術を用いることを可能としています。

ブラウザのレンダリングの仕組み #

ブラウザのアドレスバーに URL を打ち込んで、ウェブページが表示されるまでにどういったプロセスをたどって処理が進んでいくのかを追いつつ、理解を深めていきましょう。レンダリングエンジンは複数のプロセスを経てウェブページを描画しています。大きく4工程に分けることができます。

-------------------------------
Loading   | 1. Download
          | 2. Parse
-------------------------------
Scripting | 3. Scripting
-------------------------------
Rendering | 4. Calculate Style
          | 5. Layout
-------------------------------
Painting  | 6. Paint
          | 7. Rasterize
          | 8. Composite Layers
-------------------------------

Loading - リソース読み込み #

ブラウザは、与えられた URL から HTML を読み込んで、そこからさらにレンダリングに必要な付属するリソースを読み込んで解釈していきます。

1.Download

リソースのダウンロードでは、ウェブページ(HTML)自身を含むリソースをサーバからダウンロードします。

一番最初に取得されるリソースは HTML ファイルです。HTML ファイル内に記述されているリソースの参照があれば、さらにまた、そのリソースを読み込みます。そのリソース内に別の参照があればまた再帰的に読み込んでいきます。

2.Parse

リソースのパースでは、ダウンロードしたリソースをパース(構文解析)して、レンダリングエンジンの内部表現に変換します。たとえば、ダウンロードされた HTML や CSS は、それぞれ DOM(Document Object Model)ツリーや CSSOM(CSS Object Model)ツリーに変換されます。ここでのブラウザがあつかうリソースとは、以下のものを指します。内部表現に変換されたリソースは、後続のフェーズである Rendering や Painting のために利用されます。

  • HTML ファイル
  • CSS ファイル
  • JavaScript ファイル
  • JPEG, PNG, GIF SVG などの画像ファイル

HTML レスポンスとして返される HTML は、以下の工程を経て DOM ツリーへと変換されます。

  1. 字句解析によるトークンのリスト化
  2. 構文解析による構文木の構築
  3. 構文木内にある JavaScript を実行しつつ DOM ツリーの構築

レンダリングエンジンは、HTML を受け取りつつ、字句解析によるトークン化を行います。トークンとは、意味的に1つの塊になっている文字列です。HTML をトークンの列に分解したあと、構文木を構築していきます。構文木とは、トークンの列を解析することで生まれる木構造のデータです。HTML パーサーは構文木内に含まれている JavaScript を同期的に実行していきます。

ここでパフォーマンスの観点で厄介な点があります。このとき構築される DOM ツリーが script 要素内の JavaScript の内容に依存し、スクリプトの実行に DOM ツリーの構築がブロックされうる点です。以下のような HTML を想像してください。

<body>
  <p>
    <script>
      document.write("Hello World!");
    </script>
  </p>
</body>

この HTML 中で利用されている document.write() はドキュメントに対して文字列を書き出すメソッドです。これにより DOM ツリーは、JavaScript の実行結果に依存する形になっています。これにより DOM ツリーは、JavaScript の実行結果に依存する形になっています。つまり、HTML を DOM ツリーに変換する過程の中で JavaScript を実行する必要があるということです。

HTML パーサーは、トークンの列に分解して構文木を構築しながら JavaScript を実行して、その結果、もし document.write() で書き出された文字列があった場合には同期的にトークンの列に追加してきます。HTML 解析していく中で、DOM ツリーが構築されていきます。DOM ツリーを構築する過程の中で、ブラウザは DOM ツリー内に宣言されているリソースをさらに取得し読み込みを進めていきます。このとき取得されるリソースは、画像ファイルは img 要素、CSS ファイルは link 要素、JavaScript ファイルは script 要素で宣言します。

Scripting - JavaScript 実行 #

レンダリングエンジンは、JavaScript のコードを JavaScript エンジンに引き渡して実行させます。どのような内部処理で実行されるかは、ブラウザに搭載されている JavaScript エンジンの実装によって異なりますが、一般的に JavaScript エンジン内では、与えられた JavaScript のコードを次の過程を通じて実行します。

  1. 字句解析:JavaScript コードをトークン列に変換する
  2. 構文解析:トークン列を抽象構文木に変換する
  3. コンパイル:抽象構文木を実行可能コードに変換する
  4. 実行:実行可能コードを実行する

JavaScript 内では、DOM API を通じて DOM ツリーを操作できます。DOM ツリーを操作すると、後続する Rendering フェーズや Painting フェーズを引き起こすことがあります。また、いわゆる Ajax を利用して外部リソースを取得すると Loading フェーズを再度引き起こします。

Rendering - レイアウトツリー構築 #

この中ではスタイルの計算(Calculate Style)とレイアウト(Layout)という2つの処理が行われます。

1.Calculate Style

ドキュメントの DOM ツリー内のすべての DOM 要素に対して、どのような CSS プロパティが当たるのかを計算します。CSSOM ツリー内をすべて参照して、CSS ルールの CSS セレクタのマッチング処理がこのとき行われます。すべての DOM 要素に対して、CSS ルールの CSS セレクタがマッチするかを総当りで試行します。DOM 要素にどの CSS ルールが当たるのかを計算したあと、CSS ルールの詳細度を算出して個別の DOM 要素に対して、どのような CSS プロパティが適用されるかを判断します。CSS ルールセットの詳細度計算が終わると、DOM 要素に対して適用される CSS プロパティが算出されます。

2.Layout

DOM ツリー内すべての DOM 要素に当たる CSS プロパティを算出したあと、レンダリングエンジンは DOM ツリー内のすべてのノードの視覚的なレイアウト情報の計算を行います。レイアウト情報とは、具体的には次のようなものを指します。

  • 要素の大きさ
  • 要素のマージン
  • 要素のパディング
  • 要素の位置
  • 要素の z 軸の位置

Painting - レンダリング結果の描画 #

このフェーズでようやくレンダリングエンジンはユーザーが見ることができる実際のピクセルを描画します。ここでは、ペイントとラスタライズとレイヤーの合成という3つの処理が行われます。最後のレイヤーの合成が終わることで、ユーザーの目にはレンダリングエンジンが描画した画面が表示されます。

1.Paint

内部の低レベルな 2D グラフィックエンジン向けの命令を生成します。組み込まれるグラフィックエンジンは、ブラウザの実装ごとに異なります。WebKit レンダリングエンジンでは、プラットフォームやブラウザの実装によって異なるグラフィックエンジンを組み込めるように設計されています。Blink レンダリングエンジンでは、この内部のグラフィックエンジンに Skia が組み込まれています。Skia は Google によって管理されているオープンソースの 2D グラフィックライブラリです。Safari では、CoreGraphics という macOS の提供する API が内部のグラフィックエンジンとして組み込まれています。

2.Rasterize

生成された命令を用いて実際にピクセルへと描画します。このとき、レイヤーという単位で一枚一枚描画されます。

レイヤーは、オーバーラップして表示されるコンテンツがある場合に生成されます。このレイヤーは z 軸の上下関係をもちます。複数のレヤーが後で一枚の絵に合成されるとき、この z 軸の上下関係が考慮されて合成されます。このレイヤーが生成されるのは、ある要素が次のような条件にある場合です。

  • 要素が position: absolute なスタイルプロパティが適用されている
  • 要素が position: fixed なスタイルプロパティが適用されている
  • 要素が transform: translate3d(0px, 0px, 0px) などの GPU で描画・合成される CSS プロパティを持っている
  • 要素に opacity CSS プロパティが適用されており、透過して背後のコンテンツが表示される必要がある

このようなレイヤーという単位で一度実際のピクセルに描画するのは、再レンダリングする場合、すでに描画が終わったレイヤーを再利用することで、素早く再レンダリングできる場合があるからです。たとえば、ブラウザのウィンドウをスクロールするときには、スクロール分だけコンテンツの表示される位置を移動して描画を更新する必要があります。しかしこのとき更新しなければならないのはコンテンツの表示の位置だけです。コンテンツの描画そのものは更新する必要はないため、以前描画したレイヤーがこのとき再利用されます。

3.Composite Layers

ピクセルにしたレイヤーを合成して最終的なレンダリング結果を生成します。

チューニングの基礎 #

「早すぎる最適化は諸悪の根源である」 ー ドナルド・クヌース

チューニングテクニックの適用は、多くの場合、トレードオフを含みます。パフォーマンスが改善される一方で、失われるものがあることを意識する必要があります。それは開発者の時間的リソースであったり、コードの単純さ(可読性、保守性、拡張性)です。

「推測するな、計測せよ」 ー UNIX 格言

効果的なチューニングのためには、まずは計測し、どうすることが最も効果的なのか検討していく必要があります。

目標すべき指標を設定する - RAIL #

RAIL は Google 社の開発者である Ilya Grigorik 氏が提唱したパフォーマンスモデルです。RAIL では、これらの4つの項目に対して、それぞれ守るべき基準時間を設けています。

項目 基準時間
Response(応答) 100 ms
Animation(アニメーション) 16 ms
Idle(アイドル処理) 50 ms
Load(読み込み) 1000 ms

Response: 100 ms

Response は、ユーザーの何らかのアクションに対してウェブページがユーザーインターフェイス上の変化を引き起こして、応答するまでの時間を指します。この時間を RAIL では 100 ミリ秒内に抑えるべきとしています。ここでは、応答とはユーザーにとって感じることのできる視覚的な変化を指します。

たとえば、ユーザーがボタンを押し、JavaScript が実行されてダイアログが表示されるようなインタラクションの場合、この Response の処理時間とはユーザーがボタンをクリックしてからダイアログが表示されるまでの時間となります。

ユーザーからの入力に対して 100 ミリ秒以下で応答すると即座に応答しているように見え、この応答の時間が 100 ミリ秒から 300 ミリ秒程度までかかると、ユーザーはかすかに知覚できる遅延を感じるようになります。

もし 100 ミリ秒以下で収まらない処理を行いたい場合には、処理中を意味するインジケータをまず表示することでユーザーに対して応答をしてから処理を行うことが求められます。

ただし、これには例外があります。指のタッチの動きやスクロールという入力に対しては、次に説明する Animation と同様に 16 ミリ秒以下に抑えるべき、としています。通常のアクションとは違って、タッチの動きやスクロールなどのアクションのイベントは 100 ミリ秒以下の高い頻度で起こることが多いため、これらのアクションが起こるたびに 100 ミリ秒がかかっているとユーザーにとっては非常にストレスフルだからです。

Animation: 16 ms

Animation は、アニメーション中に連続して行われるフレームの中で1フレームの処理の時間の目安を指します。

フレームとは、Scripting、Rendering、Painting という描画の処理が始まって完了するまでの工程を指します。アニメーション処理では、何度も連続して再描画をすることでアニメーションの動きを再現するので、アニメーション全体の処理のなかでフレームをいくつも繰り返すことになります。

RAIL ではこのフレームの処理時間を 16 ミリ秒以内に抑えるべきであるとしています。

1フレーム 16 ミリ秒の根拠は、16 ミリ秒以下に抑えることができれば、ディスプレイの一般的なリフレッシュレートである 60FPS を達成できるからです。60FPS でアニメーションを描画することができればユーザーは滑らかに感じます。それが一般的な滑らかさの限界に当たるからです。もしアニメーションの処理を高速化して 60FPS 以上を叩きだしたとしても、60FPS のリフレッシュレートを持つディスプレイでは意味がありません。

アニメーションを実装するにあたってフレームのなかで JavaScript を走らせる必要がある場合には、JavaScript の処理時間はさらに少ない6ミリ秒以下に抑えることを Chromium チームの開発者は推奨しています。

システムや OS や GPU ドライバや描画の合成の固定的な処理時間が4ミリ秒程度かかり、JavaScript の処理時間やレンダリングに残されている時間が 12 ミリ秒、そして安定的に 60FPS を達成するには JavaScript の処理時間はその半分の6ミリ秒程度が望ましいとしています。つまりアニメーションを行う際には、そのフレームで許される JavaScript の処理時間は 16 ミリ秒ではなく6ミリ秒程度と主張しています。

逆にアニメーション処理中のフレームの中で JavaScript を走らせる必要のない場合には、この6ミリ秒の制限を気にする必要はなく、単にフレームが 16 ミリ秒以下になるかどうかを注視すれば問題ありません。

Idle: 50 ms

アイドル状態に実行される JavaScript の処理時間を指します。アイドル状態とは、一度 Response や Animation や Load が終了してから何らかのユーザーのアクションを待っている状態を指します。RAIL では、このときに行う処理の時間を 50 ミリ秒以下に抑えるべきとしています。

モダンブラウザのレンダリングエンジンでは、JavaScript の処理とユーザーからのアクションの受取は同一のメインスレッドで実行されます。このため、ウェブページが何も処理を行っていないように見えても JavaScript が実行されている最中は、ユーザーからの入力の受取が、JavaScript の実行が終わるまで遅延することになります。

Response の処理時間を 100 ミリ秒以下に抑えていたとしても、アイドル状態のときに実行される JavaScript の処理時間が大きければ大きいほど、その間に行われるユーザーの入力を受け取るのも遅くなるため、実際にユーザーが何らかのアクションを起こしてからユーザーインターフェイス上の応答が返るまでの時間が実際には 100 ミリ秒には収まらないこともあるということです。

ここで Idle での処理時間を 50 ミリ秒以下に抑えることができれば、もしアイドル状態のときに JavaScript を実行している間にユーザーが何か入力をしたとしても理論上 150 ミリ秒以下で実際の UI 上の応答が返ることになります。

Load: 1000 ms

ウェブページのコンテンツの読み込みにかかる時間を指します。RAIL では、この処理時間を 1000 ミリ秒以下に抑えるべきであるとしています。

この指標をより正確に表すと、ウェブページの読み込みを開始してから実際にユーザーがそのウェブページを操作できるようになるまでの時間を指します。この処理時間を 1000 ミリ秒で抑えるとリンクのクリックによる画面遷移が、間断なく行えているようにユーザーは感じます。

様々なリソースのあるウェブページの読み込みを 1000 ミリ秒に抑えるのは難しいので、キャッシュを駆使したり、最初の読み込み時にはローディング画面を表示して各種リソースの実際の読み込みを遅延させることが求められます。

計測する #

  • Chrome DevTools などのデベロッパーツールによる計測
  • パフォーマンス診断ツールの利用
  • パフォーマンスの継続的監視

デベロッパーツールによる計測は、ウェブページ内の細かな計測や解析を行うのに有効です。特にパフォーマンスのボトルネックを引き起こす原因となっている箇所を調査するのに有効です。ただし、開発者自身の環境で起動し、手動で操作を行うことによって計測するものであるため、継続的な計測には向かない部分もあります。

パフォーマンス診断ツールの代表的なものは PageSpeed Insights と Lighthouse です。この種のツールはパフォーマンスを子細に計測して指標(たとえば RAIL)を満たしているかどうかを判別してくれるわけではありません。基本的にウェブページの初期読み込みのパフォーマンスを解析します。Interactive やアニメーションのパフォーマンスを計測してくれるわけではありません。その代わり改善のために、どのようなチューニングを行えばよいかについて助言してくれるので、どこから計測を始めていいかわからない場合やどのようにチューニングを行うべきかがわからない場合に利用できます。助言内容については、ある程度どのようなウェブページでも一定の効果が見込めるベストプラクティスが中心です。チューニングの手がかりがつかめないときなどに活用するといいでしょう。

ユーザーが実際に体験しているパフォーマンスを継続的に収集し、問題がないかを絶えず監視することが必要になります。これをクライアント側で実行するためには、ユーザーがウェブサイトにアクセスした場合に JavaScript で(密かに)パフォーマンスを計測しつつ、その結果をサーバに送信して記録、分析していくことが考えられます。自前でこの仕組みを作るよりも、既存の外部サービスを導入するほうが手間が省ける場合があります。代表的なサービスが New Relic 社が提供している New Relic Browser です。仕組みとしては、 New Relic の提供する JavaScript をウェブサイトに埋め込むと、計測値を New Relic サーバーへと送信して、開発者はそれをダッシュボードで確認できます。

リソース読み込みのチューニング #

目次

- リソース読み込みの流れ
- HTML/CSS/JavaScriptを最小化する
- 適切な画像形式を選択する
- 画像ファイルを最適化する
- CSSのimportを避ける
- JavaScriptの同期的な読み込みを避ける
- JavaScriptを非同期で読み込む
  - defer 属性
  - async 属性
  - JavaScript で script 要素を非同期で読み込む
  - 非同期読み込みの利用方針
- デバイスピクセル比ごとに読み込む画像を切り替える
- CSSのメディアクエリを適切に指定する
- CSSスプライトを使って複数の画像をまとめる
- リソースを事前読み込みしておく
  - DNS プリフェッチ
  - リソースの事前読み込み
  - ウェブページのプリレンダリング
  - 接続の投機的開始
  - 事前読込するリソースの動的追加
- Gzip圧縮を有効にする
- CDNを用いてリソースを配信する
- ドメインシャーディング
- リダイレクトしない
- ブラウザのキャッシュを活用する
  - Expires ヘッダーの設定
  - Cache-Control ヘッダーの設定
  - Expires ヘッダーと Cache-Control ヘッダーの使い分け
  - URL にトークンを付加する
  - 弱いキャッシュの活用
  - Last-Modified ヘッダーの設定
  - ETag ヘッダーの設定
  - Last-Modified ヘッダーと ETag ヘッダーの使い分け
  - キャッシュの使い分け
- Service Workerの利用
- HTTP/2の利用

URL にトークンを付加する #

キャッシュを擬似的にクリアするのによく知られたテクニックが、取得するリソースの URL に対してトークンを付加することです。キャッシュはリソースの URL ごとに有効です。したがって、あるリソースに対するキャッシュを効かせたくない場合にはリクエストする URL を変更すれば良いわけです。

たとえば、HTML 内に次のように image.png というリソースがあるとします。

<img src="image.png" />

この画像の強いキャッシュをクリアする場合には、この HTML ファイルを変更してこの画像を呼び出す箇所に次の変更を加えることで行います。

<img src="image.png?1446881046" />

このようにトークンを付加することでリソースの URL が異なるので、以前設定されたこのファイルへのキャッシュにはヒットしないようになります。ユニークなトークンであれば何でも構いません。たとえば、次のようにファイルのバージョンを付加するのでも問題ありません。

<img src="image.png?v=1.2.0" />

この例では img 要素を取り上げていますが、CSS ファイルや JavaScript ファイルなど、HTML 内に宣言して間接的に取得させるリソースであれば何にでも利用できます。

実際のウェブサイトで運用する際には、サーバーサイドスクリプトを用いてトークンの付加を自動化する場合がほとんどです。たとえば、そのファイルの最終更新日のタイムスタンプを付加するなどして自動化します。

Service Worker の利用 #

Service Worker は Web Worker の一種で、メインスレッドとは別スレッドで動作し、メインスレッドでの JavaScript の実行にお互い影響を与えません。

Service Worker は、ページから行われるリソース取得の要求とウェブサーバーの間で透過的に動作します。一度 Service Worker が登録されると、ブラウザがリソースを取得する際には Service Worker 内に記述されているロジックを参照してキャッシュからリソースを取得するかウェブサーバーからリソースを取得するかを決めます。Service Worker は、ウェブページとウェブサーバーの間にあるプロキシのような形で動作します。

serviceWorker.register() メソッドで、指定したスコープで Service Worker を登録します。

// main.js

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js", { scope: "/" });
}
// sw.js

// serviceWorker.register() が完了したときに呼ばれる
this.addEventListener("install", function (event) {
  event.waitUntil(
    caches.open("v2").then(function (cache) {
      // キャッシュするリソースを指定する
      return cache.addAll(["/index.html", "/style.css", "/script.js"]);
    })
  );
});

もしページから何らかのリソースが要求された場合には、Service Worker の fetch イベントが発火します。この fetch イベントリスナの中で、リソースの取得のロジックを記述することができます。次のコードでは、リソースのリクエストに対してキャッシュにヒットするファイルがあればキャッシュから取得するが、キャッシュにヒットしなければウェブサーバーから取得するというロジックを記述しています。

// sw.js

// ページからのリクエストが発生した時に呼ばれる
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {

      // キャッシュがあったのでレスポンスを返す
      if (response) {
        return response;
      }

      // ない場合にはウェブサーバーから取得する
      return fetch(event.request);

    });
  );
});

Service Worker にはキャッシュだけではなくリクエストとレスポンスをあつかう柔軟な API が用意されています。プロキシサーバーのようにページ側のリソースのリクエストやウェブサーバーからのレスポンスを加工することも可能です。また Service Worker から動的にレスポンスの生成もできます。

Service Worker を利用して、オフラインに対応したウェブアプリケーションを開発できます。ブラウザがリソースをリクエストする前に、Service Worker 内でリソースを取得してキャッシュに入れておくとう事前キャッシュも可能になっています。

JavaScript 実行のチューニング #

目次

- JavaScriptの実行モデル
- JavaScriptのボトルネックを特定する
- GCを避ける
- メモリリークを防ぐ
- WeakMapとWeakSet
- Web Workersの利用
- asm.jsによるJavaScript高速化
- SIMD.jsの利用
- 高頻度で発火するイベントの抑制
- モバイル端末でのclickイベントの遅延をなくす
- Passive Event Listenerでスクロールのパフォーマンスを改善する
- setImmediate()の非同期実行
- アイドル時処理を使う
- ページ表示状態を確認する
- 無駄なForced Synchronous Layoutを減らす
- DocumentFragmentの追加
- Intersection Observerで効率的に交差を検知する
- canvas要素の2D Contextアニメーション
- requestAnimationFrame()を活用する
- JavaScriptからCSS Transitionを用いる
- WebGL

JavaScript の実行モデル #

JavaScript の実行モデルで大切なのは、UI スレッドとイベントループです。

UI スレッド

JavaScript の実行は、基本的にタブ1つにつき1つの UI スレッド上で実行されます。Service Worker は別スレッド上で動作することを除いては、JavaScript は必ず UI スレッド上で実行されます。複数のスクリプトが並列で動くわけではなく、シングルスレッドで順々に動作します。

UI スレッドでは、JavaScript の実行に限らず、レンダリングエンジンの様々な処理が実行されます。処理とは、レイアウトの計算やレンダリング処理、DOM イベントの発火などです。

したがって、レイアウトの計算やレンダリングの処理などが遅くなればその分 JavaScript の実行の開始を遅くすることがあります。同様に JavaScript の実行が遅くなればなるほど、その分その他のレイアウト処理やレンダリング処理の開始が遅くなることがあります。

JavaScript の実行中には DOM イベントの発火もその分遅くなるため、重い JavaScript の処理を実行中に画面をクリックしたとしても、その DOM イベントが発火するのは、JavaScript の実行処理が完全に終わってからになります。

イベントループと実行キュー

UI スレッドには実行キューがあり、中にはタスクの列が格納されています。UI スレッドは実行キューの中にあるタスクが空になるまでタスクを消費し続けます。実行キューの中にあるタスクが空になるまでタスクを消費し続けます。実行キューのなかにあるタスクが空になったとき、UI スレッドはなにも処理を行わない Idle 状態(アイドル状態)になります。

この UI スレッドのなかで、JavaScript はイベントループと呼ばれるモデルで動作します。このモデルにより、JavaScript はシングルスレッドで動作するにも関わらず並列処理が可能です。たとえば、Ajax で HTTP リクエストを送信して HTTP レスポンスを待っている間には、JavaScript の他の処理や UI スレッド上での処理が実行可能になっています。

イベントループ上では、ネットワーク処理や待ち時間の処理は UI スレッドを決してブロックしません。レスポンスの受取などの継続する処理は、非同期で動作するコールバックの受け渡しやイベントリスナーによって行われます。そのため、その間は JavaScript が非同期的に実行できます。

ネットワーク処理の待ち時間などの処理待ちになる場合には、そのときの実行キューのタスクは一旦終了して UI スレッドに制御を戻して、待ち時間が終わって処理が再開するときに初めて、UI スレッド上での処理が開始されます。

たとえば、XMLHttpRequest() を使ったコードでは、send() メソッドを実行した時点で JavaScript の実行が終わり、UI スレッドに制御が戻ります。レスポンスを待っている間にも UI スレッド上では他の処理を行うことができます。そして、HTTP レスポンスが戻ってきて初めて JavaScript に処理が戻ります。

Web Workers の利用 #

Web Workers で、UI スレッドとは別のバックグラウンドのスレッドで JavaScript のコードを実行できます。Web Workers にはコードの複雑化、スレッド生成コストなどのトレードオフが生じうることには注意しましょう。

Web Workers は、Worker オブジェクトを生成して利用します。worker.js には Web Workers 上で実行する処理を記述します。

<script>
  var worker = new Worker("worker.js");
</script>

Web Workers のスレッドと UI スレッド間の通信は、非同期でデータを通信しあうメッセージパッシングによって行います。

UI スレッド側からメッセージを投げるには、Worker オブジェクトの postMessage() メソッドを使います。Web Workers からのメッセージを受け取るには、Worker オブジェクトから発火される message イベントに対してイベントリスナーを登録します。

<script>
  var worker = new Worker("worker.js");

  // メッセージを Web Workers 側に投げる
  worker.postMessage({ command: "doSomething", data: {} });

  // Web Workers 側からメッセージを受け取る
  worker.addEventListener("message", function (e) {
    // ...
  });
</script>

Web Workers 側からメッセージをやり取りするには、グローバルなスコープのメソッドに postMessage() メソッドと addEventListener() メソッドを使ってメッセージのやり取りを行います。

// worker.js

// UI スレッド側からメッセージを受け取る
addEventListener("message", function (event) {
  console.log(event.data);

  // UI スレッド側にメッセージを投げる
  postMessage({ command: "result", data: {} });
});

このメッセージには、通常の JavaScript のオブジェクトを渡すことができますが、スレッド間で共有されるわけではなくコピーされます。

現代のマシンはマルチコア構成がほとんどです。シングルコアではマルチスレッドで計算を行っても、実際には1つの CPU によって計算が行われるので、厳密な意味での並列計算ではありません。マルチコア CPU では、マルチスレッドで処理を行うと複数の演算がコア数に応じて独立して行われます。

Web Workers を利用してマルチスレッドで計算することで、CPU パワーを活用できます。Web Workers のコンテキストから Web Workers を生成することもできます。複数の Web Workers をあつかうことで CPU パワーを最大限活用することもできます。

ただし Web Workers には制限があります。次のオブジェクトは利用できません。

  • DOM 要素
  • document オブジェクト
  • window オブジェクト
  • parent オブジェクト

純粋なデータの処理のみを Web Workers 側で行う必要があります。

WebAssembly #

WebAssembly は、より高速なロードと実行を目的として各ブラウザベンダによって策定されている、ブラウザ上で実行可能なバイナリフォーマットです。直接ブラウザに読み込ませることができるコンパイル済みのバイナリフォーマットを提供することで、高速なロードと実行を可能にしようとしています。

高頻度で発火するイベントの抑制 #

DOM イベントには高い頻度で発火するものがあります。たとえば、デスクトップのブラウザでは、scroll イベントや resize イベントがそうです。スクロールすると scroll イベントが高頻度で発火することになります。ブラウザのウィンドウの大きさをマウスで変えると resize イベントが高頻度で発火します。モバイル端末でのブラウザでは、指でジェスチャを行うと touchmove イベントが高頻度で発火します。

こういった高頻度で発火するイベントの場合、イベントリスナに登録した処理が終わる前に、次のイベントが発火するようなことが続くと処理が詰まって重たくなることがあります。

このケースでは、処理を一定の頻度でのみ実行する対策が効果的でしょう。

(function() {
  var running = false;

  var callback = function() {
    doSomething();
  };

  window.addEventListener('scroll', function() {
    if (!running) {
      running = true;
      setTimeout(function() {
        running = false;
        callback();
      }, 60); // 60 ミリ秒に一度実行する
    }
  )};
});

CSS のチューニング #

高速な CSS セレクタの記述 #

レンダリングエンジンは、すべての CSS ルールセットと、ドキュメントに含まれるすべての DOM 要素を突き合わせて CSS セレクタのマッチング処理を行います。

パフォーマンス特性として重要なのは、マッチングの際には CSS セレクタは右から左に向けて処理されることです。

body > div.logo {
  ...;
}

この CSS セレクタは次のような試行を上から順に行います。

  1. class 属性に logo が含まれている
  2. その要素名が div である
  3. その親の要素名が body である

これらの試行がすべて通った場合に初めてセレクタがその DOM 要素とマッチしているとみなします。試行が1つでも失敗した場合には、その時点で CSS セレクタのマッチングに失敗することになります。

この例に限らず、どのような CSS セレクタでもマッチング処理の際の試行は右から左に向けて行われます。

table > tbody > tr > td {
  ...;
}
  1. 要素名が td である
  2. その親の要素名が tr である
  3. その親の要素名が tbody である
  4. その親の要素名が table である

CSS セレクタの記述を詳細にすればするほどマッチング処理は短くなると直感的に考える開発者がいるかもしれませんが、実際にはセレクタの記述を増やせば増やすほどそれのマッチング処理に必要となる試行も多くなりマッチング処理にも時間がかかることになります。

CSS セレクタのマッチング処理のパフォーマンスを上げる考え方の基本は、セレクタの記述をシンプルにすることです。

1つのクラスセレクタのみを使った CSS セレクタのほうが速くなります。セレクタはいくつも組み合わせず、シンプルにしましょう。

たとえば、CSS の設計規約の1つに BEM があります(https://en.bem.info/)。これはあくまで設計・保守しやすい CSS を記述するためのものですが、BEM は結果的にパフォーマンスの観点からも優れた書き方になっています。

<div class="global-header">
  <div class="global-header__logo">
    <a href="/"><img src="images/logo.png" height="60" width="200" /></a>
  </div>
  <a class="global-header__navi">HOME</a>
  <a class="global-header__navi">BLOG</a>
  <a class="global-header__navi">CONTACT</a>
</div>

<style>
  .global-header {
    width: 100%;
    height: 60px;
    border-bottom: 1px solid #ddd;
    text-aligh: right;
  }

  .global-header__logo {
    width: 200px;
    height: 60px;
    float: left;
  }

  .global-header__navi {
    color: #ccc;
    font-size: 14px;
  }

  .global-header__navi:hover {
    text-decoration: none;
  }
</style>

hover 擬似クラスを除くと、1つのクラスセレクタのみで CSS セレクタを宣言していることに注目してください。

CSS セレクタのマッチング処理を避ける #

そもそも CSS セレクタのマッチング処理をレンダリングエンジンに行わせないことが有効な場合があります。

JavaScript で DOM 要素に当たるスタイルを変更する基本的な方法には、次の2つがあります。

  • DOM 要素の class 属性などを変更する
  • DOM 要素の style プロパティ(style 属性)を変更する

1つ目の DOM 要素の class 属性を変更する方法は、CSS で宣言したルールによって当たるスタイルを記述しておいて、JavaScript で DOM 要素の class 属性を変更して、当たる CSS ルールセットを切り替える方法です。具体的には次のコードになります。

<style>
  .red {
    background-color: red;
    color: white;
    border: 1px solid #c00;
  }
</style>

<script>
  var div = document.querySelector("#my-element");

  div.classList.add("red");
</script>

2つ目の DOM 要素の style プロパティを変更する方法を見てみましょう。

<script>
  var div = document.querySelector("#my-element");

  div.style.backgroundColor = "red";
  div.style.color = "white";
  div.style.border = "1px solid #c00";
</script>

class 属性を変えてスタイルの適用を変更する方法では、その属性を変更した要素だけではなく、その子要素や兄要素などの周辺の DOM 要素もセレクタのマッチング処理の対象となります。

DOM 要素の style プロパティを直接変更することで、CSS セレクタのマッチング処理を避けることができます。

見た目を変える際には、必ず style プロパティを直接変えるべきというわけではありません。class 属性を変更して CSS ファイルに変更後のスタイルを記述したほうが保守性があがって好ましい場合が多いでしょう。

利用していない CSS ルールセットを減らす #

CSS セレクタのマッチング処理を減らすには、利用していない CSS ルールセットを減らすことも重要です。

CSS セレクタのマッチング処理は、原理的にはそのドキュメントの DOM ツリーに含まれている DOM 要素と CSSOM 内に宣言されている CSS ルールセットの総当たりに近い処理が行われます(注:レンダリングエンジンに加えられている最適化によって多くの場合総当たりにはなりません)。素朴に考えれば、ドキュメント内に 100 個の DOM 要素があり、300 個の CSS ルールセットがある場合には、最悪の場合、CSS セレクタのマッチング処理が 100 × 300 で 30,000 回行われることになります。

したがって、1つの CSS ルールセットを減らすだけでもドキュメントに含まれている DOM 要素の数だけマッチング処理を減らせる可能性があります。

認知的チューニング #

インジケータを用いる #

アプリケーションの UI は常にシステムの状況をユーザーにフィードバックすべきです。

時間のかかる何らかの処理を行っている場合、何も表示しないと待ち状態なのか、もしくはすでに処理が完了したのか、いまどのような状態なのかわからず、ユーザはストレスを非常に感じやすくなります。

それでは、どれぐらいの待ち時間の場合にインジケータを表示するのが適切なのでしょうか?

RAIL パフォーマンスモデルでは、ユーザーのアクションに対する応答(Response)の指標は 100 ミリ秒以内です。ロードが始まってからユーザーが操作可能になるまでの時間は 1000 ミリ秒以内にすべきとしています。

また、ヤコブ・ニールセンは『ユーザビリティエンジニアリング原論』で、ユーザーにフィードバックすべきレスポンスタイムについて次のように記述しています。

  • ユーザーがシステムの反応が瞬時に行われていると感じる限界は 0.1 秒であり、結果を表示する以外のフィードバックは必要ない。
  • たとえ遅れに気づいていても、ユーザーの考えの流れが妨げられない限界は 1.0 秒である。通常、0.1 秒以上の遅れでも 1.0 秒以内であれば、遅れに対する特別なフィードバックは必要ない。しかし、ユーザーはデータを直接処理しているという感覚を失ってしまう。
  • ユーザーが対話に集中する時間は 10 秒が限界である。時間がかかりすぎると、ユーザーはコンピュータが処理を終えるのに待つ間に他の作業をしたくなるので、いつコンピュータが処理を終えるかを表示するフィードバックが必要になる。レスポンスタイムに変化がある場合、ユーザーはどのくらい待つのかわからないので、処理中のフィードバックは特に重要である。

これらから、1秒を超えるような処理や読み込みを行う場合には、待ち時間であることを表すインジケータやなんらかのフィードバックを返すことが望ましいでしょう。10 秒を超えるような長い処理を行う場合には、プログレスバーなど処理の進捗がわかるインジケータを表示することが望ましいです。

インターフェイスプレビューを用いる #

インターフェイスプレビューとは、表示される予定のコンテンツのプレースホルダーやダミーコンテンツを表示して、実際のどのようなコンテンツが表示される予定なのかを暗示しつつ読込中であることをフィードバックします。読込中を表す明示的なインジケータを表示するのではなく、表示される予定のコンテンツを仄かに表現することで待ち時間の印象やストレスを和らげます。

処理が終わったように振る舞う #

この手法は、Ajax でデータを送信するときに利用できます。

リクエストした瞬間に UI 側では処理が完了したかのように表示することで、ユーザーは待ち時間を意識せずに利用を継続できます。もちろん Ajax でサーバーとやり取りする時点で何らかのエラーが出る場合があるので、そのときに初めてエラーを表示しつつ UI の変更をロールバックします。