Kekeの日記

エンジニア、読書なんでも

Lighthouseを使ってVue.jsで書いたポートフォリオサイトを爆速化する

本記事はVue.js Advent Calendar 2018の第3日目の記事です。

はじめに

GCPの無料枠が終了したため、ポートフォリオサイトはクローズドしましたが、引き続き本記事はご覧いただけます。

私は以前、インターンシップなどの選考に備えて自分のポートフォリオサイトなるものを作りました。

しかし、あまりに速くデプロイしたかったことからパフォーマンスについて一切無視した結果、とてつもなく遅いサイトができてしまいました。

結局、他人に見せるには恥ずかしいサイトになってしまい、今回の記事で爆速にできればと思います。

サイトはこちらで、SEO対策やOGPも実装したので以下のように展開されます。

f:id:bobchan1915:20181216014218p:plain

要約

  • 自分のポートフォリオサイトがロードまで8秒近くかかっていたのがキャッシュ込みで0.3秒くらいになった
  • クリティカルレンダリングパスを知ることが重要
  • Chrome DevToolを使い倒すとよくわかる。Lighthouseは便利だし、解決策をくれる
  • いろんなサイトをみても最初は画像の質が悪い(サイズが大きいなど)の問題が目立つので注意する

サイトが遅くなる主な原因

  1. ネットワークに関することです。ブラウザでコンテンツをリクエストすると、ネットワークを介してレスポンスが返ってきます。通信速度などの影響を受けるので、速度計測ツールなどによって検証が必要です。

  2. レンダリングに関することです。画像などのデータはラスタライズされて表示されますが、一例として、画像が非常に大きいと、その時間もかかってしまいます。特に、リッチなアニメーションを導入していると、ボトルネックになりがちです。レンダリングの効率について検証する必要があります。

  3. スクリプトに関することです。これは主にJavascriptを指します。特に、ロジックについては注意が必要です。 使用メモリなどを検証する必要があります。

0. 実際に遅いのかを定量的に調べる

まず、自分のサイトがどのくらいの時間でレスポンスを返すのかを調べます。

実際にどのくらい遅いのかを数字で知ることが重要です。

Google Chromeの「デベロッパーコンソール」のNetworkタブで調べることができます。私は普段Google Chrome Canaryを使っているので、興味がある人は使ってみてください。

www.google.com

ここでキャッシュがない状態で設定したいので、Disable Cacheにチェックボックスをいれます。

スクリーンショット 2018-08-18 8.29.55.png

すると計測結果が下に表示されます。

スクリーンショット 2018-08-19 1.29.16.png

めちゃめちゃ遅いです、、、 そもそも遅くても1.0秒が閲覧者のUXにもいいということなので、至急改善しないといけません。

また、全体的なサイトの評価は、"Audit"タブで測定することができます。

スクリーンショット 2018-08-18 19.58.06.png

ターゲット端末、指標、通信状態を決めて評価します。20秒ぐらいかかりました。

まず、最初にモバイル端末から調査しました。

スクリーンショット 2018-08-18 20.01.44.png

やはり、パフォーマンスが非常に悪いです。

しかしながら、タグを適切に使うなど、SEO対策はしたつもりだったので、かなりスコアはいいです。

提案は以下の通りです。

スクリーンショット 2018-08-18 20.05.00.png

ネットワークのペイロードに問題があるみたいです。

デスクトップも調査してみると

スクリーンショット 2018-08-18 20.13.59.png

同様の結果です。これから

  • ネットワークに関して
  • レンダリングに関して
  • スクリプトに関して

の3つのセクションで解説していこうと思います。

1. ネットワークに関して

まず、ネットワーク処理の3原則は以下の通りです。

  • データの転送量は小さく
  • データの転送回数は少なく
  • データの転送距離は短く

またブラウザの内部構造を知る中でもクリティカルレンダリングパスは非常に重要です。

1.0 クリティカルレンダリングパス

クリティカルレンダリングパスでは最初にHTMLをダウンロードして、バイトを解析し、トークンに変換し、DOMツリーというものを構築します。

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/full-process.png?hl=ja

またheadなどのlink属性に

<!doctype html>
<html>
  <head>
    <link href="main.css" rel="stylesheet">
    ...

のようなstyleがあるとそれをネットワークから取得してCSSOMツリーを構築します。

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/cssom-construction.png?hl=ja

そして最終的にはDOMツリーとCSSOMツリーを一つにレンダリングするわけです。これでできあがったものをレンダリングツリーと呼びます。

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/render-tree-construction.png?hl=ja

レンダリングツリーは構造が知れても、端末内でどのように表示するかはわかりません。つまりwindowなどがわからないということです。

次のレイアウト段階で決まります。このレンダリングツリーを実際のピクセルに変換することをペインティングラスタライジングと呼びます。

ここで見えたDomContentLoadedは青線で表示され、DOMツリーが構築された状態も示します。

またonloadは全てのリソースの処理が終了するまでの時間を示していて、赤線です。

後から参考画像を追加したのですでに爆速化し終わったものですが、このような対応関係です。

f:id:bobchan1915:20181205213616p:plain

幸いにもリソースによってそこまで大幅にレンダリングがブロックされてはいませんでした。

今回はなかったのですが

青線と赤線の差が短いのならば何かしらブロッキングされている

ということですので、注意してみてください。

ここで少し体系的にクリティカルレンダリングパスを説明します。

仮に以下のようなレンダリングパスをとるページがあるとしましょう。あくまでもHTMLだけのページです。

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom.png?hl=ja

このときはT0T1はネットワークから取得するまでの時間なので、あまりアプリケーションエンジニアはすることはありません。

厳密に言えばデータの転送距離は短くの原則から近くのリージョンのデータセンターにコンテンツを置いたりすることで高速化できます。

次に新たにCSSを追加したとします。

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom-css.png?hl=ja

HTMLとCSSで二回取得が必要になります。

ここからどんどんクリティカルレンダリングパスが何なのか?ってことがわからなくなるので、ここで用語を定義したいのですが

  • クリティカルリソース: 初回のページレンダリングをブロックする可能性のあるリソース(cssやjs、画像ファイルなど)
  • クリティカルパス長: すべてのクリティカルリソースを取得するために必要なラウンドトリップ数または総時間
  • クリティカルバイト :初回のページレンダリングに必要なバイト数

です。デフォルトではHTMLもCSSもクリティカルリソースです。だってHTMLがダウンロードされるまで何も見えないからです。

またCSSも同様で、CSSOMが構築されるまでは処理済みコンテンツのレンダリングを保留します。

できるだけ小さくして、できるだけ早くクライアントに配信しましょう

ここでまたjavascriptファイルがあったとしましょう。

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom-css-js.png?hl=ja

クリティカルリソースであるjsファイルはJavascriptを実行するためにブロックしてCSSOMを待つ必要があります。

scriptタグにasyncを追加すると

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom-css-js-async.png?hl=ja

になり非同期スクリプトは、パーサーブロックをしないためクリティカルリソースではなくなります。またCSSも特定のページしか使わないのならば以下のようにレンダリングをブロックすることもないでしょう。

https://developers.google.com/web/fundamentals/performance/critical-rendering-path/images/analysis-dom-css-nb-js-async.png?hl=ja

これはCSSにもいえて、例えばモーダルが使われていて、それに使うCSSならモーダルが出された時にロードする方が初期ページレンダリングをはやくすることができます。

クリティカルレンダリングパスを知ることで、ユーザーにすばやくコンテンツを返すことができるようになると思います。

つまりクリティカルレンダリングパスとネットワークの原則を合わせると

  • データの転送量は小さく => ブロッキングするかもしれないので細か渡したり、何かしらはやく届けようとする
  • データの転送回数は少なく => 何回も何回も取得するとそれが律速になってしまうので、できるならばまとめる
  • データの転送距離は短く => 転送はなるべくはやくなるように距離を短くデータセンターに近くなるようにするなど工夫をする

必要なスクリプトは優先的に届けたり、必要性の低いものは別のjsファイルにしたりすることもできます。

webpack.js.org

1.1 実際にネットワークを調べる

スクリーンショット 2018-08-18 8.40.52.png

ここでは、Networkタブで、「どのような通信があり」、「どのくらい時間がかかったか」を調べます。

重要の指標としては、Status, Size, Time, Waterfallです。

説明は不要かと思いますが、Waterfallとはリクエストのダウンロードの処理の詳細と、かかった時間のことです。

特にこのサイトだと、以下のような画像が非常に遅いことがわかります。

スクリーンショット 2018-08-18 9.17.23.png

  • 遅い画像は明らかにデータサイズが大きい

ので、データサイズを改善する必要があります。

あとから気づいたのですが、時間でソートして表示すればよかったです、すみません

f:id:bobchan1915:20181205215255p:plain

また、各々の詳細は項目を選択しTimingタブを見ることで設定できます。

スクリーンショット 2018-08-18 9.48.24.png

TTFBは最初の1バイトがくるまでの時間を表しています。 6msとわりかし高速であり、フロントエンドエンジニアが何かできることはありません。

例えばTTFBが遅いと通信状態が悪かったり、バックエンドに問題があるのではないかと予想することができます。

サーバーサイド、インフラの経験からクラウド環境のネットワークを考えることもできるとは思いますが、、、今回の記事ではそっとしておきます。

Contents Downloadはコンテンツ(画像なりファイルなり)をダウンロードするまでの時間ですコンテンツのサイズが非常に大きいのでダウンロードに時間がかかっているのでしょう。

これは、レンダリングのところで再度指摘します。

特に、通信のオーバーロードがかかっているわけではないので、ネットワーク処理としては大丈夫そうです。

他にも改善方法としては、

  • 画像の遅延ダウンロードする
  • 不要なライブラリどを削除する

などができると思います。

今回は遅延ダウンロードをするためにvue-lazyloadというものを使いました。

github.com

  • コンポーネットごとまるごとlazyload
  • 特定のタグをlazyload

を簡単にできるので採用しましたが、かなり使いやすかったです。

1.2 通信状態の悪い状態で試してみる

通信制限や、単純に電波が届きにくい場所にいるなど通信状態が悪い時はいくらでもありえます。

そのようなときにDevToolではシミュレーションをして、どのようなUXになるのかをあらかじめ知ることができます。

f:id:bobchan1915:20181203031053p:plain

試しにSlow 3Gでやってみます。

DOMContentLoadに7.63秒で、Loadには28.48秒なので、わりかしはやくに何かしら表示できているのではないでしょうか。

2. レンダリング処理に関して

レンダリングの原則は以下の通りです。

  • 1フレーム内の処理を軽くすること。60FPS以上が理想的
  • ブラウザの内部処理を利用して最適化を測ること

特に"Performance"で知ることができるので使ってみます。 Recordを押して記録をはじめ、適当にスクロールなどをしたら停止しましょう。

8秒ほど録画しました。 しかし、アニメーションが皆無なので特に問題そうなところはありません。

スクリーンショット 2018-08-18 20.32.55.png

すこし大事な箇所を解説をします。

ここでもネットワーク処理について知ることができます。 スクロールなどをして画像が読みこまれるなど、ネットワーク処理が期待される場合に、調べることができます。

スクリーンショット 2018-08-18 20.35.17.png

また、全体として、どのくらいスクリプト処理、ペイント処理、レンダリングに費やしているかを知ることができます。

再度、同じ説明になりますが

  • スクリプト処理: Javascriptの実行によるもの
  • ペイント処理: 実際にレンダリングツリーを端末内の画面のピクセルとして配置すること
  • レンダリング処理: DOMツリーとCSSOMツリーを合わせること

ペイント処理で特に困ることといえば、CSSなどでbox-shadowなどの比較的に重いグラフィカルな処理のことです。一般的には問題になることはありませんがあまりにも多用しすぎると重くなるので注意が必要です。

スクリーンショット 2018-08-18 20.40.12.png

しかし、やっぱりレンダリング処理が重いです。

2.1 ペイント処理に関して

ここだけ、副セクションを定義して調査したいと思います。

繰り返しになりますが、ペイント処理とは

ユーザーの画面に合成されるピクセルを書き込む処理

のことです。

  • 要素を変更
  • 背景、テキスト色、影などの非形状プロパティの変更をしたとき

などでレイアウトがトリガーされたときなどにペイント処理が発生します。

前者(要素の変更)はレイアウトをトリガーしています。

f:id:bobchan1915:20190216144251p:plain

後者はレイアウトはトリガーされませんが、以下のようなパイプラインが実行されます。

f:id:bobchan1915:20190216144535p:plain

レイアウト算出と同様に包括的な計測には"Performance"が参考になります。 ここで"Enable Advanced paint instrumentation"をチェックしてください。

以下が測定した結果です。

スクリーンショット 2018-08-18 21.57.16.png

これによって具体的なCSSの何が遅いのかを知ることはできません。 適宜、遅いところを調査することになります。

3. スクリプト処理に関して

スクリプト処理の原則は以下の通りです。

  • UIブロッキングをするような処理を避ける
  • メモリリークを検出し、節約するようにする

javascriptはメインスレッドで実行するので、その間はレンダリング処理をすることはできないので、その間は塞がれることになります。

3.1 メモリリークを見つける

メモリリークの調査をします。 ヒープ領域の調査は"Memory"タブで計測することができます。

ヒープとは、動的に確保されるメモリ領域で、例えば以下のようにnewするとヒープアロケートがおきます。

var date = new Date()

そして、スコープを抜けるとガーベジコレクションによってメモリ解放されます。

if (true) {
    var data = new Date()
    // ここでメモリ解放   
}

しかし、何かしらの問題でメモリが解放されずに漏れるメモリリークというものがあります。

メモリリークの調査方法は以下の動画で知りました。

www.youtube.com

なぜメモリリークを解決するのが大事というとメモリリークすると

  • スクリプト処理などのパフォーマンスが悪くなる
  • 画面がかくつく

などいろんな問題があります。

"Memory"タブではヒープアロケーションとヒープスナップショットをとることができます。

また、重い処理は再度"Performance"で調べることができます。ヒープ(領域)とはプログラムが一時的に使用するメモリのことで、OSなどによって割り当てられ、主に関数間で変数などを使いまわせるようにするためにヒープを使います。

ここで以下のように選択します。

f:id:bobchan1915:20181203034104p:plain

そして、実際に録画して、何かメモリリークしそうなところをぽちぽち押したりして、ヒープを確認します。

f:id:bobchan1915:20181203034345g:plain

青い線がヒープアロケーションで、メモリが割り当てられています。

f:id:bobchan1915:20181203034530p:plain

青い線がありますが、イベントごとにlistenerを解放しているので青い線はすぐ灰色の線に変わるので問題はありません。

実際にメモリリークがあれば、スナップショットを取れば解決できます。

最初にページをロードしたときにスナップショットを取ります。

f:id:bobchan1915:20181203034856p:plain

そして気になるところ(タブを何回も開いたりして)再度、スナップショットをとります。

f:id:bobchan1915:20181203034914p:plain

そしてSummeryではなくComparisionを選択しましょう。

f:id:bobchan1915:20181203035140p:plain

すると二つのスナップショットで比較をすることができます。

f:id:bobchan1915:20181203035253p:plain

私はないのですが、赤で表示される項目は参照がなく、メモリリークをしている状態です。Swiftだとweakなど弱参照をつけて避けていたようなことが実際に起きているのでバグを直す必要があります。

一例としては、よくやる間違いですが

  • Aという要素のイベントリスナーBを定義する
  • Aが消される
  • Bが取り残される

のケースがありえます。

実際に問題を直す

1. 画像を圧縮する

Vue.jsでは、プロダクション環境ではすでに圧縮されていますが、元の画像を圧縮するといいでしょう。

ChromeのAuditを使うとWebPを推奨されますので、<picture>でWebP非対応のブラウザも対応します。

developers.google.com

WebPとはPNGより26%小さく25~34%ほどJPEGよりも小さく同等の画質の静止画フォーマットです。

対応状況は以下の通りです。

https://blog.ideamans.com/assets/2018-08-10-webp-02.jpg

まず

brew install webp

でインストールができたあとに

cwebp input_file -o output_file.webp -q [画質]

と指定します。[画質]はデフォルトが75のようで、小さいほど荒くなってしまいますが、画像を小さくできます。

実際にHTMLで記述するときは<picture>で囲ってください。

<picture>とは

  • 0個以上の<source>
  • 1個以上の<img>

を含むもので画面や端末にあった画像を提供できる要素です。

<source>の中から適切なものがあればそれを選び、なければ<img>を使います。

picture
   source(srcset="hogehoge.webp")
   img(src="hogehoge.png")

のように使います。

画像だけNetworkタブでみたいときはFilterでImgを指定すると見ることができます。

f:id:bobchan1915:20181205215455p:plain

元の画像の次元数を減らさなければなりませんので、すべて約50%縮小しました。

再度、AuditとPerformanceによる評価をします。 ここではモバイルで検証します。

スクリーンショット 2018-08-20 3.11.46.png

ポイントが時間に対して線形ではないのですが、特にいいスコアを出していますね。 また、ネットワークの方は、画像群がかなりボトルネックになっていますね。

image.png

2. 遅延ロードを実装する

次にコンテンツが下のものは遅延ローディングを実装します。

例えば以下のように実装できます。

.protofolio-img
    img(v-lazy="'https://storage.googleapis.com/keisuke-portofolio/portofolios/' + portfolio.headerImage" alt="portfolio")

まずは画像ファイルから。パラパラ読み込まれていく様子がわかります。

hoge.gif

今回速度を計測すると、たまに1.2秒を下回るくらいです。

スクリーンショット 2018-08-20 3.43.14.png

同様にはてなブログの記事も、遅延ローディングしました。

スクリーンショット 2018-08-20 11.59.59.png

案外、はてなブログのリンクも重かったためです。

ここでキャッシュを使わずに、約0.8秒を実現しました。

また、コンテンツをGoogle Cloud Storageに保存しました。 無料枠をすべて使ってしまっていたので同じファイルに保存していました。

スクリーンショット 2018-08-20 12.03.24.png

Auditスコアはこちらです。

スクリーンショット 2018-08-20 12.14.16.png

2. 再度、圧縮する

27インチのiMacの全画面でみてみて、許容できるまで再度圧縮しました。

なぜもう一回するかというと許容できる粗さまでに圧縮したいと思ったからですし、伸び代(高速化)の一つです。

キャッシュを使ってですが、0.2秒くらいで表示できるようになりました。キャッシュを使わないと0.6秒くらいです。

f:id:bobchan1915:20181203032514p:plain

3. 肥大化しているJSファイルを見つけて縮小させる

Webpackとは、先ほどのも説明したようにバンドルファイルにまとめてくれます。

f:id:bobchan1915:20181215230111p:plain

しかし、ファイルをまとめるといっても

  • まとめ方がおかしい
  • 使わないものが混じっている

などいろんな可能性があります。逆に必要なものをレンポンスに含めなれなかったり、取得が遅かったり問題を起こす反面もあります。

また、まとめることによってHTTPリクエストを減らしたら、ファイルの大きさを小さくできることができ、本記事での目的である「爆速化」に貢献するのは間違いありません。

副次的なものとしてはデバッグしやすかったり、管理しやすかったりします。

f:id:bobchan1915:20181215230458p:plain

例えば以下のようにdist以下はなっています。

dist/
├── index.html
└── static
    ├── css
    │   ├── app.7f765b1ba440fdc48331e45914d9d7ed.css
    │   └── app.7f765b1ba440fdc48331e45914d9d7ed.css.map
    ├── favicon.png
    ├── fonts
    │   ├── feather.5fad700.eot
    │   ├── feather.66cbb62.woff
    │   └── feather.a940fe8.ttf
    ├── img
    │   └── feather.023ba08.svg
    └── js
        ├── app.43a20de5e475988e5126.js
        ├── app.43a20de5e475988e5126.js.map
        ├── manifest.2ae2e69a05c33dfc65f8.js
        ├── manifest.2ae2e69a05c33dfc65f8.js.map
        ├── vendor.f37bcea6cc3c0912b65c.js
        └── vendor.f37bcea6cc3c0912b65c.js.map

まず、以下のコマンドでWebpack Bundle Analyzer を使って可視化をして、一体何が大きいのかを探る必要があります。

npm run build --report

するとlocalhostで以下のようなページが立ち上がります。

f:id:bobchan1915:20181216000043p:plain

まず、支配的な項目から縮小させていきます。

前提として、濃淡によって依存関係が階層構造になっています。

f:id:bobchan1915:20181216000430p:plain

明らかにat-uiが大きすぎます。

f:id:bobchan1915:20181216000646p:plain

参考にat-uiが何であるかというと軽量なUIライブラリです。

AT UI | O2Team

使用箇所は以下の自己紹介部分です。

f:id:bobchan1915:20181216001600p:plain

しかしながらファイルの大きさは約330KBで、gzipでも35KBくらい食っているので、できるならば自分でCSSなどで直接書いて置き換えます。

試しにローカルホストで起動します。

まだ取り除いていないので以下のようになっています。

f:id:bobchan1915:20181216001851p:plain

at-uiをプロジェクトから取り除きました。

すると以下のように1.1MBになっていて約400MB分だけ縮小しました。

f:id:bobchan1915:20181216002758p:plain

再度、可視化をすると以下のようになっています。

f:id:bobchan1915:20181216002945p:plain

これから支配的なのはVue自体ですが、これは私自身では改善が望めないのでここでやめました。

しかしながら、転送速度が約100ミリ秒変わったので、大きさ一歩ではないでしょうか。

デプロイ環境では以下のようになっています。

f:id:bobchan1915:20181216004533p:plain

まとめ

今ではこんな感じですね。

f:id:bobchan1915:20181212210248p:plain

今回は爆速化ということでスピードにフォーカスしたのでPWAやオフラインでの閲覧などは考量してないです。

まとめとしては

  • ネットワーク処理は、基本的にhttp通信に関すること
    • ブログうめこみなど、ボトルネックがある可能性がある
  • サーバーサイドなどの経験があれば、イベントリスナーなどくらいしかスクリプト処理は問題にならないはず
    • といれど、forなどがあれば計算量を想定すべき
  • レンダリング処理は、特に画像を正しく用意しないとボトルネックになりがち
    • 数MB単位の画像を使っているサイトもあったり、以外に対策法は多様である。

もちろん速度は大事ですが、それはUXが何より大事だからです。 1秒以上待たされるけど、ローディングアニメやプログレスバーがリッチだったら、逆に待ち時間は短く感じるかもしれません。

まだまだそっちの方面で勉強しないといけないと痛感しました。

web-grpcなどやってみたいことがたくさんありますが、卒業のために卒論に集中します。

ご覧いただき、ありがとうございました。

おまけ

Lighthouse CIの存在

あまり知られていませんがGithub CheckAPIを使ったLighthouse CIというものがあります。

github.com

公式リポジトリから参照しますがCheckAPIを使って以下のように常にCIツールのようにAudits Scoreを知ることができます。

エンジニアとしてはフィードバックを常時もらえるようになるのでぜひ設定してみてください。

f:id:bobchan1915:20181205220255p:plain