32
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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

最終的な結果は以下です。
result.png

ポートフォリオサイト

中身がアレなのは今回は無視してください!!!
site: https://reireias.github.io
repo: https://github.com/reireias/reireias.github.io

計測環境の用意

改善するにあたり何らかの指標が必要です。

基本はLightHouseを利用する方向で問題ないですが、今回は実行環境に左右されず、APIがあるPageSpeed Insightsを採用することにしました。

pagespeedinsights.png

PageSpeed Insightsの結果はLightHouseの結果を内包しており、結果のjson中に含まれるlighthouseResultオブジェクトで確認できます。
また、上記のjsonをLighthouse Report Viewerに入力することで、LighthouseのレポートをWebUIで確認できます。

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

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にデプロイする設定にしています。

.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

結果の下の方に行くとパフォーマンスの詳細が確認できます。
1st-2.png

また、どのような改善を行えばいいかも書いてあります。
1st-3.png

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

改善1: 次世代フォーマットでの画像の配信

詳細を見ると、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ファイルを一気に変換しています。

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

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

<!-- 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

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

改善2: レンダリングを妨げるリソースの除外

画像系はいったん置いておき、別の部分を改善していきます。
下記画像にあるように、外部のウェブフォントとcssが遅れの原因となっています。
3rd-1.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

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

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

改善3: 適切なサイズの画像

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

画像を正方形の透過pngにする

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

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

#!/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ファイルに対し上記スクリプトを実行し、リネーム

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変換後に少しぼやけたため)

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

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

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画像を生成します

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画像がデバイス/ブラウザに応じて表示されるようになります。

<!-- 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

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

改善4: インタラクティブになるまでの時間の短縮

改善3までの時点での結果詳細は以下のようになっていました。
5th-1.png

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

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

パッケージを追加。

yarn add lazysizes

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

<!-- 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

あと少し!!

改善5: html 要素に [lang] 属性が指定されていません

パフォーマンスではなく、ユーザー補助の項目です。
これは単純に実装忘れなので、以下のようにnuxt.config.js中のhead内にhtmlAttrsを追加します。

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

改善後の結果はこちら。
6th.png
(しれっとパフォーマンスが99になってますが、計測誤差でしょう)

改善6: 最大推定 FID を少しでも減らす

パフォーマンスが安定して99or100が出るように少しでも改善します。

使用していない CSS を削除してください 15 KB 減らせます

調査したところ、利用しているVue.js用のUIライブラリVuetify.jsにはこれ以上cssを小さくする方法はないようです。

そこで今回は、htmlとcssファイルを入力すると、未使用のcssを削除したcssを出力してくれるdropcssを利用してcssのコード量を削減します。

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

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

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

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

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

改善8: トップの画像にも遅延ロードを導入

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

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

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

.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

32
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?