CSS
JavaScript
HTML5
フロントエンド

続・ハイパフォーマンスwebサイト読んだまとめ

以前の記事で参考文献に上げたオライリーの「続・ハイパフォーマンスWebサイト」をちゃんと読んでいないことに気がついたので、GW に読み、その内容と感想をまとめてみました。

本書が発行されたのが2010年、HTML5 や CSS3 は草案が発表され、
それらの対応ブラウザも限られている状態でした。(国産Android が発売されたのもこの年)
ES2015 など影もない時期で今から見ると古い内容もありますが、普段なんとなく避けていたことの理由が述べられていたりと勉強になりました。

しかし、最初の「ハイパフォーマンスWebサイト」のほうがインパクトが大きく、
すぐに取り組むべき内容がまとめられているので
未読の方はそちらを優先にすると良いと思います。

また web パフォーマンスに関する書籍として、最近 Webフロントエンド ハイパフォーマンス チューニング という本も出版されました。
こちらも広く内容の濃い良書でした:grinning:
ただ、1つ1つの説明がオライリー本のほうが細かくわかりやすかったので、最初のきっかけとしてはハイパフォーマンスWebサイトがおすすめです。

内容と感想

Ajax アプリケーションとパフォーマンス

どこに負担が大きいのか?を調べて改善するのが最も効果的」はとても大切な視点で、どのパフォーマンス改善本でも序盤に書かれ、この本でもやはり最初に書いてありました。
処理コストの低いプログラムを改善したとしても、結果を伴わない。思い込みで作業してはいけない、というのは肝に命じたいです。
(私が計測するときは、主に Chrome DevTools の Network ばかりみておりました。。反省)

ブラウザは JavaScript の実行より DOM の処理に時間がかかっているので、
JavaScript の小手先のテクニックにこだわらず、わかりやすくて綺麗なコードを書くほうが大事、という内容。

応答性の高いウェブアプリケーション

応答時間によって、ユーザの印象が変わってくる。(Jakob Nielsen の調査)

時間 ユーザの印象
0.1秒 ユーザがUI直接変更していると感じる時間。hover やセレクトメニューの出現などはここまでに収めたい
1秒 自由に操作できていると感じる時間。
0.2〜1.0秒の遅延はユーザも気づき、自分の操作のあとにコンピューターの処理が行われていると考える
10秒 ユーザの我慢の限界

web workers

JavaScript はシングルスレッドなので、
web アプリケーションで処理が重くユーザの操作が中断されてしまう場合は web workers を使い、バックグラウンドで処理させるとよい。

web wokers は非同期処理なので、普段使う Ajax にふるまいが似ています。
以前、あえて Ajax を同期通信で行ったことがあるのですが、
シングルスレッドや web workers の関係を理解するのにわかりやすいと感じました。

メモリが原因の応答性悪化

ブラウザの GC と仮想メモリのページングなどが原因で、メモリの使用が原因で処理が遅くなることがある。
一般に、メモリ不足は JavaScript の delete 演算子や不要な DOM を削除するなどで対応できる。

ただ、現状ではフロントエンドでのメモリの完璧なコントロールは難しいので、
ユーザ応答が必要な場面では、メインスレッドで行う処理を極力コンパクトにし
すぐに応答できるようにする設計を心がけたい。

初期ロードの分割

onload イベント以前に実行される JavaScript の関数はそんなに多くないので、
確実に onload 以降に実施される処理は別ファイルにし、初期ロードに含めないようにすると効率的というお話。

これを実現するために MicroSoft が Doloto というツールを作ったが、
ここ数年話題になっていない。

仕組みとしては納得なのだが、実装の難易度がかなり高い + 現在の SPA などと執筆時の状況が違いすぎるので採用は難しい。。

実行をブロックしないスクリプトのロード

<script src="**/**.js"></script> のような記述で外部 JavaScript ファイルを読み込むと、
実行完了まで他のリソースのダウンロードや DOM の解析をブロックしてしまう。

例: ブロックしてしまうパターン

