GitHub Pages上のサイトのLighthouseスコアをほぼ100点満点に改善した話

ふとSRE的なことをやりたくなって、GitHub Pages上の自分のポートフォリオのパフォーマンス改善を行いました。

最終的な結果は以下です。
![result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/150149f6-2934-1f51-bffe-41469589b472.png)


## ポートフォリオサイト
中身がアレなのは今回は無視してください！！！
site: https://reireias.github.io
repo: https://github.com/reireias/reireias.github.io

## 計測環境の用意
改善するにあたり何らかの指標が必要です。

基本はLightHouseを利用する方向で問題ないですが、今回は実行環境に左右されず、APIがある[PageSpeed Insights](https://developers.google.com/speed/pagespeed/insights/?hl=ja)を採用することにしました。

![pagespeedinsights.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/d9e476d0-e1ae-6c76-1a2e-cd82c8342713.png)


PageSpeed Insightsの結果はLightHouseの結果を内包しており、結果のjson中に含まれる`lighthouseResult`オブジェクトで確認できます。
また、上記のjsonを[Lighthouse Report Viewer](https://googlechrome.github.io/lighthouse/viewer/)に入力することで、LighthouseのレポートをWebUIで確認できます。

これを毎回手動で行うのは大変なので、Node.jsでスクリプト化し、`yarn benchmark`で素早く実行できるようにします。
https://github.com/reireias/reireias.github.io/blob/source/scripts/benchmark.js

```javascript:src/benchmark.js
/* eslint no-console: 0 */
const fs = require('fs')
const qs = require('qs')
const axios = require('axios')
const dateFormat = require('dateformat')
require('dotenv').config()

const LIMIT = 10
const TARGET_URL = process.argv[2]
const VERSION = process.argv[3]
const PAGE_SPEED_INSIGHTS_URL =
  'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'

const waitDeploy = async () => {
  let count = 0
  while (count < LIMIT) {
    const res = await axios.get(TARGET_URL)
    if (res.data.indexOf(VERSION) > 0) {
      return true
    }
    await new Promise(resolve => setTimeout(resolve, 60 * 1000))
    count += 1
  }
  return false
}

const saveJsonFile = (obj, client) => {
  const current = new Date()
  const dateString = dateFormat(current, 'yyyymmddhhMMss')
  const path = `./tmp/raw/${client}-raw-${dateString}-${VERSION}.json`
  fs.writeFileSync(path, JSON.stringify(obj))
  const lhPath = `./tmp/${client}-lh-${dateString}-${VERSION}.json`
  fs.writeFileSync(lhPath, JSON.stringify(obj.lighthouseResult))
}

const main = async () => {
  if (await waitDeploy()) {
    ;['desktop', 'mobile'].forEach(async client => {
      const params = {
        url: TARGET_URL,
        locale: 'ja',
        category: [
          'accessibility',
          'best-practices',
          'performance',
          'pwa',
          'seo'
        ],
        strategy: client
      }
      if (process.env.PAGE_SPEED_INSIGHTS_URL) {
        params.key = process.env.PAGE_SPEED_INSIGHTS_URL
      }
      const result = await axios.get(PAGE_SPEED_INSIGHTS_URL, {
        params: params,
        paramsSerializer: params =>
          qs.stringify(params, { arrayFormat: 'repeat' })
      })

      if (result.status !== 200) {
        console.error(result)
        throw new Error('Insight failed.')
      }

      saveJsonFile(result.data, client)
    })
  } else {
    throw new Error('Not deployed yet.')
  }
}

;(async () => {
  try {
    await main()
  } catch (err) {
    // eslint-disable-next-line no-console
    console.error(err)
  }
})()
```

`package.json`では`yarn benchmark`を定義します。

```
    "benchmark": "node scripts/benchmark.js https://reireias.github.io $(git rev-parse HEAD)"
```

GitHub Pagesのデプロイ後の反映には数分かかるので、commitハッシュをmetaタグに埋め込み、最新のページが取得できるようになってからPageSpeed InsightsのAPIを実行するように工夫しています。

実行すると、`tmp`ディレクトリ以下にLighthouseの結果がdesktop版とmobile版の2種が出力されます。
(後半はコミットハッシュになっているので、あとからどの時点のコードの結果なのか追えるようにしてあります。)

```
desktop-lh-20190612093848-38a02b5f9b08ffd3e468f7e70e4861eee74cd8d3.json
mobile-lh-20190612093845-38a02b5f9b08ffd3e468f7e70e4861eee74cd8d3.json
```

また、`tmp/raw`ディレクトリ以下にはそれぞれのPageSpeed Insightsの結果が出力されます。

今回はLighthouseの結果のmobile版を指標としてパフォーマンス改善を行っていきます。

## CI/CDの準備
高速に改善サイクルを回すためにはCI/CDが不可欠です。
このリポジトリではTravisCIを使って、テスト(lint)が通ったらGitHub Pagesにデプロイする設定にしています。

```yaml:.travis.yml
---
language: node_js
node_js:
  - 'stable'
env:
  secure: xxxxxx
cache: yarn
script:
  - yarn lint
  - yarn generate
  - yarn dropcss
deploy:
  provider: pages
  target_branch: master
  skip_cleanup: true
  github_token: $GITHUB_TOKEN
  local_dir: dist
  on:
    branch: source
notifications:
  email: false
  slack:
    secure: xxxxxxx
```

## 現状把握
では、改善前の結果を見てみましょう。
この時点のソースコードは下記になります。
https://github.com/reireias/reireias.github.io/tree/1b31298ee49fb4788bc6cf150b05c6f5e4a8844d

全体の結果は次のようになっていました。
パフォーマンスの数値が特に低いです。
SEOなどはきちんと実装していたので、100点でした。
![1st-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/48329987-bbb2-7a7c-0e82-b658d5540b0f.png)

結果の下の方に行くとパフォーマンスの詳細が確認できます。
![1st-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/cea9f987-5fec-ec45-a62a-f9ba59eebb14.png)


また、どのような改善を行えばいいかも書いてあります。
![1st-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/0b8a8fa7-8a25-ac43-cebf-c2e1867c321c.png)


この改善できる項目を順に対応していきます。

## 改善1: 次世代フォーマットでの画像の配信
[詳細](https://developers.google.com/web/tools/lighthouse/audits/webp?utm_source=lighthouse&utm_medium=unknown)を見ると、JPEG 2000、JPEG XR、WebPなどの次世代画像フォーマットを利用することが推奨されています。

今回は変換の容易性やブラウザのサポート状況からWebPフォーマットを採用することとします。
対応状況: https://caniuse.com/#feat=webp
Safariがネックですね。（IEなんてこの世に無い、いいね？）

pictureタグを利用することで未対応ブラウザには従来の画像を、対応ブラウザにはWebPを出し分けることができるので、これで対応します。
参考: https://blog.jxck.io/entries/2016-03-26/webp.html

webpへの変換は`cwebp`コマンドで行います。
ワンライナーでディレクトリ以下のpngファイルを一気に変換しています。

```bash
# one liner
for f in $(find ./ -name "*.png"); do base=$(basename $f | sed -e 's/\.png//g'); cwebp $f -o ${base}.webp; done 
```

pictureタグを使った出し分けの実装は下記のようになります。

```html
<!-- before -->
<img :src="skill.image" alt="" />
            
<!-- after -->
<picture>
  <source type="image/webp" :srcset="`${skill.image}.webp`" />
  <img :src="`${skill.image}.png`" :alt="skill.name" />
</picture>
```

この時点での実装は下記になります。
https://github.com/reireias/reireias.github.io/tree/664262bdb04d9bc88cfc22c0c3366b7b015620b7

計測結果は下記のようになりました。
![2nd.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/6b96cdef-0f69-a66d-e170-d2d9192706df.png)

むしろパフォーマンスが48点から44点に悪化しました。

## 改善2: レンダリングを妨げるリソースの除外
画像系はいったん置いておき、別の部分を改善していきます。
下記画像にあるように、外部のウェブフォントとcssが遅れの原因となっています。
![3rd-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/f210c32b-7501-b45a-cd03-019a05da087a.png)


ウェブフォントをやめ、以下を参考にクライアントの環境にインストールされているフォントを利用するようにcssを設定する方針にします。
参考: https://sole-color-blog.com/blog/1380/

この方法だとVuetifyのiconが利用できなくなりますが、今回は利用していないので、無視します。

また、TwitterやGitHubのアイコンも今回は諦めます。
どうしてもアイコンで表示したいなら、後述の画像を遅延ロードする方法で対応します。

改善後のソースコードはこちら。
https://github.com/reireias/reireias.github.io/tree/a48ab96b6db533ea4bd9e0494979c811922a2003

改善後の結果はこちら。
![3rd-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/f0153f7a-a68a-e6b7-9d99-52363d09e8c2.png)


パフォーマンスが44->82にまで向上しました！

ウェブフォントの利用（特に日本語）はやはりパフォーマンスとのトレードオフになってしまう傾向にあるようです。
（もちろん、遅延ロードするなりで対策はできるのですが）

## 改善3: 適切なサイズの画像
WebP化したことで、パフォーマンスに改善は見られなかったので、今度はサイズの方を対応していきます。

### 画像を正方形の透過pngにする
もとの実装では横長の画像であれば問題なく表示される実装でしたが、全体のサイズ統一のため、これらをいったん正方形にします。

第一引数のファイルを正方形にキャンバス拡大するスクリプト

```bash
#!/bin/bash -e

# pngを正方形になるようにキャンバスサイズの拡大を行うスクリプト
# 拡大部分は透過に指定され、もと画像は中央に配置される

file=$1
width=$(identify -format "%w" $file)
height=$(identify -format "%h" $file)

if [[ $width -gt $height ]]; then
    size=$width
    convert $file -background none -gravity center -extent ${size}x${size} sq.$(basename $file)
fi

if [[ $width -lt $height ]]; then
    size=$height
    convert $file -background none -gravity center -extent ${size}x${size} sq.$(basename $file)
fi
```

すべてのpngファイルに対し上記スクリプトを実行し、リネーム

```bash
find ./ -name "*.png" | xargs -I{} ~/scripts/resize-square.sh {}
for f in $(find ./ -name "sq.*.png"); do name=$(basename $f | sed -e 's/sq\.//g'); mv $f $name; done
```

### pngを200x200と100x100の2サイズ作成
今回の実装では、表示領域は最大でも100x100程度での表示なので、200x200にすべてリサイズします。(100x100ではWebP変換後に少しぼやけたため)

```bash
# mongrifyはconvertコマンドの上書き版
mogrify -resize 200x200 *.png
```

また、モバイル用に100x100のpngも用意します。

```bash
for f in $(find ./ -name "*.png"); do base=$(basename $f | sed -e 's/\.png//g'); convert -resize 100x100 $f ${base}-small.png; done
```

### それぞれをWebP画像に変換
`cwebp`コマンドでwebp画像を生成します

```bash
for f in $(find ./ -name "*.png"); do base=$(basename $f | sed -e 's/\.png//g'); cwebp $f -o ${base}.webp; done
```

### 画面サイズに応じて画像を出し分ける
画面の横幅に応じて利用する画像をpictureタグを用いて切り替えます。
この実装により、大きいWebP画像、小さいWebP画像、png画像がデバイス/ブラウザに応じて表示されるようになります。

```html
<!-- before -->
<picture>
  <source type="image/webp" :srcset="`${skill.image}.webp`" />
  <img :src="`${skill.image}.png`" :alt="skill.name" />
</picture>

<!-- after -->
<picture>
  <source media="(min-width: 600px)" type="image/webp" :srcset="`${skill.image}.webp`" />
  <source type="image/webp" :srcset="`${skill.image}-small.webp`" />
  <img :src="`${skill.image}.png`" :alt="skill.name" />
</picture>
```

### 結果
改善後のソースコードはこちら。
https://github.com/reireias/reireias.github.io/tree/21781e948fff0f517f4d5e9789989e7a437dc46d

改善後の結果はこちら。
![4th.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/e1474e6d-b3cb-5e8e-f0e1-dd996b8fa895.png)

パフォーマンスは82->83と微々たる改善でした。

## 改善4: インタラクティブになるまでの時間の短縮
改善3までの時点での結果詳細は以下のようになっていました。
![5th-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/9fa6fb32-67cc-d249-cfdd-5e6edc7a4c1b.png)


計測結果を見るに、インタラクティブになるまでの時間がネックっぽいので、次はここを改善します。

pictureタグに対応した画像遅延ロードライブラリ`Lazysizes`を導入することで、操作可能になるまでの時間の短縮を図ります。
参考: https://oxynotes.com/?p=10810

パッケージを追加。

```shell
yarn add lazysizes
```

READMEを確認しながら、下記のようにlazyloadクラスを適用します。

```html
<!-- before -->
<picture>
  <source
    media="(min-width: 600px)"
    type="image/webp"
    :srcset="`${skill.image}.webp`"
  />
  <source type="image/webp" :srcset="`${skill.image}-small.webp`" />
  <img :src="`${skill.image}.png`" :alt="skill.name" />
</picture>

<!-- after -->
<picture>
  <source
    media="(min-width: 600px)"
    type="image/webp"
    :data-srcset="`${skill.image}.webp`"
  />
  <source
    type="image/webp"
    :data-srcset="`${skill.image}-small.webp`"
  />
  <img
    class="lazyload"
    :data-src="`${skill.image}.png`"
    :alt="skill.name"
  />
</picture>
```

この実装により、画面外にある画像ファイルはスクロールしていかないとロードされないようになりました。

改善後のソースコードはこちら。
https://github.com/reireias/reireias.github.io/tree/b8dcc4406ab6ce06cc10e01a449d434b33ad8d22

改善後の結果はこちら。
![5th-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/4662a7e4-0495-95ae-00f6-361a5eadbb5e.png)


あと少し！！

## 改善5: html 要素に [lang] 属性が指定されていません
パフォーマンスではなく、ユーザー補助の項目です。
これは単純に実装忘れなので、以下のように`nuxt.config.js`中のhead内に`htmlAttrs`を追加します。

```javascript
export default {
  mode: 'universal',
  head: {
    title: pkg.name,
    htmlAttrs: {
      lang: 'ja'
    },
...
```

改善後の結果はこちら。
![6th.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/b66b7d00-d811-da40-f067-58ddf2283817.png)
（しれっとパフォーマンスが99になってますが、計測誤差でしょう）

## 改善6: 最大推定 FID を少しでも減らす
パフォーマンスが安定して99or100が出るように少しでも改善します。

### 使用していない CSS を削除してください 15 KB 減らせます
調査したところ、利用しているVue.js用のUIライブラリVuetify.jsにはこれ以上cssを小さくする方法はないようです。

そこで今回は、htmlとcssファイルを入力すると、未使用のcssを削除したcssを出力してくれる[dropcss](https://github.com/leeoniya/dropcss)を利用してcssのコード量を削減します。

ちょっとやっかいなのは、Nuxt.jsで静的サイトをジェネレートした際のcssの出力です。
デフォルトではheadタグ内のstyleタグにcssが記述されます。
ただ、この状態ではdropcssを適用することができないため、styleタグをcssに分離する必要があります。
Nuxt.jsのビルド設定にextractCSSオプションを渡すとcssを外部ファイル化し、それをlinkタグで読み込んでくれるようになるのですが、バグなのか一部cssにpreload属性が付かず、パフォーマンスが悪化してしまいました。

なので、今回はstyleタグからinnerHTMLを取り出し、それをcssファイルとして読み込み、dropcssによる圧縮後にまたinnerHTMLに戻すという強引な手法で行います。

上記を実行するスクリプトは以下になります。

```javascript:scripts/dropcss.js
/* eslint no-console: 0 */
const fs = require('fs')
const dropcss = require('dropcss')
const cheerio = require('cheerio')

const htmlPath = './dist/index.html'
const html = fs.readFileSync(htmlPath).toString()
const $ = cheerio.load(html, { decodeEntities: false })
$('style').each((_, element) => {
  const css = element.children[0].data
  const cleaned = dropcss({
    html,
    css
  })
  element.children[0].data = cleaned.css
})
fs.writeFileSync(htmlPath, $.html())

```

改善後のソースコードはこちら。
https://github.com/reireias/reireias.github.io/tree/e1d69f86338b98997d8143727d0fb46e880229cd

改善後の結果はこちら。
![7th.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214526/b159a288-82a1-d886-e7a8-7fa00c0ce194.png)


何度か計測した結果、パフォーマンスの値が99で安定しました！

## 改善8: トップの画像にも遅延ロードを導入
Chrome Developer Toolで遅いネットワークをシミュレートしながらサイトを見ていたところ、トップの画像のロードが他をブロックしているように見えたので、ここにも遅延ロードを入れます。

しかし、改善4と同様に遅延ロードを入れてしまうと、ファーストビューに含まれているため、image brokenアイコンが一瞬表示されてしまい、よろしくありません。

そこで、cssで調整します。
`lazysizes`の機能でロード中とロード後でclassが変わるので、そこにcssを適用します。
今回はopacityを設定してフェードインするようにします。

```css
.lazyload,
.lazyloading {
  opacity: 0;
}
.lazyloaded {
  opacity: 1;
  transition: opacity 300ms;
}
```
パフォーマンスは変化しなかったので、結果は割愛します。

## まとめ
GiHub PagesにホストしているNuxt.jsで作成した静的ページのパフォーマンスを改善してみました。
改善の方法はどれも簡単なものなので、みなさんも自分のサイトや担当サービスで実施してみてはいかがでしょうか？

## おまけ
ちなみに、スコアは高いですがCDNのキャッシュ時間の設定で警告？がまだ残っています。
これはGitHub Pagesを使ってる以上回避不可能なようなので、今回は無視することにしました。
参考: https://qiita.com/Duct-and-rice/items/9f9763d322eb2c98a90f