blocking.html
<div id="wrap">
  <p id="top">ここと</p>
  <script src="blockjs.js"></script>
  <p id="bottom">ここの間でブロッキングが発生</p>
</div>
blockjs.js
const e = document.createElement('p');
e.innerHTML = 'block!';
document.getElementById('wrap').appendChild(e);

スクリーンショット 2017-07-10 12.44.47.png

例: ブロックしないパターン

noblocking.html
<div id="wrap">
  <p id="top">ここと</p>
  <script src="blockjs.js" defer></script>
  <p id="bottom">ここの間でブロッキングが発生</p>
</div>

スクリーンショット 2017-07-10 13.03.21.png

■ JavaScript ファイル読み込みでブロッキングを発生させない手法

1.JavaScript をすべてインラインで埋め込む
 JavaScript の記述が少なければ問題ないが、巨大になるとちょっと採用できない。。

2.XHR eval を使う
 読み込み中のインジケータが出ない。ただし、XMLHttpRequest が呼び出し元と同じドメインでなければならない。← CORS で対応可能?

3.XHR インジェクションを使う 
 eval とほぼ同じ。eval に比べるとちょっと処理が早いらしい。

4.iframe スクリプトを使う
 iframe は生成コストの高い DOM なのでこの目的での使用はおすすめできない。
 また、実行順序は保証されない。

5.script DOM要素を使う
 下記のような SNS プラグイン読み込みでよく見る手法。こちらも実行順序は保証されない。
 多くのブラウザで安定して動く。

loadPlugin.js
var script = document.createElement('script');   
script.src = "http://**/**.js";   
document.getElementsByTagName('head')[0].appendChild(script);   

6.defer を使う
 現在はほぼすべてのブラウザで利用可能
 ページの読み込みが完了してから、スクリプトを実行させる。
 実行順序は defer を使っている読み込み同士ならコントロール可能。

7.document.write で script タグを書き出す
 document.write 自体が古い書き方。現在では HTML5で非推奨。

8.async を使う
 本書では言及されていない。
 レンダリングのブロックは発生しないが、実行順序の保障はない。

非同期のスクリプトの組み合わせ

読み込みブロッキングを避け、非同期で外部ファイルを読み込んだ場合、インライン(HTML に直接埋め込まれた)JavaScript と依存関係が確保できない。
こうした状況の解消方法について。

非同期スクリプトに依存する設計はなんてリスキーな…と感じますが、
本書では、普段非表示のメニューを操作するためのスクリプトを非同期にし、インライン側でそれを呼び出す場合を想定していました。

1.ハードコーディングコールバック
 外部コードの中でインラインスクリプトを呼び出す手法。
 ライブラリに直接手を入れちゃう感じがなんとも強引

2.Window Onload 内で実行
 上記「実行をブロックしないスクリプトのロード」解決法(5) script DOM では無効。
 また、Window Onload での実行は iflame の読み込み後に実行されるが、iflame の読み込みが思ったより遅く実行されることがあり、制御が難しい。

3.タイマー
  非同期で読み込まれるファイルを監視するポーリングをつくる

4.Script Onload
 onreadystatechangeを使う。非同期スクリプトが複数あるとちょっとしんどい手法。

5.script タグの分解
すべてのブラウザで動くようにする手法がややこしいので割愛。

本書執筆時点では defer が IE のみでしかまともに動いていなかったので使い所が限定されているとされていたのですが、今なら defer の利用で回避できるのでは?と思います。

インラインスクリプトの適切な位置

インラインスクリプトの実行中も、他のリソースのダウンロードやレンダリングをブロックしてしまう。

■ 解消法

1.インラインスクリプトをページ末尾に移動する
2.インラインスクリプトを setTimeout で非同期にする
3.<script differ> プロパティを使う

■ 注意点

CSS の読み込みのあとにインラインスクリプトが続くと、リソースのダウンロード&インラインスクリプトの実行まで次の読み込みが阻害されてしまう。
なので、両者が続かないようにすると良い。

■ 外部リソースを読み込む順番について

一般的な方法(<script src="*"></script><link href="*">)で外部スクリプトを並べて読み込ませると、依存関係の解消のため、必ず記述順でひとつずつダウンロードが開始される

また、 CSS も解析が完了するまで JavaScript は実行されない。

効率的な JavaScript コード

本の序盤で小手先の JavaScript にこだわるな、と書いたのですが
ここで説明されている手法はごく一般的な記述だったので、マナーとしてできるとよいと思います。

■ 同じ DOM へ何度もアクセスする場合は、変数に格納する

特にうっかりループに組み込んでしまうのはよく見る気がします。

// 非推奨
const divs = document.getElementsByTagName('div');
for (let i = 0; i < divs.length; i++) {   // ← ループの度に DOM にアクセスする

// 推奨
const divs = document.getElementsByTagName('div');
for (let i = 0, len = divs.length; i < len; i++) {

■ 条件文の使い分け

// 判定すべき値が2個以下か、一定の範囲内で分割できる場合
if (val > 0) {}

// 判定すべき値が3個以上10個以下の場合
swich (val){
  case 0:
  break;
}

// 判定すべき値が多く、結果が単一の値のときは配列要素の参照がよい
// 引数の値が限定される関数のチェックとかに良さそう
const result = ['result01', 'result02', 'result03'];
return result[0]; // result01

■ 文字列操作

ES5 以降から追加された組み込み関数は、処理も早くて便利なので覚えよう。

// 文字列先頭/末尾の空白文字や改行文字の削除は trim がよい
let str = ' hello world! \n ';
str.trim(); // 'hello world!'

■ 長時間実行されるスクリプトを書かない

ブラウザが止まる…!
DOM の操作が多い、ループの処理が重い、再帰が深すぎるなどが原因。

Comet

サーバから非同期で送られてくるデータを、リアルタイムでうまく処理する方法について。

技術 概要
ポーリング 定期的にサーバにデータ更新があるか尋ねる。
状況に関わらずとにかくリクエストを投げ続ける。サーバレスポンスでコネクションは閉じる
ロングポーリング サーバ側でデータ更新があるまでレスポンスを待機する。
再接続は再びクライアントサイドから行う必要がある
永久フレーム 非表示のインラインフレームを使う。
古いIEでも使えるのでcomet の中ではよく使われている手法
XHRストリーミング XMLHttpRequest を使って効率的にデータを取得する。
XHR ストリーミングが長くなるとブラウザの負荷が大きい
Server Sent Events 接続したままサーバからデータを受け取り続けることができる。
これも古いブラウザで一部未対応
Web Sockets 低コストでサーバとクライアントを接続する。HTML5 で定義。IE10以降で使える

gzip圧縮再考

gzip がうまく機能していないユーザが10%程度いたので、原因を調べてみたら
ネットワーク監視などの目的で圧縮を無効にしているケースだった。
(ブラウザアドオンや国規模のファイヤーウォールの影響らしい)

なので、圧縮が使えないユーザがいることを前提に gzip 圧縮に甘えない(コード量をそもそも減らす)コーディングを心がけるとよい。

1. イベントデリゲーション
 イベントハンドラを親要素に譲渡することで全体の記述を減らす

<!-- こういう場合は親要素(ul)にイベントを設定すると効果的 -->
<li onclick="return foo(this, event, function(){...})">リスト1</li>
<li onclick="return foo(this, event, function(){...})">リスト2</li> 

2. 相対パスを使う
3. 行末などホワイトスペースの削除
4. 属性を囲む引用符の削除
 HTML5では、属性の引用符が空白・一部記号でない場合は省略できる。

<input type=text> <!-- OK -->

5. インラインにスタイルを書かない
6. 変数をうまく使って記述量を減らす

// (6)の非推奨
let foo = $('div');
foo.style.color = 'red';
foo.style.width = '10px';

// (6)の推奨
let foo = $('div').style;
foo.color = 'red';
foo.width = '10px';

他の対処法として、gzip 圧縮が有効でないユーザにアラートを出して
自分でなんとか有効にしてもらい、解消してもらう方法もあるらしい(なんという力技…)

画像の最適化

多くの web サイトにおいて、画像ファイルが転送量の約半分以上を占めているので、
最適化を必ず行うようにする。

■ 画像の各形式と特徴

画像形式 特徴
GIF ・可逆圧縮(再編集しても品質が劣化しない)
・中間の透明度はもてない
・アニメーションが可能
・圧縮時に画像が水平方向にスキャンされるため、縦縞模様より横縞模様のほうがファイルサイズが小さくなる
・インターレースをサポートしている
JPEG ・不可逆圧縮だが、[ 回転(90°きざみの場合のみ)、トリミング、上下・左右反転、ベースライン形式とプログレッシブ形式の切り替え、メタデータの編集 ]の操作は可逆的に行える
・プログレッシブJPEGはインターレースをサポートしている
PNG ・トゥルーカラー(16,777,216色)形式とパレット(上限256色)形式に大別できる
・トゥルーカラーPNG では中間の透明度を持てる
・FireFox と Chome(59以降)のみ、APNG というアニメーションPNG がサポートされている
・可逆圧縮
・GIF同様、水平スキャン
・インターレースをサポートしている

■ 可逆的な画像最適化手法

  1. PNG チャンク(ほぼ web 上で使用されない画像データ)の削除
  2. JPEG のメタデータの削除
  3. アニメーションでない GIF の PNG 変換
  4. GIF アニメーションの最適化

いくつかツールが紹介されていますが、個人的には ImageOptimImageAlpha の組み合わせが一番使いやすく、圧縮できると思います。
画像が大量だったら、 imagemin をタスクランナーやモジュールバンドラに組み込むのが確実。
GIF アニメーション最適化目的で compressor.io もよく使います。

■ プログレッシブ JPEG とファイルサイズ

10k 以上の JPEG 画像はプログレッシブ方式のほうがファイルサイズが小さくなる傾向がある。
(いままで JPEG のプログレッシブ圧縮とベースライン圧縮に気を使っていなかったので知見がなく、
Android の古い端末でも問題なくプログレッシブ圧縮ができるか、などは未知数です。。)

■ CSS スプライトの最適化

  • カラーパレットを制限するため256色以内(できるだけ類似の色に制限するとなお良し)の画像でまとめる。
  • アルファ透過度が必要な画像とそうでない画像はなるべく混ぜない
  • 不要な空白部分を作らない
  • 水平スキャンのため、垂直方向より水平方向に配置するとわずかに容量が小さくなる
  • アンチエイリアス処理のピクセルを、サイズと位置合わせによって削減する
  • 斜めのグラデーションは使わない

とはいえ、CSS スプライトは運用コストとトレードオフなので、できる範囲の対応でよいと思います。

■ favicon と web クリップアイコン

  • 404 エラーは高価な処理になってしまうので発生させないようにする
  • HTML上での参照はなくても、できるだけ小さく、キャッシュ可能にするようにする

主ドメインの細分化

DNS ルックアップのコストを減らすため、ドメインをなるべく分散しないほうが良いとされるが、
HTTP/1.1 ではドメイン毎の同時接続数の制限があるため、
サイトの初期表示に必要なファイルの読み込みにかえって時間がかかってしまうことがある。

コンテンツ配信を複数のドメインに細分化させるテクニックは「ドメインシャーディング」と呼ばれるが、Yahoo! が発表した研究によると、
ドメインを3個以上に増やすとかえってロード時間に影響がでてしまった。

HTTP/2 ではドメイン毎の同時接続数の上限がないので、このテクニックは不要になる。
ただ、同時接続数がすくない場合などは HTTP/1.1 の方がパフォーマンスに優れる場合があるので、
パフォーマンス最適化目的で HTTP/2 と HTTP/1.1 を使い分けられる状況なら
計測して確かめてからの利用が良さそうです。

ドキュメントのフラッシュ(flush)

PHP などサーバサイドで HTML 生成する場合、通常はページ全てがパースされないとブラウザへデータを送信できない。
そのため、HTML 生成前にサーバサイドで時間のかかる処理を行ってしまうとブラウザに何も表示されない時間が長くなってしまう。

これを解決する手段として、テンプレート内などで時間がかかる処理の前に flush() を実行する。
これによって、そこまでパースしたデータを一旦ブラウザに送信し、一部でもページを表示できるようになります。

ただし、

  • HTTP/1.0では使えない
  • gzipのファイルの場合、Apache 2.2.8系以前ではページにパディングが必要
  • プロキシやセキュリティソフトが勝手にレスポンスを HTTP/1.0 に変更してしまい、無効化されてしまう場合もある
  • chrome や safari ではページが表示されるのに必要な HTML データ量があるので、あまりに小さすぎると flush が有効にならない(現在は変わっているかも…)

という問題もあります。
wordpress などでは flush はいいなあと思います。

iframeの取り扱い

■ iframe の利用コスト

空の anchor タグ、div タグ、script タグ、style タグ、iframe タグを100個読み込ませるテストを各ブラウザで行ったところ、iframe は他の要素と比べて1桁〜2桁倍の時間がかかった。

■ iframe と onload

iframe の読み込みが完了するまでは onload イベントが発火されることはないが、
広告で iframe が使われている場合、意図しないイベント発火遅延がおきることがある。

解決方法として、JavaScript で iframe の src 属性をセットすれば
onload を阻害しない。

<iframe id="frame" src=""></iframe>

<script>
  document.getElementById('iframe').src = "**/**.html";
</script>

本では Firefox では使えないとされていたのですが、
現在は解消し、どのブラウザでも有効な手法のようでした。

■ iframe と並列ダウンロード

一般的には呼び出し元のメインページと iframe 内のリソースのダウンロードは平行して行われる。
メインページで JavaScript の読み込みなどでリソース取得の阻害があった場合は、iframe 内のリソース取得にも影響がでる。

■ iframe と同時接続数

メインページも iframe も接続数は共有して制限される。
ブラウザの同時接続数の制限を解消する目的で iframe を使っても意味はない。

CSS セレクタの単純化

CSS セレクタの解析は右から行うという基本から発展した内容。

本書執筆時点でもセレクタ指定方法による実行速度の低下はほぼ誤差みたいな結果でしたが、
セレクタの一番右がユニバーサルセレクタ の場合だけは明らかに遅くなるから避けるべしとされていました(特にIE6と7で顕著な結果だった)。

試しに chrome 59 と Firefox 52、IE 10 と IE11 で確認してみたところ、
ユニバーサルセレクタが含まれていても実行時間に差はほとんどありませんでした。

セレクタが深くなることで保守の難易度が上がってしまうので、
その点は気をつけるべきかと思いますが、パフォーマンスとしては気にしなくてもよいかなと思います。

■ リフロー時間

リフロー時間とは DOM と CSS 解析後に行うレイアウト計算時間のことです。(chrome では layout と呼びます)
CSS の解析が行われるのは、ページ表示時だけでなく JavaScript で style 要素を書き換えたときもリフローが起こります。
このリフローも、CSS の書き方が非効率だと処理が遅くなっていきます。

しかし、これも本書執筆時点での計測で、現在のブラウザでテストしてみるとそこまで大きく影響ありませんでした。
(テストページがそこまで処理負荷の重い CSS プロパティを使っていなかったことも原因かも?)

まとめ

冒頭でも同じことを書いていますが、本書は前のハイパフォーマンスWebサイト に比べて細かな内容でした。
それでも、知らないことも多く、最後まで読んでよかった本だと思います。

このまとめでは技術的な根拠の部分をかなり省略しておりますが、本書ではしっかりと説明されていました。
何か気になったところがあれば、一読をおすすめします!
ついでに私の間違いもあればご指摘いただけると助かります…:bow_tone1: